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