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