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