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