Skip to main content

ai_usagebar/tui/
app.rs

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