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 ratatui::layout::Rect;
8use reqwest::Client;
9
10use crate::cache::DEFAULT_TTL;
11use crate::config::{Config, CustomProviderConfig};
12use crate::error::{AppError, Result};
13use crate::theme::Theme;
14use crate::tui::settings::SettingsRow;
15use crate::vendor::{VendorId, VendorOutcome};
16
17/// What we display per vendor — raw snapshot + fetch metadata for native
18/// panel rendering, or an error message when the fetch failed.
19///
20/// `Ready` is boxed because the snapshot is much larger than the other two
21/// variants (silences `clippy::large_enum_variant`).
22#[derive(Debug, Clone)]
23pub enum TabState {
24    Loading,
25    Ready(Box<ReadyTab>),
26    Error {
27        message: String,
28        /// Vendor plan already known from credentials when the quota fetch
29        /// failed (Claude OAuth `subscriptionType`). Absent when the vendor
30        /// has no plan without a snapshot.
31        plan: Option<String>,
32    },
33}
34
35#[derive(Debug, Clone)]
36pub struct ReadyTab {
37    pub snapshot: crate::usage::VendorSnapshot,
38    pub stale: bool,
39    pub last_error: Option<(u16, String)>,
40    /// Absolute moment the cache was written (i.e. the API response landed).
41    /// Snapshotted once at TabState build time so the rendered "Updated …"
42    /// timestamp stays stable across redraws instead of drifting with the
43    /// passing wall clock.
44    pub fetched_at: Option<chrono::DateTime<chrono::Utc>>,
45    /// Bar-number settings this vendor was configured with — the tank size a
46    /// prepaid balance is metered against, and which of the two numbers goes on
47    /// the bar. Resolved from config at fetch time rather than stored in the
48    /// snapshot, so editing config.toml takes effect on the next redraw instead
49    /// of waiting for the cache to expire.
50    pub display: crate::balance::DisplayPrefs,
51}
52
53/// Where a tab's usage comes from: a built-in vendor, or a user-declared
54/// `[[custom]]` HTTP provider. A custom tab carries its own display names so
55/// the TUI and the report never need the config again just to label it.
56#[derive(Debug, Clone, PartialEq, Eq, Hash)]
57pub enum TabSource {
58    Builtin(VendorId),
59    Custom {
60        id: String,
61        name: String,
62        short_name: String,
63    },
64}
65
66impl TabState {
67    pub fn error(message: impl AsRef<str>) -> Self {
68        Self::error_with_plan(message, None)
69    }
70
71    pub fn error_with_plan(message: impl AsRef<str>, plan: Option<String>) -> Self {
72        let plan = plan.and_then(|plan| {
73            let cleaned = crate::display::sanitize_untrusted_field(plan.trim());
74            if cleaned.is_empty() || cleaned.eq_ignore_ascii_case("unknown") {
75                None
76            } else {
77                Some(cleaned)
78            }
79        });
80        Self::Error {
81            message: message.as_ref().to_string(),
82            plan,
83        }
84    }
85}
86
87/// Identity of one TUI tab. Usually a whole vendor; Claude and OpenRouter can
88/// also name a configured account. `account: None` is a plain vendor tab or
89/// that vendor's default account.
90/// `desktop` marks an account whose usage comes from the Claude Desktop app's
91/// own token rather than a `claude` CLI credential.
92/// Custom providers never carry an account or a desktop flag.
93#[derive(Debug, Clone, PartialEq, Eq, Hash)]
94pub struct TabId {
95    pub source: TabSource,
96    pub account: Option<String>,
97    pub desktop: bool,
98}
99
100impl TabId {
101    /// A plain vendor tab (default account for Anthropic).
102    pub fn vendor(vendor: VendorId) -> Self {
103        Self {
104            source: TabSource::Builtin(vendor),
105            account: None,
106            desktop: false,
107        }
108    }
109
110    /// A named Anthropic account tab (`[[anthropic.accounts]]` label).
111    pub fn account(label: impl Into<String>) -> Self {
112        Self::account_for(VendorId::Anthropic, label)
113    }
114
115    /// A named account for a vendor that supports account arrays.
116    pub fn account_for(vendor: VendorId, label: impl Into<String>) -> Self {
117        Self {
118            source: TabSource::Builtin(vendor),
119            account: Some(label.into()),
120            desktop: false,
121        }
122    }
123
124    /// An Anthropic account whose usage is read from the Claude Desktop app's
125    /// own token store (a saved `~/.claude-acc/profiles/<label>` account).
126    pub fn desktop_account(label: impl Into<String>) -> Self {
127        Self {
128            source: TabSource::Builtin(VendorId::Anthropic),
129            account: Some(label.into()),
130            desktop: true,
131        }
132    }
133
134    /// A user-declared `[[custom]]` provider tab.
135    pub fn custom(spec: &CustomProviderConfig) -> Self {
136        Self {
137            source: TabSource::Custom {
138                id: spec.id.clone(),
139                name: spec.name.clone(),
140                short_name: spec.short_name.clone(),
141            },
142            account: None,
143            desktop: false,
144        }
145    }
146
147    /// The built-in vendor behind this tab; `None` for a custom provider.
148    /// (Named `vendor_id` because `TabId::vendor` is the constructor.)
149    pub fn vendor_id(&self) -> Option<VendorId> {
150        match &self.source {
151            TabSource::Builtin(vendor) => Some(*vendor),
152            TabSource::Custom { .. } => None,
153        }
154    }
155}
156
157/// Expand enabled vendors into the tab list. Claude, OpenRouter, and Codex
158/// (OpenAI) yield their default account followed by configured named accounts;
159/// every other vendor is a single tab. With no extra accounts and
160/// `overview_vendors` unset, the result equals `config.enabled_vendors()` in
161/// canonical order. When the list is set, listed vendors lead and unlisted
162/// enabled vendors follow in that same canonical order.
163///
164/// Config-only and pure — no Desktop profiles. Production uses
165/// [`tabs_with_desktop`]; this stays for the hermetic unit tests and any caller
166/// that only wants configured accounts.
167pub fn tabs_from_config(config: &Config) -> Vec<TabId> {
168    build_tabs(config, &[])
169}
170
171/// The production aggregate-view tab list: configured accounts plus every saved
172/// Claude Desktop profile that has usable credentials. Desktop discovery is
173/// best-effort and macOS-only; anywhere else this equals [`tabs_from_config`].
174pub fn tabs_with_desktop(config: &Config) -> Vec<TabId> {
175    build_tabs(config, &desktop_profile_labels(config))
176}
177
178/// Enabled vendors in `[ui] overview_vendors` order. Unlisted vendors keep
179/// the canonical order and follow the listed ones. Unset means canonical.
180fn vendors_for_tabs(config: &Config) -> Vec<VendorId> {
181    let enabled = config.enabled_vendors();
182    let Some(wanted) = config.ui.overview_vendors.as_ref() else {
183        return enabled;
184    };
185    let mut ordered = Vec::with_capacity(enabled.len());
186    for vendor in wanted {
187        if enabled.contains(vendor) && !ordered.contains(vendor) {
188            ordered.push(*vendor);
189        }
190    }
191    for vendor in enabled {
192        if !ordered.contains(&vendor) {
193            ordered.push(vendor);
194        }
195    }
196    ordered
197}
198
199/// Core expansion, parameterized on the Desktop account labels so it stays pure
200/// and unit-testable. Desktop accounts follow the CLI accounts and count toward
201/// "Anthropic has accounts" for the default-tab suppression.
202///
203/// In aggregate views, a label present in both a `[[anthropic.accounts]]` CLI
204/// entry and a Desktop profile is sourced from **Desktop**, and the CLI entry is
205/// dropped. The same account in two stores means two of them refreshing one
206/// rotating refresh token — each rotation invalidates the other's copy — and
207/// the CLI copy can even refresh to a stale/wrong identity that still
208/// authenticates but reports another account's (often zero) usage, which no
209/// credential-health check can catch. The app-maintained Desktop token is the
210/// one source that avoids both the rotation war and that silent misattribution.
211fn build_tabs(config: &Config, desktop_labels: &[String]) -> Vec<TabId> {
212    let desktop_set: HashSet<&str> = desktop_labels.iter().map(String::as_str).collect();
213    let mut tabs = Vec::new();
214    for vendor in vendors_for_tabs(config) {
215        if vendor == VendorId::Anthropic {
216            let accounts: Vec<_> = config
217                .anthropic
218                .all_accounts()
219                .into_iter()
220                .filter(|a| !desktop_set.contains(a.label.as_str()))
221                .collect();
222            // The default (unnamed) Claude tab is suppressible once every
223            // account is named — but never when it would leave Anthropic with
224            // no tab at all. Desktop accounts count as named accounts here.
225            if config.anthropic.show_default_account
226                || (accounts.is_empty() && desktop_labels.is_empty())
227            {
228                tabs.push(TabId::vendor(vendor));
229            }
230            for acct in accounts {
231                tabs.push(TabId::account(acct.label));
232            }
233            for label in desktop_labels {
234                tabs.push(TabId::desktop_account(label.clone()));
235            }
236        } else if let Some(accounts) = config.api_key_accounts(vendor) {
237            if config.show_default_api_key_account(vendor) || accounts.is_empty() {
238                tabs.push(TabId::vendor(vendor));
239            }
240            for account in accounts {
241                tabs.push(TabId::account_for(vendor, account.label.clone()));
242            }
243        } else if vendor == VendorId::Openai {
244            if config.openai.show_default_account || config.openai.accounts.is_empty() {
245                tabs.push(TabId::vendor(vendor));
246            }
247            for account in &config.openai.accounts {
248                tabs.push(TabId::account_for(vendor, account.label.clone()));
249            }
250        } else if vendor == VendorId::Copilot {
251            if config.copilot.show_default_account || config.copilot.accounts.is_empty() {
252                tabs.push(TabId::vendor(vendor));
253            }
254            for account in &config.copilot.accounts {
255                tabs.push(TabId::account_for(vendor, account.label.clone()));
256            }
257        } else {
258            tabs.push(TabId::vendor(vendor));
259        }
260    }
261    // Custom providers follow every built-in vendor, in config order.
262    for spec in config.enabled_custom() {
263        tabs.push(TabId::custom(spec));
264    }
265    tabs
266}
267
268/// Labels of saved Claude Desktop profiles with usable credentials. macOS-only
269/// (elsewhere there is no Desktop app); best-effort, so an unreadable profile
270/// store just yields none rather than failing the whole tab list.
271#[cfg(target_os = "macos")]
272fn desktop_profile_labels(config: &Config) -> Vec<String> {
273    let Ok(paths) = crate::claude_desktop::Paths::resolve(&config.anthropic) else {
274        return Vec::new();
275    };
276    if !paths.available() {
277        return Vec::new();
278    }
279    crate::claude_desktop::load_profiles(&paths.profiles_dir)
280        .into_iter()
281        .filter(|p| p.has_credentials)
282        .map(|p| p.label)
283        .collect()
284}
285
286#[cfg(not(target_os = "macos"))]
287fn desktop_profile_labels(_config: &Config) -> Vec<String> {
288    Vec::new()
289}
290
291/// Mouse hit-test surface recorded by the last draw and consumed by the event
292/// loop. Draw is the single source of truth for where things are on screen, so
293/// the render pass records the interactive rects here instead of the input
294/// handler re-deriving layout.
295#[derive(Debug, Default, Clone)]
296pub struct HitTargets {
297    /// Vendor navigation entries: Overview first, then each tab by index.
298    pub nav_entries: Vec<(NavTarget, Rect)>,
299    /// Footer actions with a mouse-accessible keyboard equivalent.
300    pub footer_actions: Vec<(FooterAction, Rect)>,
301    /// Settings overlay interactive rows: the primary selector, key fields,
302    /// and save row.
303    pub settings_rows: Vec<(SettingsRow, Rect)>,
304}
305
306/// What a click in the vendor navigation selects.
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
308pub enum NavTarget {
309    Overview,
310    Tab(usize),
311}
312
313/// What a click in the footer invokes.
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub enum FooterAction {
316    Refresh,
317    RefreshAll,
318    Settings,
319    Quit,
320}
321
322#[derive(Debug)]
323pub struct App {
324    pub tabs_meta: Vec<TabId>,
325    pub active: usize,
326    pub tabs: Vec<TabState>,
327    /// Tab identities with a request currently in flight. Kept separate from
328    /// `tabs` so a successful snapshot remains visible while it is refreshed.
329    refreshing_tabs: HashSet<TabId>,
330    /// Monotonically increasing identity for a complete tab-set replacement.
331    /// Background fetches carry this with their tab identity so results from a
332    /// previous Settings reload cannot land in a new tab at the old index.
333    pub tab_generation: u64,
334    /// When `true`, the Overview pane is selected (the virtual first tab that
335    /// summarizes every vendor at once) instead of a per-vendor detail tab.
336    pub overview: bool,
337    /// Which vendors the Overview lists (`[ui] overview_vendors`); `None` = all.
338    pub overview_vendors: Option<Vec<VendorId>>,
339    pub theme: Theme,
340    pub quit: bool,
341    /// When `Some`, the Settings overlay is open and consuming key events.
342    pub settings: Option<crate::tui::settings::SettingsState>,
343    /// Local context monitoring is separately opt-in and never changes the
344    /// vendor tab set.
345    pub context_enabled: bool,
346    /// Monotonic across overlay close/reopen cycles so an old detached scan
347    /// can never share the new overlay's first generation number.
348    pub context_generation: u64,
349    /// When `Some`, the local Claude Code context overlay owns keyboard input.
350    pub context: Option<crate::tui::context::ContextState>,
351    /// Presentation style for the vendor navigation box (`[ui] vendor_box`).
352    pub vendor_box: crate::config::VendorBoxStyle,
353    /// Interactive rects from the most recent draw, for mouse hit-testing.
354    pub hit: std::rc::Rc<std::cell::RefCell<HitTargets>>,
355}
356
357impl App {
358    pub fn new(tabs_meta: Vec<TabId>) -> Self {
359        // Production: resolve the palette from the environment (Omarchy theme
360        // if present, else One Dark).
361        Self::with_theme(tabs_meta, Theme::default().merged_with_omarchy())
362    }
363
364    /// Like [`App::new`] but with an explicit theme. Lets tests build an `App`
365    /// without reading the real Omarchy theme file
366    /// (`~/.local/state/omarchy/current/theme/colors.toml`) — `new` resolves
367    /// that path and the `$HOME` env var via `merged_with_omarchy`, which is
368    /// not hermetic. Production code uses `new`/`new_with_primary`.
369    pub fn with_theme(tabs_meta: Vec<TabId>, theme: Theme) -> Self {
370        let n = tabs_meta.len();
371        Self {
372            tabs_meta,
373            active: 0,
374            tabs: vec![TabState::Loading; n],
375            refreshing_tabs: HashSet::new(),
376            tab_generation: 0,
377            overview: false,
378            overview_vendors: None,
379            theme,
380            quit: false,
381            settings: None,
382            context_enabled: false,
383            context_generation: 0,
384            context: None,
385            vendor_box: crate::config::VendorBoxStyle::Sidebar,
386            hit: std::rc::Rc::new(std::cell::RefCell::new(HitTargets::default())),
387        }
388    }
389
390    /// Construct with an initial active tab — usually `[ui] primary` from
391    /// config. Silently falls through to index 0 if the requested vendor
392    /// isn't present (e.g. it was disabled).
393    pub fn new_with_primary(tabs_meta: Vec<TabId>, primary: Option<VendorId>) -> Self {
394        let mut app = Self::new(tabs_meta);
395        // Default landing is the Overview (show everything at once). An explicit
396        // `[ui] primary` opts into opening on that vendor's tab instead.
397        if primary.is_some() {
398            app.select_primary(primary);
399        } else {
400            app.overview = true;
401        }
402        app
403    }
404
405    pub fn active_tab_id(&self) -> Option<&TabId> {
406        self.tabs_meta.get(self.active)
407    }
408
409    pub fn active_vendor(&self) -> Option<VendorId> {
410        self.tabs_meta.get(self.active).and_then(TabId::vendor_id)
411    }
412
413    /// Replace the tab set — used after a Settings save reloads config, so
414    /// tabs added or removed in `config.toml` while the TUI is open (e.g. a
415    /// new `[[anthropic.accounts]]` entry) appear without a restart. Every
416    /// tab resets to `Loading` (the caller re-spawns fetches). The selected tab
417    /// is preserved by identity when possible; otherwise its old position is
418    /// clamped in case the list shrank.
419    pub fn set_tabs(&mut self, tabs_meta: Vec<TabId>) {
420        let selected = self.active_tab_id().cloned();
421        let fallback = self.active.min(tabs_meta.len().saturating_sub(1));
422        self.tab_generation = self.tab_generation.wrapping_add(1);
423        self.active = selected
424            .as_ref()
425            .and_then(|tab| tabs_meta.iter().position(|candidate| candidate == tab))
426            .unwrap_or(fallback);
427        self.tabs = vec![TabState::Loading; tabs_meta.len()];
428        self.tabs_meta = tabs_meta;
429        self.refreshing_tabs.clear();
430    }
431
432    /// Mark one tab as in flight. A ready snapshot stays in place; tabs that
433    /// have never succeeded still use the full `Loading` state. Returning
434    /// `false` suppresses duplicate requests for the same tab.
435    pub fn begin_refresh(&mut self, tab: &TabId) -> bool {
436        let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
437            return false;
438        };
439        if !self.refreshing_tabs.insert(tab.clone()) {
440            return false;
441        }
442        if !matches!(self.tabs[index], TabState::Ready(_)) {
443            self.tabs[index] = TabState::Loading;
444        }
445        true
446    }
447
448    pub fn is_refreshing(&self, tab: &TabId) -> bool {
449        self.refreshing_tabs.contains(tab)
450    }
451
452    pub fn tab_is_refreshing(&self, index: usize) -> bool {
453        self.tabs_meta
454            .get(index)
455            .is_some_and(|tab| self.is_refreshing(tab))
456    }
457
458    /// Apply an asynchronous refresh only when it still belongs to this tab
459    /// generation and the captured tab identity still exists. Lookup by
460    /// identity, rather than the old positional index, also makes a reordered
461    /// tab list safe.
462    pub fn apply_refresh(&mut self, generation: u64, tab: &TabId, state: TabState) -> bool {
463        if generation != self.tab_generation {
464            return false;
465        }
466        let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
467            return false;
468        };
469        let was_refreshing = self.refreshing_tabs.remove(tab);
470        // If revalidation fails after a successful snapshot, preserve the
471        // useful data but make the failure explicit. Initial failures still
472        // become the normal Error state because there is no data to preserve.
473        if was_refreshing
474            && let TabState::Ready(ready) = &mut self.tabs[index]
475            && let TabState::Error { message, .. } = state
476        {
477            ready.stale = true;
478            ready.last_error = Some((0, message));
479        } else {
480            self.tabs[index] = state;
481        }
482        true
483    }
484
485    /// Move to the first tab of `primary`'s vendor (the default account tab,
486    /// since it precedes any of that vendor's account tabs).
487    pub fn select_primary(&mut self, primary: Option<VendorId>) {
488        if let Some(p) = primary
489            && let Some(idx) = self.tabs_meta.iter().position(|t| t.vendor_id() == Some(p))
490        {
491            self.active = idx;
492            self.overview = false;
493        }
494    }
495
496    /// The selectable ring is `[Overview, tab0, tab1, …]`. `next_tab`/`prev_tab`
497    /// walk it, wrapping through the Overview at the ends.
498    pub fn next_tab(&mut self) {
499        if self.overview {
500            if !self.tabs_meta.is_empty() {
501                self.overview = false;
502                self.active = 0;
503            }
504        } else if self.active + 1 < self.tabs_meta.len() {
505            self.active += 1;
506        } else {
507            self.overview = true;
508        }
509    }
510
511    pub fn prev_tab(&mut self) {
512        if self.overview {
513            if !self.tabs_meta.is_empty() {
514                self.overview = false;
515                self.active = self.tabs_meta.len() - 1;
516            }
517        } else if self.active > 0 {
518            self.active -= 1;
519        } else {
520            self.overview = true;
521        }
522    }
523
524    /// Select the Overview pane (mouse click on the first nav entry).
525    pub fn select_overview(&mut self) {
526        self.overview = true;
527    }
528
529    /// Select a vendor tab by index (mouse click on a nav entry).
530    pub fn select_tab(&mut self, index: usize) {
531        if index < self.tabs_meta.len() {
532            self.active = index;
533            self.overview = false;
534        }
535    }
536
537    /// Apply a mouse click on a vendor-navigation entry.
538    pub fn nav_from_target(&mut self, target: NavTarget) {
539        match target {
540            NavTarget::Overview => self.select_overview(),
541            NavTarget::Tab(index) => self.select_tab(index),
542        }
543    }
544
545    /// Tabs the Overview should list: `overview_vendors` filtered against the
546    /// live tab set (preserving the config order), or all tabs when unset.
547    /// The filter names built-in vendors only, so a configured list excludes
548    /// every custom provider tab; the unset default includes them. A repeated
549    /// vendor is listed once, at its first occurrence.
550    pub fn overview_tabs(&self) -> Vec<usize> {
551        match &self.overview_vendors {
552            None => (0..self.tabs_meta.len()).collect(),
553            Some(wanted) => {
554                let mut seen = HashSet::new();
555                wanted
556                    .iter()
557                    .filter(|vendor| seen.insert(**vendor))
558                    .flat_map(|v| {
559                        self.tabs_meta
560                            .iter()
561                            .enumerate()
562                            .filter(move |(_, t)| t.vendor_id() == Some(*v))
563                            .map(|(i, _)| i)
564                    })
565                    .collect()
566            }
567        }
568    }
569}
570
571/// Fetch and render one tab — returns a `TabState`.
572pub async fn refresh_one(client: &Client, config: &Config, tab: &TabId) -> TabState {
573    match build_outcome(client, config, tab).await {
574        Ok(outcome) => {
575            // Resolve the cache age (a duration from "now" at fetch time) into an
576            // absolute instant ONCE. Without this, sections_for would recompute
577            // `Utc::now() - cache_age` on every draw and the displayed time would
578            // tick upward in real time instead of holding at the last refresh.
579            let now = Utc::now();
580            let off_the_wire = outcome.off_the_wire();
581            let fetched_at = outcome
582                .cache_age
583                .map(|age| now - chrono::Duration::from_std(age).unwrap_or_default());
584            let state = TabState::Ready(Box::new(ReadyTab {
585                snapshot: outcome.snapshot,
586                stale: outcome.stale,
587                last_error: outcome.last_error.map(|(code, message)| {
588                    (code, crate::display::sanitize_untrusted_field(&message))
589                }),
590                fetched_at,
591                display: match &tab.source {
592                    TabSource::Builtin(vendor) => config.display_prefs(*vendor),
593                    // A `[[custom]]` provider states its own percentages; it has
594                    // no balance to meter and no headline to choose.
595                    TabSource::Custom { .. } => crate::balance::DisplayPrefs::default(),
596                },
597            }));
598            // The single notification hook every frontend shares: TUI, `usage`
599            // report, tray, and the GNOME/KDE/Omarchy frontends all land here.
600            // Wire-fresh outcomes only — never cached, stale, or failed ones —
601            // and best-effort by construction: `run` returns nothing and cannot
602            // change this function's result or the caller's exit code.
603            if off_the_wire
604                && config.notifications.enabled
605                && let Some(input) = crate::notify::RefreshInput::from_tab(tab, &state, now)
606            {
607                let _ = crate::notify::run(input, config.notifications.threshold).await;
608            }
609            state
610        }
611        Err(e) => TabState::error_with_plan(
612            crate::display::sanitize_untrusted_field(&e.user_message()),
613            e.plan().map(str::to_string),
614        ),
615    }
616}
617
618/// Key and cache for an API-key vendor tab: a named account's key with its own
619/// `<slug>/<label>` cache, or the default key with the vendor-root cache, whose
620/// path never moves (issue #14).
621fn api_key_and_cache(
622    config: &Config,
623    vendor: VendorId,
624    account: Option<&str>,
625) -> Result<(String, crate::cache::Cache)> {
626    let api_key = config.resolve_account_api_key_for(vendor, account)?;
627    let cache = match account {
628        Some(label) => crate::cache::Cache::for_vendor_account(vendor.slug(), label)?,
629        None => crate::cache::Cache::for_vendor(vendor.slug())?,
630    };
631    Ok((api_key, cache))
632}
633
634async fn build_outcome(client: &Client, config: &Config, tab: &TabId) -> Result<VendorOutcome> {
635    let vendor = match &tab.source {
636        TabSource::Builtin(vendor) => *vendor,
637        TabSource::Custom { id, .. } => {
638            // A custom provider owns its TTL and its cache slot; the cache is
639            // keyed by the provider id under one shared `custom` vendor dir.
640            let spec = config.custom_by_id(id).ok_or_else(|| {
641                AppError::Other(format!("custom provider {id} is not configured"))
642            })?;
643            let api_key = spec.resolve_api_key()?;
644            let cache = crate::cache::Cache::for_vendor_account("custom", id)?;
645            let outcome =
646                crate::custom::fetch_snapshot(client, spec, &api_key, &cache, spec.cache_ttl())
647                    .await?;
648            return Ok(outcome.into());
649        }
650    };
651    match vendor {
652        VendorId::Anthropic => {
653            // A named account resolves to its own file + `anthropic/<label>`
654            // cache, shared with the widget via `account_target` (#14/#17).
655            // The default tab keeps the pre-existing resolution: config
656            // `credentials_path` is an explicit strict read, and only the
657            // platform default gets the macOS Keychain fallback.
658            let (creds_target, cache) = match tab.account.as_deref() {
659                Some(label) if tab.desktop => {
660                    crate::anthropic::desktop_creds::account_target(config, label)?
661                }
662                Some(label) => config.anthropic.account_target(label)?,
663                None => (
664                    config.anthropic.default_creds_target(),
665                    crate::cache::Cache::for_vendor("anthropic")?,
666                ),
667            };
668            let endpoints = crate::anthropic::fetch::Endpoints::default();
669            let outcome = crate::anthropic::fetch_snapshot(
670                client,
671                &creds_target,
672                &cache,
673                &endpoints,
674                DEFAULT_TTL,
675            )
676            .await?;
677            Ok(outcome.map(crate::usage::VendorSnapshot::Anthropic))
678        }
679        VendorId::AnthropicApi => {
680            let key = crate::config::resolve_api_key(
681                "Anthropic_API",
682                &config.anthropic_api.api_key_env,
683                config.anthropic_api.api_key.as_deref(),
684            )?;
685            let cache = crate::cache::Cache::for_vendor("anthropic_api")?;
686            let endpoints = crate::anthropic_api::fetch::Endpoints::default();
687            let outcome = crate::anthropic_api::fetch_snapshot(
688                client,
689                &key,
690                &cache,
691                &endpoints,
692                DEFAULT_TTL,
693                config.anthropic_api.monthly_limit,
694            )
695            .await?;
696            Ok(outcome.into())
697        }
698        VendorId::Openrouter => {
699            let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
700            let management_key = config.openrouter_management_key(tab.account.as_deref());
701            let endpoints = crate::openrouter::fetch::Endpoints::default();
702            let outcome = crate::openrouter::fetch_snapshot(
703                client,
704                &api_key,
705                management_key.as_deref(),
706                &cache,
707                &endpoints,
708                DEFAULT_TTL,
709            )
710            .await?;
711            Ok(outcome.into())
712        }
713        VendorId::Zai => {
714            let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
715            let endpoints = crate::zai::fetch::Endpoints::default();
716            let outcome = crate::zai::fetch_snapshot(
717                client,
718                &api_key,
719                &cache,
720                &endpoints,
721                DEFAULT_TTL,
722                config.zai.plan_tier.as_deref(),
723            )
724            .await?;
725            Ok(outcome.into())
726        }
727        VendorId::Openai => {
728            let label = tab.account.as_deref();
729            let cache = match label {
730                Some(label) => crate::cache::Cache::for_vendor_account("openai", label)?,
731                None => crate::cache::Cache::for_vendor("openai")?,
732            };
733            let route = || config.openai.fetch_auth_path(label);
734            let endpoints = crate::openai::fetch::Endpoints::default();
735            let outcome = crate::openai::fetch_snapshot_routed(
736                client,
737                route,
738                &cache,
739                &endpoints,
740                DEFAULT_TTL,
741            )
742            .await?;
743            Ok(outcome.into())
744        }
745        VendorId::Copilot => {
746            let label = tab.account.as_deref();
747            let token = config.copilot.resolve_token(label)?;
748            let cache = match label {
749                Some(label) => crate::cache::Cache::for_vendor_account("copilot", label)?,
750                None => crate::cache::Cache::for_vendor("copilot")?,
751            };
752            let endpoints = crate::copilot::fetch::Endpoints::default();
753            let outcome =
754                crate::copilot::fetch_snapshot(client, &token, &cache, &endpoints, DEFAULT_TTL)
755                    .await?;
756            Ok(outcome.into())
757        }
758        VendorId::Deepseek => {
759            let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
760            let endpoints = crate::deepseek::fetch::Endpoints::default();
761            let outcome =
762                crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
763                    .await?;
764            Ok(outcome.into())
765        }
766        VendorId::Deepinfra => {
767            let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
768            let endpoints = crate::deepinfra::fetch::Endpoints::default();
769            let outcome = crate::deepinfra::fetch::fetch_snapshot(
770                client,
771                &api_key,
772                &cache,
773                &endpoints,
774                DEFAULT_TTL,
775            )
776            .await?;
777            Ok(outcome.into())
778        }
779        VendorId::Kimi => {
780            let (auth, endpoints) = crate::kimi::resolve_auth(&config.kimi)?;
781            let cache = crate::cache::Cache::for_vendor("kimi")?;
782            let outcome = crate::kimi::fetch::fetch_snapshot_with_auth(
783                client,
784                &auth,
785                &cache,
786                &endpoints,
787                DEFAULT_TTL,
788            )
789            .await?;
790            Ok(outcome.into())
791        }
792        VendorId::Kilo => {
793            let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
794            let endpoints = crate::kilo::fetch::Endpoints::default();
795            let outcome = crate::kilo::fetch_snapshot(
796                client,
797                &api_key,
798                &cache,
799                &endpoints,
800                DEFAULT_TTL,
801                config.kilo.organization_id.as_deref(),
802            )
803            .await?;
804            Ok(outcome.into())
805        }
806        VendorId::Novita => {
807            let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
808            let endpoints = crate::novita::fetch::Endpoints::default();
809            let outcome =
810                crate::novita::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
811                    .await?;
812            Ok(outcome.into())
813        }
814        VendorId::Synthetic => {
815            let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
816            let endpoints = crate::synthetic::fetch::Endpoints::default();
817            let outcome =
818                crate::synthetic::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
819                    .await?;
820            Ok(outcome.into())
821        }
822        VendorId::Moonshot => {
823            let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
824            let (endpoints, currency) =
825                crate::moonshot::fetch::Endpoints::for_region(&config.moonshot.region);
826            let outcome = crate::moonshot::fetch_snapshot(
827                client,
828                &api_key,
829                &cache,
830                &endpoints,
831                DEFAULT_TTL,
832                currency,
833            )
834            .await?;
835            Ok(outcome.into())
836        }
837        VendorId::Grok => {
838            let (key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
839            let endpoints = crate::grok::fetch::Endpoints::default();
840            let outcome = crate::grok::fetch_snapshot(
841                client,
842                &key,
843                &cache,
844                &endpoints,
845                DEFAULT_TTL,
846                config.grok.team_id.as_deref(),
847            )
848            .await?;
849            Ok(outcome.into())
850        }
851        VendorId::Supergrok => {
852            let cache = crate::cache::Cache::for_vendor("supergrok")?;
853            let scope_paths = crate::supergrok::scope::ScopePaths::with_overrides(
854                config.supergrok.auth_path.as_deref(),
855                config.supergrok.config_path.as_deref(),
856            )?;
857            let outcome = crate::supergrok::fetch_snapshot(
858                &config.supergrok.grok_binary,
859                &scope_paths,
860                &cache,
861                DEFAULT_TTL,
862            )
863            .await?;
864            Ok(outcome.into())
865        }
866        VendorId::Antigravity => {
867            // No API key: the local Antigravity server is the source, and the
868            // saved Google session stands in while nothing is running.
869            let cache = crate::cache::Cache::for_vendor("antigravity")?;
870            let oauth = crate::antigravity::cloud::OauthClient::from_config(
871                config.antigravity.oauth_client_id.as_deref(),
872                config.antigravity.oauth_client_secret.as_deref(),
873            );
874            let outcome = crate::antigravity::fetch_snapshot(
875                client,
876                &cache,
877                DEFAULT_TTL,
878                oauth.as_ref(),
879                config.antigravity.agy_binary.as_deref(),
880                config.antigravity.ide_language_server,
881            )
882            .await?;
883            Ok(outcome.into())
884        }
885        VendorId::Grokbot => {
886            // The desktop app's own session is the login; refreshed pairs
887            // persist only inside the vendor cache.
888            let creds = crate::grokbot::resolve_credentials(&config.grokbot)?;
889            let cache = crate::cache::Cache::for_vendor("grokbot")?;
890            let endpoints = crate::grokbot::fetch::Endpoints::default();
891            let outcome = crate::grokbot::fetch::fetch_snapshot_with(
892                client,
893                &creds,
894                &cache,
895                &endpoints,
896                DEFAULT_TTL,
897            )
898            .await?;
899            Ok(outcome.into())
900        }
901        VendorId::ModelStudio => {
902            // The bl CLI's own console session is the login; its region/site
903            // pair picks the gateway, and only a token fingerprint persists.
904            let creds = crate::modelstudio::resolve_credentials(&config.modelstudio)?;
905            let cache = crate::cache::Cache::for_vendor("modelstudio")?;
906            let endpoints =
907                crate::modelstudio::fetch::Endpoints::for_gateway(creds.region, creds.site);
908            let outcome = crate::modelstudio::fetch_snapshot_with(
909                client,
910                &creds,
911                &cache,
912                &endpoints,
913                DEFAULT_TTL,
914            )
915            .await?;
916            Ok(outcome.into())
917        }
918        VendorId::MuseCode => {
919            let cache = crate::cache::Cache::for_vendor("muse-code")?;
920            Ok(crate::muse::fetch::fetch_snapshot(&config.muse, &cache)
921                .await?
922                .into())
923        }
924        VendorId::Devin => {
925            let cache = crate::cache::Cache::for_vendor("devin")?;
926            let outcome =
927                crate::devin::fetch::fetch_snapshot(&config.devin, &cache, DEFAULT_TTL).await?;
928            Ok(outcome.into())
929        }
930        VendorId::Minimax => {
931            let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
932            let endpoints = crate::minimax::fetch::Endpoints::for_region(&config.minimax.region);
933            let outcome =
934                crate::minimax::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
935                    .await?;
936            Ok(outcome.into())
937        }
938        VendorId::Cursor => {
939            let cache = crate::cache::Cache::for_vendor("cursor")?;
940            let db_path = config
941                .cursor
942                .db_path
943                .clone()
944                .map(Ok)
945                .unwrap_or_else(crate::cursor::db::default_db_path)?;
946            let agent_auth_path = config
947                .cursor
948                .agent_auth_path
949                .clone()
950                .map(Ok)
951                .unwrap_or_else(crate::cursor::db::default_agent_auth_path)?;
952            let endpoints = crate::cursor::fetch::Endpoints::default();
953            let outcome = crate::cursor::fetch_snapshot(
954                client,
955                &db_path,
956                &agent_auth_path,
957                &cache,
958                &endpoints,
959                DEFAULT_TTL,
960            )
961            .await?;
962            Ok(outcome.into())
963        }
964        VendorId::Kiro => {
965            let cache = crate::cache::Cache::for_vendor("kiro")?;
966            let db_path = config
967                .kiro
968                .db_path
969                .clone()
970                .map(Ok)
971                .unwrap_or_else(crate::kiro::db::default_db_path)?;
972            let outcome =
973                crate::kiro::fetch_snapshot(client, &db_path, &cache, DEFAULT_TTL).await?;
974            Ok(outcome.into())
975        }
976        VendorId::NousResearch => {
977            let store = crate::nous::credentials::CredentialStore::default();
978            let endpoints = crate::nous::fetch::Endpoints::default();
979            let account = crate::nous::fetch::fetch_account_with_refresh(
980                client,
981                &store,
982                &endpoints,
983                Utc::now(),
984            )
985            .await?;
986            // Nous keeps no cache of its own, so every read is a live one.
987            Ok(crate::outcome::Outcome::fresh(
988                crate::usage::VendorSnapshot::NousResearch(account),
989            ))
990        }
991        VendorId::OpenCodeGo => {
992            let api_key = crate::opencode_go::creds::resolve_key(
993                &config.opencode_go.api_key_env,
994                config.opencode_go.api_key.as_deref(),
995                config.opencode_go.auth_paths.as_deref(),
996            )?;
997            let cache = crate::cache::Cache::for_vendor("opencode-go")?;
998            let endpoints = crate::opencode_go::fetch::Endpoints::default();
999            let outcome = crate::opencode_go::fetch::fetch_snapshot(
1000                client,
1001                &api_key,
1002                &cache,
1003                &endpoints,
1004                DEFAULT_TTL,
1005            )
1006            .await?;
1007            Ok(outcome.into())
1008        }
1009        VendorId::Cline => {
1010            let credential = crate::cline::creds::resolve(
1011                config.cline.auth_paths.as_deref(),
1012                &config.cline.api_key_env,
1013                config.cline.api_key.as_deref(),
1014            )?;
1015            let cache = crate::cache::Cache::for_vendor("cline")?;
1016            let endpoints = crate::cline::fetch::Endpoints::default();
1017            let outcome = crate::cline::fetch::fetch_snapshot(
1018                client,
1019                &credential,
1020                &cache,
1021                &endpoints,
1022                DEFAULT_TTL,
1023                Utc::now(),
1024            )
1025            .await?;
1026            Ok(outcome.into())
1027        }
1028        VendorId::CommandCode => {
1029            let credential =
1030                crate::commandcode::creds::resolve(config.commandcode.auth_paths.as_deref())?;
1031            let cache = crate::cache::Cache::for_vendor("commandcode")?;
1032            let endpoints = crate::commandcode::fetch::Endpoints::default();
1033            let outcome = crate::commandcode::fetch::fetch_snapshot(
1034                client,
1035                &credential.token,
1036                &cache,
1037                &endpoints,
1038                DEFAULT_TTL,
1039            )
1040            .await?;
1041            Ok(outcome.into())
1042        }
1043        VendorId::Ollama => {
1044            let api_key = crate::config::resolve_api_key(
1045                "Ollama",
1046                &config.ollama.api_key_env,
1047                config.ollama.api_key.as_deref(),
1048            )?;
1049            let cache = crate::cache::Cache::for_vendor("ollama")?;
1050            let endpoints = crate::ollama::fetch::Endpoints::default();
1051            let outcome = crate::ollama::fetch_snapshot(
1052                client,
1053                &api_key,
1054                &config.ollama.plan,
1055                &cache,
1056                &endpoints,
1057                DEFAULT_TTL,
1058            )
1059            .await?;
1060            Ok(outcome.into())
1061        }
1062        VendorId::OrcaRouter => {
1063            let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
1064            let endpoints = crate::orcarouter::fetch::Endpoints::default();
1065            let outcome = crate::orcarouter::fetch_snapshot(
1066                client,
1067                &api_key,
1068                &cache,
1069                &endpoints,
1070                DEFAULT_TTL,
1071            )
1072            .await?;
1073            Ok(outcome.into())
1074        }
1075        VendorId::Lyceum => {
1076            let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
1077            let outcome =
1078                crate::lyceum::fetch::fetch_snapshot(client, &api_key, &cache, DEFAULT_TTL).await?;
1079            Ok(outcome.into())
1080        }
1081    }
1082}
1083
1084/// Convenience for the watch-driven binary: how long to wait between
1085/// automatic refreshes.
1086pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
1087
1088/// Gap between successive Anthropic fetches at refresh time. Every Anthropic
1089/// tab (the default account and each named/discovered account) hits the same
1090/// `/api/oauth/usage` + token-refresh endpoints, which rate-limit a burst of
1091/// simultaneous requests from one client — so with several accounts the TUI
1092/// would fire them all at once and some would come back `429`. Spacing them
1093/// out keeps every account refreshing politely.
1094pub const ANTHROPIC_REFRESH_STAGGER: Duration = Duration::from_millis(800);
1095
1096/// Per-tab startup delay for one `spawn_all` pass. Only Anthropic tabs are
1097/// staggered (they share the rate-limited endpoint and multiply with accounts);
1098/// every other vendor hits its own endpoint and starts immediately. The first
1099/// Anthropic tab also starts immediately; each subsequent one waits one more
1100/// `step`. Pure and position-based so it is unit-testable.
1101pub fn refresh_stagger(tabs: &[TabId], step: Duration) -> Vec<Duration> {
1102    let mut anthropic_seen: u32 = 0;
1103    tabs.iter()
1104        .map(|tab| {
1105            if tab.vendor_id() == Some(VendorId::Anthropic) {
1106                let delay = step * anthropic_seen;
1107                anthropic_seen += 1;
1108                delay
1109            } else {
1110                Duration::ZERO
1111            }
1112        })
1113        .collect()
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118    use super::*;
1119    use chrono::TimeZone;
1120
1121    // Use `App::with_theme(.., Theme::default())` rather than `App::new`, which
1122    // would read the real Omarchy theme file + `$HOME`. The tab-selection logic
1123    // under test is theme-agnostic.
1124    #[test]
1125    fn refresh_stagger_spaces_out_anthropic_tabs_only() {
1126        let step = Duration::from_millis(800);
1127        let tabs = vec![
1128            TabId::vendor(VendorId::Anthropic), // default account
1129            TabId::account("work"),
1130            TabId::account("personal"),
1131            TabId::vendor(VendorId::Openai),
1132            TabId::vendor(VendorId::Zai),
1133        ];
1134        let delays = refresh_stagger(&tabs, step);
1135        assert_eq!(
1136            delays,
1137            vec![
1138                Duration::ZERO, // 1st anthropic — immediate
1139                step,           // 2nd anthropic
1140                step * 2,       // 3rd anthropic
1141                Duration::ZERO, // openai — own endpoint, immediate
1142                Duration::ZERO, // zai — own endpoint, immediate
1143            ]
1144        );
1145    }
1146
1147    #[test]
1148    fn refresh_stagger_is_a_noop_without_anthropic_accounts() {
1149        // A single Anthropic tab (or none) never waits.
1150        let tabs = vec![
1151            TabId::vendor(VendorId::Anthropic),
1152            TabId::vendor(VendorId::Openrouter),
1153        ];
1154        assert!(
1155            refresh_stagger(&tabs, Duration::from_millis(800))
1156                .iter()
1157                .all(|d| d.is_zero())
1158        );
1159    }
1160
1161    #[test]
1162    fn select_primary_moves_to_enabled_vendor() {
1163        let mut app = App::with_theme(
1164            vec![
1165                TabId::vendor(VendorId::Anthropic),
1166                TabId::vendor(VendorId::Openrouter),
1167            ],
1168            Theme::default(),
1169        );
1170        app.select_primary(Some(VendorId::Openrouter));
1171        assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
1172    }
1173
1174    #[test]
1175    fn select_primary_ignores_disabled_vendor() {
1176        let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1177        app.select_primary(Some(VendorId::Openai));
1178        assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
1179    }
1180
1181    #[test]
1182    fn nav_ring_wraps_through_the_overview_at_both_ends() {
1183        let mut app = App::with_theme(
1184            vec![
1185                TabId::vendor(VendorId::Anthropic),
1186                TabId::vendor(VendorId::Openai),
1187            ],
1188            Theme::default(),
1189        );
1190        app.overview = true;
1191
1192        app.next_tab(); // Overview -> first vendor
1193        assert!(!app.overview);
1194        assert_eq!(app.active, 0);
1195        app.next_tab();
1196        assert_eq!(app.active, 1);
1197        app.next_tab(); // last vendor -> Overview
1198        assert!(app.overview);
1199
1200        app.prev_tab(); // Overview -> last vendor
1201        assert!(!app.overview);
1202        assert_eq!(app.active, 1);
1203        app.prev_tab();
1204        assert_eq!(app.active, 0);
1205        app.prev_tab(); // first vendor -> Overview
1206        assert!(app.overview);
1207    }
1208
1209    #[test]
1210    fn mouse_nav_targets_select_overview_and_tabs() {
1211        let mut app = App::with_theme(
1212            vec![
1213                TabId::vendor(VendorId::Anthropic),
1214                TabId::vendor(VendorId::Openai),
1215            ],
1216            Theme::default(),
1217        );
1218        app.overview = true;
1219        app.nav_from_target(NavTarget::Tab(1));
1220        assert!(!app.overview);
1221        assert_eq!(app.active, 1);
1222        app.nav_from_target(NavTarget::Overview);
1223        assert!(app.overview);
1224        // Out-of-range tab indices are ignored.
1225        app.nav_from_target(NavTarget::Tab(99));
1226        assert!(app.overview);
1227    }
1228
1229    #[test]
1230    fn hit_targets_default_to_empty() {
1231        let app = App::with_theme(Vec::new(), Theme::default());
1232        assert!(app.hit.borrow().nav_entries.is_empty());
1233        assert!(app.hit.borrow().footer_actions.is_empty());
1234        assert!(app.hit.borrow().settings_rows.is_empty());
1235    }
1236
1237    #[test]
1238    fn overview_tabs_defaults_to_all_and_honors_the_config_filter() {
1239        let mut app = App::with_theme(
1240            vec![
1241                TabId::vendor(VendorId::Anthropic),
1242                TabId::vendor(VendorId::Openai),
1243                TabId::vendor(VendorId::Zai),
1244            ],
1245            Theme::default(),
1246        );
1247        assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
1248
1249        // Subset in the given order.
1250        app.overview_vendors = Some(vec![VendorId::Zai, VendorId::Anthropic]);
1251        assert_eq!(app.overview_tabs(), vec![2, 0]);
1252
1253        // A listed-but-absent vendor is simply skipped.
1254        app.overview_vendors = Some(vec![VendorId::Grok, VendorId::Openai]);
1255        assert_eq!(app.overview_tabs(), vec![1]);
1256
1257        // A repeated vendor is listed once, at its first occurrence.
1258        app.overview_vendors = Some(vec![VendorId::Anthropic, VendorId::Anthropic]);
1259        assert_eq!(app.overview_tabs(), vec![0]);
1260    }
1261
1262    fn config_with_accounts(labels: &[&str]) -> Config {
1263        let mut config = Config::default();
1264        // Keep only Anthropic enabled so the test asserts on account expansion,
1265        // not on the full default vendor set.
1266        config.openai.enabled = false;
1267        config.zai.enabled = false;
1268        config.openrouter.enabled = false;
1269        config.commandcode.enabled = false;
1270        config.anthropic.accounts = labels
1271            .iter()
1272            .map(|l| crate::config::AnthropicAccount {
1273                label: (*l).to_string(),
1274                credentials_path: format!("/creds/{l}.json").into(),
1275            })
1276            .collect();
1277        config
1278    }
1279
1280    #[test]
1281    fn show_default_account_false_hides_the_unnamed_claude_tab() {
1282        // With named accounts and show_default_account=false, only the named
1283        // tabs appear — no redundant default "Claude" tab.
1284        let mut config = config_with_accounts(&["work", "personal"]);
1285        config.anthropic.show_default_account = false;
1286        assert_eq!(
1287            tabs_from_config(&config),
1288            vec![TabId::account("work"), TabId::account("personal")]
1289        );
1290
1291        // But with no named accounts it is kept, so Anthropic never loses its
1292        // only tab.
1293        let mut empty = Config::default();
1294        empty.openai.enabled = false;
1295        empty.zai.enabled = false;
1296        empty.openrouter.enabled = false;
1297        empty.commandcode.enabled = false;
1298        empty.anthropic.show_default_account = false;
1299        assert_eq!(
1300            tabs_from_config(&empty),
1301            vec![TabId::vendor(VendorId::Anthropic)]
1302        );
1303    }
1304
1305    #[test]
1306    fn tabs_expand_anthropic_accounts_after_default() {
1307        // Default Claude tab first, then each account in config order.
1308        let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
1309        assert_eq!(
1310            tabs,
1311            vec![
1312                TabId::vendor(VendorId::Anthropic),
1313                TabId::account("work"),
1314                TabId::account("personal"),
1315            ]
1316        );
1317    }
1318
1319    #[test]
1320    fn tabs_follow_overview_vendors_and_keep_the_rest() {
1321        let mut config = Config::default();
1322        config.zai.enabled = false;
1323        config.openrouter.enabled = false;
1324        config.commandcode.enabled = false;
1325        config.supergrok.enabled = true;
1326        config.antigravity.enabled = true;
1327        config.opencode_go.enabled = true;
1328        config.copilot.enabled = true;
1329        config.ui.overview_vendors = Some(vec![
1330            VendorId::Anthropic,
1331            VendorId::Zai,
1332            VendorId::Openai,
1333            VendorId::Anthropic,
1334            VendorId::Supergrok,
1335            VendorId::OpenCodeGo,
1336            VendorId::Antigravity,
1337        ]);
1338        let vendors: Vec<VendorId> = tabs_from_config(&config)
1339            .iter()
1340            .filter_map(TabId::vendor_id)
1341            .collect();
1342        assert_eq!(
1343            vendors,
1344            vec![
1345                VendorId::Anthropic,
1346                VendorId::Openai,
1347                VendorId::Supergrok,
1348                VendorId::OpenCodeGo,
1349                VendorId::Antigravity,
1350                VendorId::Copilot,
1351            ]
1352        );
1353    }
1354
1355    #[test]
1356    fn tabs_without_accounts_are_just_enabled_vendors() {
1357        // No [[anthropic.accounts]] → one tab per enabled vendor, unchanged.
1358        let config = Config::default();
1359        let tabs = tabs_from_config(&config);
1360        let vendors: Vec<VendorId> = tabs.iter().filter_map(TabId::vendor_id).collect();
1361        assert_eq!(vendors, config.enabled_vendors());
1362        assert!(tabs.iter().all(|t| t.account.is_none()));
1363    }
1364
1365    #[test]
1366    fn tabs_expand_openrouter_accounts_without_changing_other_vendors() {
1367        let mut config = Config::default();
1368        config.anthropic.enabled = false;
1369        config.openai.enabled = false;
1370        config.zai.enabled = false;
1371        config.commandcode.enabled = false;
1372        config.openrouter.accounts = vec![
1373            crate::config::ApiKeyAccount {
1374                label: "work".into(),
1375                api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1376                api_key: None,
1377                management_api_key_env: None,
1378            },
1379            crate::config::ApiKeyAccount {
1380                label: "personal".into(),
1381                api_key_env: None,
1382                api_key: Some("personal-key".into()),
1383                management_api_key_env: None,
1384            },
1385        ];
1386        assert_eq!(
1387            tabs_from_config(&config),
1388            vec![
1389                TabId::vendor(VendorId::Openrouter),
1390                TabId::account_for(VendorId::Openrouter, "work"),
1391                TabId::account_for(VendorId::Openrouter, "personal"),
1392            ]
1393        );
1394    }
1395
1396    #[test]
1397    fn openai_named_accounts_get_their_own_tabs_after_the_default() {
1398        let mut config = Config::default();
1399        config.anthropic.enabled = false;
1400        config.zai.enabled = false;
1401        config.openrouter.enabled = false;
1402        config.commandcode.enabled = false;
1403        config.openai.accounts.push(crate::config::OpenAiAccount {
1404            label: "work".into(),
1405            codex_auth_path: "/tmp/codex-work/auth.json".into(),
1406        });
1407        assert_eq!(
1408            tabs_from_config(&config),
1409            vec![
1410                TabId::vendor(VendorId::Openai),
1411                TabId::account_for(VendorId::Openai, "work"),
1412            ]
1413        );
1414    }
1415
1416    #[test]
1417    fn copilot_named_accounts_get_their_own_tabs_and_can_hide_the_default() {
1418        let base = || {
1419            let mut config = Config::default();
1420            config.anthropic.enabled = false;
1421            config.openai.enabled = false;
1422            config.zai.enabled = false;
1423            config.openrouter.enabled = false;
1424            config.commandcode.enabled = false;
1425            config.copilot.enabled = true;
1426            config
1427        };
1428
1429        // No accounts: exactly the pre-#378 single tab.
1430        assert_eq!(
1431            tabs_from_config(&base()),
1432            vec![TabId::vendor(VendorId::Copilot)]
1433        );
1434
1435        // Named accounts follow the default, in config order.
1436        let mut config = base();
1437        config.copilot.accounts.push(crate::config::CopilotAccount {
1438            label: "work".into(),
1439            user: "octocat-work".into(),
1440        });
1441        config.copilot.accounts.push(crate::config::CopilotAccount {
1442            label: "personal".into(),
1443            user: "octocat".into(),
1444        });
1445        assert_eq!(
1446            tabs_from_config(&config),
1447            vec![
1448                TabId::vendor(VendorId::Copilot),
1449                TabId::account_for(VendorId::Copilot, "work"),
1450                TabId::account_for(VendorId::Copilot, "personal"),
1451            ]
1452        );
1453
1454        // The ambient active-gh tab is suppressible once every account is named.
1455        config.copilot.show_default_account = false;
1456        assert_eq!(
1457            tabs_from_config(&config),
1458            vec![
1459                TabId::account_for(VendorId::Copilot, "work"),
1460                TabId::account_for(VendorId::Copilot, "personal"),
1461            ]
1462        );
1463
1464        // But never when it would leave Copilot with no tab at all.
1465        let mut empty = base();
1466        empty.copilot.show_default_account = false;
1467        assert_eq!(
1468            tabs_from_config(&empty),
1469            vec![TabId::vendor(VendorId::Copilot)]
1470        );
1471    }
1472
1473    #[test]
1474    fn openrouter_can_hide_default_only_when_named_accounts_exist() {
1475        let mut config = Config::default();
1476        config.anthropic.enabled = false;
1477        config.openai.enabled = false;
1478        config.zai.enabled = false;
1479        config.commandcode.enabled = false;
1480        config.openrouter.show_default_account = false;
1481        assert_eq!(
1482            tabs_from_config(&config),
1483            vec![TabId::vendor(VendorId::Openrouter)]
1484        );
1485
1486        config
1487            .openrouter
1488            .accounts
1489            .push(crate::config::ApiKeyAccount {
1490                label: "work".into(),
1491                api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1492                api_key: None,
1493                management_api_key_env: None,
1494            });
1495        assert_eq!(
1496            tabs_from_config(&config),
1497            vec![TabId::account_for(VendorId::Openrouter, "work")]
1498        );
1499    }
1500
1501    #[test]
1502    fn every_api_key_account_vendor_fans_out_like_openrouter() {
1503        let config_for = |vendor: VendorId, show_default: bool| -> Config {
1504            let section = vendor.config_section();
1505            // Switch off the vendors that are on by default, so the tab list
1506            // is this vendor's alone.
1507            let mut text: String = ["anthropic", "openai", "commandcode", "zai", "openrouter"]
1508                .into_iter()
1509                .filter(|other| *other != section)
1510                .map(|other| format!("[{other}]\nenabled = false\n"))
1511                .collect();
1512            text.push_str(&format!(
1513                "[{section}]\nenabled = true\nshow_default_account = {show_default}\n\
1514                 [[{section}.accounts]]\nlabel = \"work\"\napi_key = \"k1\"\n\
1515                 [[{section}.accounts]]\nlabel = \"personal\"\napi_key = \"k2\"\n"
1516            ));
1517            let config: Config = toml::from_str(&text).unwrap();
1518            config.validate().unwrap();
1519            config
1520        };
1521        for vendor in Config::API_KEY_ACCOUNT_VENDORS {
1522            let named = vec![
1523                TabId::account_for(vendor, "work"),
1524                TabId::account_for(vendor, "personal"),
1525            ];
1526            let mut with_default = vec![TabId::vendor(vendor)];
1527            with_default.extend(named.clone());
1528            assert_eq!(
1529                tabs_from_config(&config_for(vendor, true)),
1530                with_default,
1531                "{vendor:?}"
1532            );
1533            assert_eq!(
1534                tabs_from_config(&config_for(vendor, false)),
1535                named,
1536                "{vendor:?}"
1537            );
1538        }
1539    }
1540
1541    #[test]
1542    fn codex_can_hide_default_only_when_named_accounts_exist() {
1543        let mut config = Config::default();
1544        config.anthropic.enabled = false;
1545        config.zai.enabled = false;
1546        config.openrouter.enabled = false;
1547        config.commandcode.enabled = false;
1548        config.openai.show_default_account = false;
1549        assert_eq!(
1550            tabs_from_config(&config),
1551            vec![TabId::vendor(VendorId::Openai)]
1552        );
1553
1554        config.openai.accounts.push(crate::config::OpenAiAccount {
1555            label: "work".into(),
1556            codex_auth_path: "/tmp/codex-work/auth.json".into(),
1557        });
1558        assert_eq!(
1559            tabs_from_config(&config),
1560            vec![TabId::account_for(VendorId::Openai, "work")]
1561        );
1562    }
1563
1564    #[test]
1565    fn tabs_include_accounts_auto_discovered_from_accounts_dir() {
1566        // A CLAUDE_CONFIG_DIR-style directory becomes account tabs with no
1567        // explicit [[anthropic.accounts]] entry. Hermetic: real TempDir.
1568        let td = tempfile::tempdir().unwrap();
1569        for label in ["work", "personal"] {
1570            let dir = td.path().join(label);
1571            std::fs::create_dir_all(&dir).unwrap();
1572            std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
1573        }
1574        let mut config = Config::default();
1575        config.openai.enabled = false;
1576        config.zai.enabled = false;
1577        config.openrouter.enabled = false;
1578        config.commandcode.enabled = false;
1579        config.anthropic.accounts_dir = Some(td.path().to_path_buf());
1580
1581        let tabs = tabs_from_config(&config);
1582        assert_eq!(
1583            tabs,
1584            vec![
1585                TabId::vendor(VendorId::Anthropic),
1586                TabId::account("personal"), // sorted by label
1587                TabId::account("work"),
1588            ]
1589        );
1590    }
1591
1592    #[test]
1593    fn desktop_labels_become_account_tabs_after_cli_accounts() {
1594        // Pure core: desktop accounts follow CLI accounts, in the order given.
1595        let config = config_with_accounts(&["work"]);
1596        let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1597        assert_eq!(
1598            tabs,
1599            vec![
1600                TabId::vendor(VendorId::Anthropic),
1601                TabId::account("work"),
1602                TabId::desktop_account("gmail"),
1603                TabId::desktop_account("hotmail"),
1604            ]
1605        );
1606    }
1607
1608    #[test]
1609    fn a_desktop_profile_wins_a_label_collision_with_a_cli_account() {
1610        // One tab per label; the Desktop source wins so the label is never fed
1611        // from two stores refreshing one rotating token (which invalidate each
1612        // other and can silently show a wrong account's usage). The CLI entry is
1613        // dropped; a CLI-only label (work) is untouched.
1614        let config = config_with_accounts(&["gmail", "work"]);
1615        let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1616        assert_eq!(
1617            tabs,
1618            vec![
1619                TabId::vendor(VendorId::Anthropic),
1620                TabId::account("work"),
1621                TabId::desktop_account("gmail"),
1622                TabId::desktop_account("hotmail"),
1623            ]
1624        );
1625    }
1626
1627    #[test]
1628    fn desktop_accounts_suppress_the_default_tab_like_named_ones() {
1629        // show_default_account=false + only Desktop accounts => no default tab,
1630        // exactly as if they were [[anthropic.accounts]] (the Desktop-only user).
1631        let mut config = config_with_accounts(&[]);
1632        config.cursor.enabled = false;
1633        config.anthropic.show_default_account = false;
1634
1635        // No accounts of either kind: the default tab survives (never leave
1636        // Anthropic tab-less).
1637        assert_eq!(
1638            build_tabs(&config, &[]),
1639            vec![TabId::vendor(VendorId::Anthropic)]
1640        );
1641        // A Desktop account is present: default suppressed, only the account.
1642        assert_eq!(
1643            build_tabs(&config, &["gmail".into()]),
1644            vec![TabId::desktop_account("gmail")]
1645        );
1646    }
1647
1648    #[test]
1649    fn set_tabs_resets_states_and_clamps_selection() {
1650        // Simulates a Settings save that shrank the tab list: the selection
1651        // must clamp into range and every tab must reset to Loading so the
1652        // caller's spawn_all repopulates against the new config.
1653        let mut app = App::with_theme(
1654            tabs_from_config(&config_with_accounts(&["work", "personal"])),
1655            Theme::default(),
1656        );
1657        app.active = 2; // "personal"
1658        app.tabs[0] = TabState::error("old");
1659        let old_tab = app.tabs_meta[0].clone();
1660        assert!(app.begin_refresh(&old_tab));
1661
1662        app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
1663        assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
1664        assert_eq!(app.active, 0, "selection clamped after shrink");
1665        assert!(matches!(app.tabs[0], TabState::Loading));
1666        assert!(!app.is_refreshing(&old_tab));
1667    }
1668
1669    #[test]
1670    fn set_tabs_preserves_selected_identity_when_entries_are_inserted() {
1671        let mut app = App::with_theme(
1672            vec![
1673                TabId::vendor(VendorId::Anthropic),
1674                TabId::vendor(VendorId::Openai),
1675            ],
1676            Theme::default(),
1677        );
1678        app.active = 1;
1679
1680        app.set_tabs(vec![
1681            TabId::vendor(VendorId::Anthropic),
1682            TabId::account("work"),
1683            TabId::vendor(VendorId::Openai),
1684        ]);
1685
1686        assert_eq!(app.active, 2);
1687        assert_eq!(app.active_tab_id(), Some(&TabId::vendor(VendorId::Openai)));
1688    }
1689
1690    #[test]
1691    fn refresh_from_old_generation_is_discarded() {
1692        let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1693        let old_generation = app.tab_generation;
1694        app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
1695
1696        assert!(!app.apply_refresh(
1697            old_generation,
1698            &TabId::vendor(VendorId::Anthropic),
1699            TabState::error("old result"),
1700        ));
1701        assert!(matches!(app.tabs[0], TabState::Loading));
1702    }
1703
1704    #[test]
1705    fn refresh_identity_mismatch_is_discarded() {
1706        let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1707        let generation = app.tab_generation;
1708
1709        assert!(!app.apply_refresh(
1710            generation,
1711            &TabId::vendor(VendorId::Openai),
1712            TabState::error("wrong tab"),
1713        ));
1714        assert!(matches!(app.tabs[0], TabState::Loading));
1715    }
1716
1717    #[test]
1718    fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
1719        let anthropic = TabId::vendor(VendorId::Anthropic);
1720        let openai = TabId::vendor(VendorId::Openai);
1721        let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
1722        let generation = app.tab_generation;
1723        assert!(app.begin_refresh(&anthropic));
1724
1725        // A reorder is safe because delivery resolves the captured identity,
1726        // not a stale positional index.
1727        app.tabs_meta.swap(0, 1);
1728        app.tabs.swap(0, 1);
1729        assert!(app.apply_refresh(generation, &anthropic, TabState::error("ready")));
1730        assert!(matches!(app.tabs[0], TabState::Loading));
1731        assert!(matches!(&app.tabs[1], TabState::Error { message, .. } if message == "ready"));
1732        assert!(!app.is_refreshing(&anthropic));
1733    }
1734
1735    fn ready_at(fetched_at: chrono::DateTime<Utc>) -> TabState {
1736        TabState::Ready(Box::new(ReadyTab {
1737            snapshot: crate::usage::VendorSnapshot::Openrouter(crate::usage::OpenRouterSnapshot {
1738                label: "test".into(),
1739                total_credits: 0.0,
1740                total_usage: 0.0,
1741                usage_daily: 0.0,
1742                usage_weekly: 0.0,
1743                usage_monthly: 0.0,
1744                is_free_tier: false,
1745                limit: None,
1746                limit_remaining: None,
1747                recent_models: Vec::new(),
1748            }),
1749            stale: false,
1750            last_error: None,
1751            fetched_at: Some(fetched_at),
1752            display: Default::default(),
1753        }))
1754    }
1755
1756    #[test]
1757    fn refresh_keeps_ready_snapshot_visible_and_suppresses_duplicates() {
1758        let tab = TabId::vendor(VendorId::Openrouter);
1759        let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1760        let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1761        app.tabs[0] = ready_at(fetched_at);
1762
1763        assert!(app.begin_refresh(&tab));
1764        assert!(
1765            !app.begin_refresh(&tab),
1766            "duplicate request must be suppressed"
1767        );
1768        assert!(app.is_refreshing(&tab));
1769        match &app.tabs[0] {
1770            TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1771            other => panic!("ready snapshot disappeared during refresh: {other:?}"),
1772        }
1773    }
1774
1775    #[test]
1776    fn first_refresh_still_uses_loading_until_data_arrives() {
1777        let tab = TabId::vendor(VendorId::Openrouter);
1778        let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1779
1780        assert!(app.begin_refresh(&tab));
1781        assert!(app.is_refreshing(&tab));
1782        assert!(matches!(app.tabs[0], TabState::Loading));
1783
1784        assert!(app.apply_refresh(app.tab_generation, &tab, TabState::error("not signed in"),));
1785        assert!(!app.is_refreshing(&tab));
1786        assert!(
1787            matches!(&app.tabs[0], TabState::Error { message, .. } if message == "not signed in")
1788        );
1789    }
1790
1791    #[test]
1792    fn successful_revalidation_replaces_snapshot_and_clears_indicator() {
1793        let tab = TabId::vendor(VendorId::Openrouter);
1794        let old_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1795        let new_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 1, 0).unwrap();
1796        let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1797        app.tabs[0] = ready_at(old_at);
1798
1799        assert!(app.begin_refresh(&tab));
1800        assert!(app.apply_refresh(app.tab_generation, &tab, ready_at(new_at)));
1801        assert!(!app.is_refreshing(&tab));
1802        match &app.tabs[0] {
1803            TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(new_at)),
1804            other => panic!("expected replacement snapshot, got {other:?}"),
1805        }
1806    }
1807
1808    #[test]
1809    fn failed_revalidation_preserves_snapshot_with_visible_warning() {
1810        let tab = TabId::vendor(VendorId::Openrouter);
1811        let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1812        let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1813        app.tabs[0] = ready_at(fetched_at);
1814
1815        assert!(app.begin_refresh(&tab));
1816        assert!(app.apply_refresh(app.tab_generation, &tab, TabState::error("refresh failed"),));
1817        assert!(!app.is_refreshing(&tab));
1818        match &app.tabs[0] {
1819            TabState::Ready(ready) => {
1820                assert_eq!(ready.fetched_at, Some(fetched_at));
1821                assert!(ready.stale);
1822                assert_eq!(ready.last_error, Some((0, "refresh failed".into())));
1823            }
1824            other => panic!("last successful snapshot was lost: {other:?}"),
1825        }
1826        let sections = crate::tui::panels::sections_for(&app.tabs[0], Utc::now(), 5);
1827        assert!(sections.iter().any(|section| matches!(
1828            section,
1829            crate::tui::panels::Section::Text { label, value }
1830                if label == "Warning" && value == "refresh failed"
1831        )));
1832    }
1833
1834    #[test]
1835    fn old_generation_result_does_not_clear_current_refresh() {
1836        let tab = TabId::vendor(VendorId::Openrouter);
1837        let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1838        let old_generation = app.tab_generation;
1839        app.set_tabs(vec![tab.clone()]);
1840        assert!(app.begin_refresh(&tab));
1841
1842        assert!(!app.apply_refresh(old_generation, &tab, TabState::error("old result"),));
1843        assert!(app.is_refreshing(&tab));
1844        assert!(matches!(app.tabs[0], TabState::Loading));
1845    }
1846
1847    #[test]
1848    fn apply_refresh_stamps_fetched_at_on_only_the_matching_tab() {
1849        // Pins the per-tab `fetched_at` the header now reads: a landed Anthropic
1850        // response leaves the still-loading OpenAI tab with no time of its own.
1851        // Dropping the global `last_refresh` clock is not observable from here
1852        // (it was write-only) — that is asserted against the rendered header in
1853        // `view::tests::header_refresh_*`.
1854        let anthropic = TabId::vendor(VendorId::Anthropic);
1855        let openai = TabId::vendor(VendorId::Openai);
1856        let mut app = App::with_theme(vec![anthropic.clone(), openai], Theme::default());
1857        let generation = app.tab_generation;
1858        let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1859
1860        assert!(app.apply_refresh(generation, &anthropic, ready_at(fetched_at)));
1861        match &app.tabs[0] {
1862            TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1863            other => panic!("expected Anthropic tab Ready, got {other:?}"),
1864        }
1865        assert!(matches!(app.tabs[1], TabState::Loading));
1866    }
1867
1868    #[test]
1869    fn select_primary_lands_on_default_account_tab() {
1870        // With account tabs present, `primary = anthropic` selects the default
1871        // Claude tab (index 0), not one of its account tabs.
1872        let app = {
1873            let tabs = tabs_from_config(&config_with_accounts(&["work"]));
1874            let mut a = App::with_theme(tabs, Theme::default());
1875            a.select_primary(Some(VendorId::Anthropic));
1876            a
1877        };
1878        assert_eq!(app.active, 0);
1879        assert_eq!(
1880            app.active_tab_id(),
1881            Some(&TabId::vendor(VendorId::Anthropic))
1882        );
1883    }
1884
1885    fn custom_spec(id: &str, enabled: bool) -> CustomProviderConfig {
1886        CustomProviderConfig {
1887            id: id.into(),
1888            name: "My Tool".into(),
1889            short_name: "myt".into(),
1890            enabled,
1891            ..Default::default()
1892        }
1893    }
1894
1895    #[test]
1896    fn custom_providers_get_tabs_after_every_builtin() {
1897        let config = Config {
1898            custom: vec![custom_spec("mytool", true), custom_spec("other", true)],
1899            ..Default::default()
1900        };
1901        let tabs = tabs_from_config(&config);
1902        let builtin_count = config.enabled_vendors().len();
1903        assert_eq!(tabs.len(), builtin_count + 2);
1904        assert!(
1905            tabs[..builtin_count]
1906                .iter()
1907                .all(|t| matches!(t.source, TabSource::Builtin(_)))
1908        );
1909        assert_eq!(tabs[builtin_count], TabId::custom(&config.custom[0]));
1910        assert_eq!(tabs[builtin_count + 1], TabId::custom(&config.custom[1]));
1911        assert_eq!(
1912            tabs[builtin_count].source,
1913            TabSource::Custom {
1914                id: "mytool".into(),
1915                name: "My Tool".into(),
1916                short_name: "myt".into(),
1917            }
1918        );
1919        assert!(tabs[builtin_count].account.is_none());
1920        assert!(!tabs[builtin_count].desktop);
1921        assert_eq!(tabs[builtin_count].vendor_id(), None);
1922    }
1923
1924    #[test]
1925    fn disabled_custom_provider_has_no_tab() {
1926        let config = Config {
1927            custom: vec![custom_spec("mytool", false)],
1928            ..Default::default()
1929        };
1930        let tabs = tabs_from_config(&config);
1931        assert!(
1932            tabs.iter()
1933                .all(|t| matches!(t.source, TabSource::Builtin(_)))
1934        );
1935        assert_eq!(tabs.len(), config.enabled_vendors().len());
1936    }
1937
1938    #[test]
1939    fn custom_tabs_are_listed_by_the_default_overview_but_not_by_a_vendor_filter() {
1940        let spec = custom_spec("mytool", true);
1941        let mut app = App::with_theme(
1942            vec![
1943                TabId::vendor(VendorId::Anthropic),
1944                TabId::custom(&spec),
1945                TabId::vendor(VendorId::Openai),
1946            ],
1947            Theme::default(),
1948        );
1949        assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
1950
1951        // `overview_vendors` names VendorIds only, so the custom tab drops out.
1952        app.overview_vendors = Some(vec![VendorId::Openai, VendorId::Anthropic]);
1953        assert_eq!(app.overview_tabs(), vec![2, 0]);
1954    }
1955
1956    #[test]
1957    fn a_custom_active_tab_has_no_vendor_and_never_staggers() {
1958        let spec = custom_spec("mytool", true);
1959        let tabs = vec![
1960            TabId::vendor(VendorId::Anthropic),
1961            TabId::custom(&spec),
1962            TabId::account("work"),
1963        ];
1964        let mut app = App::with_theme(tabs.clone(), Theme::default());
1965        app.active = 1;
1966        assert_eq!(app.active_vendor(), None);
1967        assert_eq!(app.active_tab_id(), Some(&TabId::custom(&spec)));
1968
1969        // select_primary never lands on a custom tab.
1970        app.select_primary(Some(VendorId::Anthropic));
1971        assert_eq!(app.active, 0);
1972
1973        let step = Duration::from_millis(800);
1974        assert_eq!(
1975            refresh_stagger(&tabs, step),
1976            vec![Duration::ZERO, Duration::ZERO, step]
1977        );
1978    }
1979}