Skip to main content

ai_usagebar/tui/
app.rs

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