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