Skip to main content

ai_usagebar/tui/
app.rs

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