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