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