Skip to main content

ai_usagebar/tui/
app.rs

1//! TUI app state — vendors, tab selection, per-vendor snapshot cache.
2
3use std::time::Duration;
4
5use chrono::Utc;
6use reqwest::Client;
7
8use crate::cache::DEFAULT_TTL;
9use crate::config::Config;
10use crate::error::Result;
11use crate::theme::Theme;
12use crate::vendor::{VendorId, VendorOutcome};
13
14/// What we display per vendor — raw snapshot + fetch metadata for native
15/// panel rendering, or an error message when the fetch failed.
16///
17/// `Ready` is boxed because the snapshot is much larger than the other two
18/// variants (silences `clippy::large_enum_variant`).
19#[derive(Debug, Clone)]
20pub enum TabState {
21    Loading,
22    Ready(Box<ReadyTab>),
23    Error(String),
24}
25
26#[derive(Debug, Clone)]
27pub struct ReadyTab {
28    pub snapshot: crate::usage::VendorSnapshot,
29    pub stale: bool,
30    pub last_error: Option<(u16, String)>,
31    /// Absolute moment the cache was written (i.e. the API response landed).
32    /// Snapshotted once at TabState build time so the rendered "Updated …"
33    /// timestamp stays stable across redraws instead of drifting with the
34    /// passing wall clock.
35    pub fetched_at: Option<chrono::DateTime<chrono::Utc>>,
36}
37
38/// Identity of one TUI tab. Usually a whole vendor; for Anthropic it can also
39/// name a specific configured account (issues #14 / #17). `account: None` is a
40/// plain vendor tab — the default Claude account, or any non-Anthropic vendor.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct TabId {
43    pub vendor: VendorId,
44    pub account: Option<String>,
45}
46
47impl TabId {
48    /// A plain vendor tab (default account for Anthropic).
49    pub fn vendor(vendor: VendorId) -> Self {
50        Self {
51            vendor,
52            account: None,
53        }
54    }
55
56    /// A named Anthropic account tab (`[[anthropic.accounts]]` label).
57    pub fn account(label: impl Into<String>) -> Self {
58        Self {
59            vendor: VendorId::Anthropic,
60            account: Some(label.into()),
61        }
62    }
63}
64
65/// Expand enabled vendors into the tab list. Anthropic yields its default
66/// account tab followed by one tab per `[[anthropic.accounts]]` entry, in
67/// config order; every other vendor is a single tab. With no extra accounts
68/// configured the result equals `config.enabled_vendors()` — identical tab set
69/// and order to before (issue #14/#17 back-compat).
70pub fn tabs_from_config(config: &Config) -> Vec<TabId> {
71    let mut tabs = Vec::new();
72    for vendor in config.enabled_vendors() {
73        if vendor == VendorId::Anthropic {
74            let accounts = config.anthropic.all_accounts();
75            // The default (unnamed) Claude tab is suppressible once every
76            // account is named — but never when it would leave Anthropic with
77            // no tab at all.
78            if config.anthropic.show_default_account || accounts.is_empty() {
79                tabs.push(TabId::vendor(vendor));
80            }
81            for acct in accounts {
82                tabs.push(TabId::account(acct.label));
83            }
84        } else {
85            tabs.push(TabId::vendor(vendor));
86        }
87    }
88    tabs
89}
90
91#[derive(Debug)]
92pub struct App {
93    pub tabs_meta: Vec<TabId>,
94    pub active: usize,
95    pub tabs: Vec<TabState>,
96    /// Monotonically increasing identity for a complete tab-set replacement.
97    /// Background fetches carry this with their tab identity so results from a
98    /// previous Settings reload cannot land in a new tab at the old index.
99    pub tab_generation: u64,
100    /// When `true`, the Overview pane is selected (the virtual first tab that
101    /// summarizes every vendor at once) instead of a per-vendor detail tab.
102    pub overview: bool,
103    /// Which vendors the Overview lists (`[ui] overview_vendors`); `None` = all.
104    pub overview_vendors: Option<Vec<VendorId>>,
105    pub theme: Theme,
106    pub quit: bool,
107    /// When `Some`, the Settings overlay is open and consuming key events.
108    pub settings: Option<crate::tui::settings::SettingsState>,
109    /// Local context monitoring is separately opt-in and never changes the
110    /// vendor tab set.
111    pub context_enabled: bool,
112    /// Monotonic across overlay close/reopen cycles so an old detached scan
113    /// can never share the new overlay's first generation number.
114    pub context_generation: u64,
115    /// When `Some`, the local Claude Code context overlay owns keyboard input.
116    pub context: Option<crate::tui::context::ContextState>,
117    /// Presentation style for the vendor navigation box (`[ui] vendor_box`).
118    pub vendor_box: crate::config::VendorBoxStyle,
119}
120
121impl App {
122    pub fn new(tabs_meta: Vec<TabId>) -> Self {
123        // Production: resolve the palette from the environment (Omarchy theme
124        // if present, else One Dark).
125        Self::with_theme(tabs_meta, Theme::default().merged_with_omarchy())
126    }
127
128    /// Like [`App::new`] but with an explicit theme. Lets tests build an `App`
129    /// without reading the real Omarchy theme file
130    /// (`$HOME/.config/omarchy/current/theme/colors.toml`) — `new` resolves
131    /// that path and the `$HOME` env var via `merged_with_omarchy`, which is
132    /// not hermetic. Production code uses `new`/`new_with_primary`.
133    pub fn with_theme(tabs_meta: Vec<TabId>, theme: Theme) -> Self {
134        let n = tabs_meta.len();
135        Self {
136            tabs_meta,
137            active: 0,
138            tabs: vec![TabState::Loading; n],
139            tab_generation: 0,
140            overview: false,
141            overview_vendors: None,
142            theme,
143            quit: false,
144            settings: None,
145            context_enabled: false,
146            context_generation: 0,
147            context: None,
148            vendor_box: crate::config::VendorBoxStyle::Sidebar,
149        }
150    }
151
152    /// Construct with an initial active tab — usually `[ui] primary` from
153    /// config. Silently falls through to index 0 if the requested vendor
154    /// isn't present (e.g. it was disabled).
155    pub fn new_with_primary(tabs_meta: Vec<TabId>, primary: Option<VendorId>) -> Self {
156        let mut app = Self::new(tabs_meta);
157        // Default landing is the Overview (show everything at once). An explicit
158        // `[ui] primary` opts into opening on that vendor's tab instead.
159        if primary.is_some() {
160            app.select_primary(primary);
161        } else {
162            app.overview = true;
163        }
164        app
165    }
166
167    pub fn active_tab_id(&self) -> Option<&TabId> {
168        self.tabs_meta.get(self.active)
169    }
170
171    pub fn active_vendor(&self) -> Option<VendorId> {
172        self.tabs_meta.get(self.active).map(|t| t.vendor)
173    }
174
175    /// Replace the tab set — used after a Settings save reloads config, so
176    /// tabs added or removed in `config.toml` while the TUI is open (e.g. a
177    /// new `[[anthropic.accounts]]` entry) appear without a restart. Every
178    /// tab resets to `Loading` (the caller re-spawns fetches). The selected tab
179    /// is preserved by identity when possible; otherwise its old position is
180    /// clamped in case the list shrank.
181    pub fn set_tabs(&mut self, tabs_meta: Vec<TabId>) {
182        let selected = self.active_tab_id().cloned();
183        let fallback = self.active.min(tabs_meta.len().saturating_sub(1));
184        self.tab_generation = self.tab_generation.wrapping_add(1);
185        self.active = selected
186            .as_ref()
187            .and_then(|tab| tabs_meta.iter().position(|candidate| candidate == tab))
188            .unwrap_or(fallback);
189        self.tabs = vec![TabState::Loading; tabs_meta.len()];
190        self.tabs_meta = tabs_meta;
191    }
192
193    /// Apply an asynchronous refresh only when it still belongs to this tab
194    /// generation and the captured tab identity still exists. Lookup by
195    /// identity, rather than the old positional index, also makes a reordered
196    /// tab list safe.
197    pub fn apply_refresh(&mut self, generation: u64, tab: &TabId, state: TabState) -> bool {
198        if generation != self.tab_generation {
199            return false;
200        }
201        let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
202            return false;
203        };
204        self.tabs[index] = state;
205        true
206    }
207
208    /// Move to the first tab of `primary`'s vendor (the default account tab,
209    /// since it precedes any of that vendor's account tabs).
210    pub fn select_primary(&mut self, primary: Option<VendorId>) {
211        if let Some(p) = primary
212            && let Some(idx) = self.tabs_meta.iter().position(|t| t.vendor == p)
213        {
214            self.active = idx;
215            self.overview = false;
216        }
217    }
218
219    /// The selectable ring is `[Overview, tab0, tab1, …]`. `next_tab`/`prev_tab`
220    /// walk it, wrapping through the Overview at the ends.
221    pub fn next_tab(&mut self) {
222        if self.overview {
223            if !self.tabs_meta.is_empty() {
224                self.overview = false;
225                self.active = 0;
226            }
227        } else if self.active + 1 < self.tabs_meta.len() {
228            self.active += 1;
229        } else {
230            self.overview = true;
231        }
232    }
233
234    pub fn prev_tab(&mut self) {
235        if self.overview {
236            if !self.tabs_meta.is_empty() {
237                self.overview = false;
238                self.active = self.tabs_meta.len() - 1;
239            }
240        } else if self.active > 0 {
241            self.active -= 1;
242        } else {
243            self.overview = true;
244        }
245    }
246
247    /// Tabs the Overview should list: `overview_vendors` filtered against the
248    /// live tab set (preserving the config order), or all tabs when unset.
249    pub fn overview_tabs(&self) -> Vec<usize> {
250        match &self.overview_vendors {
251            None => (0..self.tabs_meta.len()).collect(),
252            Some(wanted) => wanted
253                .iter()
254                .flat_map(|v| {
255                    self.tabs_meta
256                        .iter()
257                        .enumerate()
258                        .filter(move |(_, t)| t.vendor == *v)
259                        .map(|(i, _)| i)
260                })
261                .collect(),
262        }
263    }
264}
265
266/// Fetch and render one tab — returns a `TabState`.
267pub async fn refresh_one(client: &Client, config: &Config, tab: &TabId) -> TabState {
268    match build_outcome(client, config, tab).await {
269        Ok(outcome) => {
270            // Resolve the cache age (a duration from "now" at fetch time) into an
271            // absolute instant ONCE. Without this, sections_for would recompute
272            // `Utc::now() - cache_age` on every draw and the displayed time would
273            // tick upward in real time instead of holding at the last refresh.
274            let now = Utc::now();
275            let fetched_at = outcome
276                .cache_age
277                .map(|age| now - chrono::Duration::from_std(age).unwrap_or_default());
278            TabState::Ready(Box::new(ReadyTab {
279                snapshot: outcome.snapshot,
280                stale: outcome.stale,
281                last_error: outcome.last_error.map(|(code, message)| {
282                    (code, crate::display::sanitize_untrusted_field(&message))
283                }),
284                fetched_at,
285            }))
286        }
287        Err(e) => TabState::Error(crate::display::sanitize_untrusted_field(&e.to_string())),
288    }
289}
290
291async fn build_outcome(client: &Client, config: &Config, tab: &TabId) -> Result<VendorOutcome> {
292    match tab.vendor {
293        VendorId::Anthropic => {
294            // A named account resolves to its own file + `anthropic/<label>`
295            // cache, shared with the widget via `account_target` (#14/#17).
296            // The default tab keeps the pre-existing resolution: config
297            // `credentials_path` is an explicit strict read, and only the
298            // platform default gets the macOS Keychain fallback.
299            let (creds_target, cache) = match tab.account.as_deref() {
300                Some(label) => config.anthropic.account_target(label)?,
301                None => {
302                    let target = match config.anthropic.credentials_path.clone() {
303                        Some(p) => crate::anthropic::creds::CredsTarget::Explicit(p),
304                        None => crate::anthropic::creds::CredsTarget::Default(
305                            crate::anthropic::creds::default_path().unwrap_or_default(),
306                        ),
307                    };
308                    (target, crate::cache::Cache::for_vendor("anthropic")?)
309                }
310            };
311            let endpoints = crate::anthropic::fetch::Endpoints::default();
312            let outcome = crate::anthropic::fetch_snapshot(
313                client,
314                &creds_target,
315                &cache,
316                &endpoints,
317                DEFAULT_TTL,
318            )
319            .await?;
320            Ok(crate::vendor::VendorOutcome {
321                snapshot: crate::usage::VendorSnapshot::Anthropic(outcome.snapshot),
322                stale: outcome.stale,
323                last_error: outcome.last_error,
324                cache_age: outcome.cache_age,
325            })
326        }
327        VendorId::AnthropicApi => {
328            let key = crate::config::resolve_api_key(
329                "Anthropic_API",
330                &config.anthropic_api.api_key_env,
331                config.anthropic_api.api_key.as_deref(),
332            )?;
333            let cache = crate::cache::Cache::for_vendor("anthropic_api")?;
334            let endpoints = crate::anthropic_api::fetch::Endpoints::default();
335            let outcome = crate::anthropic_api::fetch_snapshot(
336                client,
337                &key,
338                &cache,
339                &endpoints,
340                DEFAULT_TTL,
341                config.anthropic_api.monthly_limit,
342            )
343            .await?;
344            Ok(outcome.into())
345        }
346        VendorId::Openrouter => {
347            let api_key = crate::config::resolve_api_key(
348                "OpenRouter",
349                &config.openrouter.api_key_env,
350                config.openrouter.api_key.as_deref(),
351            )?;
352            let cache = crate::cache::Cache::for_vendor("openrouter")?;
353            let endpoints = crate::openrouter::fetch::Endpoints::default();
354            let outcome = crate::openrouter::fetch_snapshot(
355                client,
356                &api_key,
357                &cache,
358                &endpoints,
359                DEFAULT_TTL,
360            )
361            .await?;
362            Ok(outcome.into())
363        }
364        VendorId::Zai => {
365            let api_key = crate::config::resolve_api_key(
366                "Zai",
367                &config.zai.api_key_env,
368                config.zai.api_key.as_deref(),
369            )?;
370            let cache = crate::cache::Cache::for_vendor("zai")?;
371            let endpoints = crate::zai::fetch::Endpoints::default();
372            let outcome = crate::zai::fetch_snapshot(
373                client,
374                &api_key,
375                &cache,
376                &endpoints,
377                DEFAULT_TTL,
378                config.zai.plan_tier.as_deref(),
379            )
380            .await?;
381            Ok(outcome.into())
382        }
383        VendorId::Openai => {
384            let cache = crate::cache::Cache::for_vendor("openai")?;
385            let creds_path = config
386                .openai
387                .codex_auth_path
388                .clone()
389                .unwrap_or_else(|| crate::openai::creds::default_path().unwrap_or_default());
390            let endpoints = crate::openai::fetch::Endpoints::default();
391            let outcome =
392                crate::openai::fetch_snapshot(client, &creds_path, &cache, &endpoints, DEFAULT_TTL)
393                    .await?;
394            Ok(outcome.into())
395        }
396        VendorId::Deepseek => {
397            let api_key = crate::config::resolve_api_key(
398                "DeepSeek",
399                &config.deepseek.api_key_env,
400                config.deepseek.api_key.as_deref(),
401            )?;
402            let cache = crate::cache::Cache::for_vendor("deepseek")?;
403            let endpoints = crate::deepseek::fetch::Endpoints::default();
404            let outcome =
405                crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
406                    .await?;
407            Ok(outcome.into())
408        }
409        VendorId::Kimi => {
410            let api_key = crate::config::resolve_api_key(
411                "Kimi",
412                &config.kimi.api_key_env,
413                config.kimi.api_key.as_deref(),
414            )?;
415            let cache = crate::cache::Cache::for_vendor("kimi")?;
416            let endpoints = crate::kimi::fetch::Endpoints::default();
417            let outcome =
418                crate::kimi::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
419                    .await?;
420            Ok(outcome.into())
421        }
422        VendorId::Kilo => {
423            let api_key = crate::config::resolve_api_key(
424                "Kilo",
425                &config.kilo.api_key_env,
426                config.kilo.api_key.as_deref(),
427            )?;
428            let cache = crate::cache::Cache::for_vendor("kilo")?;
429            let endpoints = crate::kilo::fetch::Endpoints::default();
430            let outcome = crate::kilo::fetch_snapshot(
431                client,
432                &api_key,
433                &cache,
434                &endpoints,
435                DEFAULT_TTL,
436                config.kilo.organization_id.as_deref(),
437            )
438            .await?;
439            Ok(outcome.into())
440        }
441        VendorId::Novita => {
442            let api_key = crate::config::resolve_api_key(
443                "Novita",
444                &config.novita.api_key_env,
445                config.novita.api_key.as_deref(),
446            )?;
447            let cache = crate::cache::Cache::for_vendor("novita")?;
448            let endpoints = crate::novita::fetch::Endpoints::default();
449            let outcome =
450                crate::novita::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
451                    .await?;
452            Ok(outcome.into())
453        }
454        VendorId::Moonshot => {
455            let api_key = crate::config::resolve_api_key(
456                "Moonshot",
457                &config.moonshot.api_key_env,
458                config.moonshot.api_key.as_deref(),
459            )?;
460            let cache = crate::cache::Cache::for_vendor("moonshot")?;
461            let (endpoints, currency) =
462                crate::moonshot::fetch::Endpoints::for_region(&config.moonshot.region);
463            let outcome = crate::moonshot::fetch_snapshot(
464                client,
465                &api_key,
466                &cache,
467                &endpoints,
468                DEFAULT_TTL,
469                currency,
470            )
471            .await?;
472            Ok(outcome.into())
473        }
474        VendorId::Grok => {
475            let key = crate::config::resolve_api_key(
476                "Grok",
477                &config.grok.api_key_env,
478                config.grok.api_key.as_deref(),
479            )?;
480            let cache = crate::cache::Cache::for_vendor("grok")?;
481            let endpoints = crate::grok::fetch::Endpoints::default();
482            let outcome = crate::grok::fetch_snapshot(
483                client,
484                &key,
485                &cache,
486                &endpoints,
487                DEFAULT_TTL,
488                config.grok.team_id.as_deref(),
489            )
490            .await?;
491            Ok(outcome.into())
492        }
493        VendorId::Antigravity => {
494            // No credentials: the local Antigravity server is the source.
495            let cache = crate::cache::Cache::for_vendor("antigravity")?;
496            let outcome = crate::antigravity::fetch_snapshot(client, &cache, DEFAULT_TTL).await?;
497            Ok(outcome.into())
498        }
499        VendorId::Minimax => {
500            let api_key = crate::config::resolve_api_key(
501                "MiniMax",
502                &config.minimax.api_key_env,
503                config.minimax.api_key.as_deref(),
504            )?;
505            let cache = crate::cache::Cache::for_vendor("minimax")?;
506            let endpoints = crate::minimax::fetch::Endpoints::for_region(&config.minimax.region);
507            let outcome =
508                crate::minimax::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
509                    .await?;
510            Ok(outcome.into())
511        }
512        VendorId::Cursor => {
513            let cache = crate::cache::Cache::for_vendor("cursor")?;
514            let db_path = config
515                .cursor
516                .db_path
517                .clone()
518                .map(Ok)
519                .unwrap_or_else(crate::cursor::db::default_db_path)?;
520            let endpoints = crate::cursor::fetch::Endpoints::default();
521            let outcome =
522                crate::cursor::fetch_snapshot(client, &db_path, &cache, &endpoints, DEFAULT_TTL)
523                    .await?;
524            Ok(outcome.into())
525        }
526    }
527}
528
529/// Convenience for the watch-driven binary: how long to wait between
530/// automatic refreshes.
531pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
532
533/// Gap between successive Anthropic fetches at refresh time. Every Anthropic
534/// tab (the default account and each named/discovered account) hits the same
535/// `/api/oauth/usage` + token-refresh endpoints, which rate-limit a burst of
536/// simultaneous requests from one client — so with several accounts the TUI
537/// would fire them all at once and some would come back `429`. Spacing them
538/// out keeps every account refreshing politely.
539pub const ANTHROPIC_REFRESH_STAGGER: Duration = Duration::from_millis(800);
540
541/// Per-tab startup delay for one `spawn_all` pass. Only Anthropic tabs are
542/// staggered (they share the rate-limited endpoint and multiply with accounts);
543/// every other vendor hits its own endpoint and starts immediately. The first
544/// Anthropic tab also starts immediately; each subsequent one waits one more
545/// `step`. Pure and position-based so it is unit-testable.
546pub fn refresh_stagger(tabs: &[TabId], step: Duration) -> Vec<Duration> {
547    let mut anthropic_seen: u32 = 0;
548    tabs.iter()
549        .map(|tab| {
550            if tab.vendor == VendorId::Anthropic {
551                let delay = step * anthropic_seen;
552                anthropic_seen += 1;
553                delay
554            } else {
555                Duration::ZERO
556            }
557        })
558        .collect()
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use chrono::TimeZone;
565
566    // Use `App::with_theme(.., Theme::default())` rather than `App::new`, which
567    // would read the real Omarchy theme file + `$HOME`. The tab-selection logic
568    // under test is theme-agnostic.
569    #[test]
570    fn refresh_stagger_spaces_out_anthropic_tabs_only() {
571        let step = Duration::from_millis(800);
572        let tabs = vec![
573            TabId::vendor(VendorId::Anthropic), // default account
574            TabId::account("work"),
575            TabId::account("personal"),
576            TabId::vendor(VendorId::Openai),
577            TabId::vendor(VendorId::Zai),
578        ];
579        let delays = refresh_stagger(&tabs, step);
580        assert_eq!(
581            delays,
582            vec![
583                Duration::ZERO, // 1st anthropic — immediate
584                step,           // 2nd anthropic
585                step * 2,       // 3rd anthropic
586                Duration::ZERO, // openai — own endpoint, immediate
587                Duration::ZERO, // zai — own endpoint, immediate
588            ]
589        );
590    }
591
592    #[test]
593    fn refresh_stagger_is_a_noop_without_anthropic_accounts() {
594        // A single Anthropic tab (or none) never waits.
595        let tabs = vec![
596            TabId::vendor(VendorId::Anthropic),
597            TabId::vendor(VendorId::Openrouter),
598        ];
599        assert!(
600            refresh_stagger(&tabs, Duration::from_millis(800))
601                .iter()
602                .all(|d| d.is_zero())
603        );
604    }
605
606    #[test]
607    fn select_primary_moves_to_enabled_vendor() {
608        let mut app = App::with_theme(
609            vec![
610                TabId::vendor(VendorId::Anthropic),
611                TabId::vendor(VendorId::Openrouter),
612            ],
613            Theme::default(),
614        );
615        app.select_primary(Some(VendorId::Openrouter));
616        assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
617    }
618
619    #[test]
620    fn select_primary_ignores_disabled_vendor() {
621        let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
622        app.select_primary(Some(VendorId::Openai));
623        assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
624    }
625
626    #[test]
627    fn nav_ring_wraps_through_the_overview_at_both_ends() {
628        let mut app = App::with_theme(
629            vec![
630                TabId::vendor(VendorId::Anthropic),
631                TabId::vendor(VendorId::Openai),
632            ],
633            Theme::default(),
634        );
635        app.overview = true;
636
637        app.next_tab(); // Overview -> first vendor
638        assert!(!app.overview);
639        assert_eq!(app.active, 0);
640        app.next_tab();
641        assert_eq!(app.active, 1);
642        app.next_tab(); // last vendor -> Overview
643        assert!(app.overview);
644
645        app.prev_tab(); // Overview -> last vendor
646        assert!(!app.overview);
647        assert_eq!(app.active, 1);
648        app.prev_tab();
649        assert_eq!(app.active, 0);
650        app.prev_tab(); // first vendor -> Overview
651        assert!(app.overview);
652    }
653
654    #[test]
655    fn overview_tabs_defaults_to_all_and_honors_the_config_filter() {
656        let mut app = App::with_theme(
657            vec![
658                TabId::vendor(VendorId::Anthropic),
659                TabId::vendor(VendorId::Openai),
660                TabId::vendor(VendorId::Zai),
661            ],
662            Theme::default(),
663        );
664        assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
665
666        // Subset in the given order.
667        app.overview_vendors = Some(vec![VendorId::Zai, VendorId::Anthropic]);
668        assert_eq!(app.overview_tabs(), vec![2, 0]);
669
670        // A listed-but-absent vendor is simply skipped.
671        app.overview_vendors = Some(vec![VendorId::Grok, VendorId::Openai]);
672        assert_eq!(app.overview_tabs(), vec![1]);
673    }
674
675    fn config_with_accounts(labels: &[&str]) -> Config {
676        let mut config = Config::default();
677        // Keep only Anthropic enabled so the test asserts on account expansion,
678        // not on the full default vendor set.
679        config.openai.enabled = false;
680        config.zai.enabled = false;
681        config.openrouter.enabled = false;
682        config.anthropic.accounts = labels
683            .iter()
684            .map(|l| crate::config::AnthropicAccount {
685                label: (*l).to_string(),
686                credentials_path: format!("/creds/{l}.json").into(),
687            })
688            .collect();
689        config
690    }
691
692    #[test]
693    fn show_default_account_false_hides_the_unnamed_claude_tab() {
694        // With named accounts and show_default_account=false, only the named
695        // tabs appear — no redundant default "Claude" tab.
696        let mut config = config_with_accounts(&["work", "personal"]);
697        config.anthropic.show_default_account = false;
698        assert_eq!(
699            tabs_from_config(&config),
700            vec![TabId::account("work"), TabId::account("personal")]
701        );
702
703        // But with no named accounts it is kept, so Anthropic never loses its
704        // only tab.
705        let mut empty = Config::default();
706        empty.openai.enabled = false;
707        empty.zai.enabled = false;
708        empty.openrouter.enabled = false;
709        empty.anthropic.show_default_account = false;
710        assert_eq!(
711            tabs_from_config(&empty),
712            vec![TabId::vendor(VendorId::Anthropic)]
713        );
714    }
715
716    #[test]
717    fn tabs_expand_anthropic_accounts_after_default() {
718        // Default Claude tab first, then each account in config order.
719        let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
720        assert_eq!(
721            tabs,
722            vec![
723                TabId::vendor(VendorId::Anthropic),
724                TabId::account("work"),
725                TabId::account("personal"),
726            ]
727        );
728    }
729
730    #[test]
731    fn tabs_without_accounts_are_just_enabled_vendors() {
732        // No [[anthropic.accounts]] → one tab per enabled vendor, unchanged.
733        let config = Config::default();
734        let tabs = tabs_from_config(&config);
735        let vendors: Vec<VendorId> = tabs.iter().map(|t| t.vendor).collect();
736        assert_eq!(vendors, config.enabled_vendors());
737        assert!(tabs.iter().all(|t| t.account.is_none()));
738    }
739
740    #[test]
741    fn tabs_include_accounts_auto_discovered_from_accounts_dir() {
742        // A CLAUDE_CONFIG_DIR-style directory becomes account tabs with no
743        // explicit [[anthropic.accounts]] entry. Hermetic: real TempDir.
744        let td = tempfile::tempdir().unwrap();
745        for label in ["work", "personal"] {
746            let dir = td.path().join(label);
747            std::fs::create_dir_all(&dir).unwrap();
748            std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
749        }
750        let mut config = Config::default();
751        config.openai.enabled = false;
752        config.zai.enabled = false;
753        config.openrouter.enabled = false;
754        config.anthropic.accounts_dir = Some(td.path().to_path_buf());
755
756        let tabs = tabs_from_config(&config);
757        assert_eq!(
758            tabs,
759            vec![
760                TabId::vendor(VendorId::Anthropic),
761                TabId::account("personal"), // sorted by label
762                TabId::account("work"),
763            ]
764        );
765    }
766
767    #[test]
768    fn set_tabs_resets_states_and_clamps_selection() {
769        // Simulates a Settings save that shrank the tab list: the selection
770        // must clamp into range and every tab must reset to Loading so the
771        // caller's spawn_all repopulates against the new config.
772        let mut app = App::with_theme(
773            tabs_from_config(&config_with_accounts(&["work", "personal"])),
774            Theme::default(),
775        );
776        app.active = 2; // "personal"
777        app.tabs[0] = TabState::Error("old".into());
778
779        app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
780        assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
781        assert_eq!(app.active, 0, "selection clamped after shrink");
782        assert!(matches!(app.tabs[0], TabState::Loading));
783    }
784
785    #[test]
786    fn set_tabs_preserves_selected_identity_when_entries_are_inserted() {
787        let mut app = App::with_theme(
788            vec![
789                TabId::vendor(VendorId::Anthropic),
790                TabId::vendor(VendorId::Openai),
791            ],
792            Theme::default(),
793        );
794        app.active = 1;
795
796        app.set_tabs(vec![
797            TabId::vendor(VendorId::Anthropic),
798            TabId::account("work"),
799            TabId::vendor(VendorId::Openai),
800        ]);
801
802        assert_eq!(app.active, 2);
803        assert_eq!(app.active_tab_id(), Some(&TabId::vendor(VendorId::Openai)));
804    }
805
806    #[test]
807    fn refresh_from_old_generation_is_discarded() {
808        let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
809        let old_generation = app.tab_generation;
810        app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
811
812        assert!(!app.apply_refresh(
813            old_generation,
814            &TabId::vendor(VendorId::Anthropic),
815            TabState::Error("old result".into()),
816        ));
817        assert!(matches!(app.tabs[0], TabState::Loading));
818    }
819
820    #[test]
821    fn refresh_identity_mismatch_is_discarded() {
822        let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
823        let generation = app.tab_generation;
824
825        assert!(!app.apply_refresh(
826            generation,
827            &TabId::vendor(VendorId::Openai),
828            TabState::Error("wrong tab".into()),
829        ));
830        assert!(matches!(app.tabs[0], TabState::Loading));
831    }
832
833    #[test]
834    fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
835        let anthropic = TabId::vendor(VendorId::Anthropic);
836        let openai = TabId::vendor(VendorId::Openai);
837        let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
838        let generation = app.tab_generation;
839
840        // A reorder is safe because delivery resolves the captured identity,
841        // not a stale positional index.
842        app.tabs_meta.swap(0, 1);
843        app.tabs.swap(0, 1);
844        assert!(app.apply_refresh(generation, &anthropic, TabState::Error("ready".into())));
845        assert!(matches!(app.tabs[0], TabState::Loading));
846        assert!(matches!(&app.tabs[1], TabState::Error(message) if message == "ready"));
847    }
848
849    fn ready_at(fetched_at: chrono::DateTime<Utc>) -> TabState {
850        TabState::Ready(Box::new(ReadyTab {
851            snapshot: crate::usage::VendorSnapshot::Openrouter(crate::usage::OpenRouterSnapshot {
852                label: "test".into(),
853                total_credits: 0.0,
854                total_usage: 0.0,
855                usage_daily: 0.0,
856                usage_weekly: 0.0,
857                usage_monthly: 0.0,
858                is_free_tier: false,
859                limit: None,
860                limit_remaining: None,
861            }),
862            stale: false,
863            last_error: None,
864            fetched_at: Some(fetched_at),
865        }))
866    }
867
868    #[test]
869    fn apply_refresh_stamps_fetched_at_on_only_the_matching_tab() {
870        // Pins the per-tab `fetched_at` the header now reads: a landed Anthropic
871        // response leaves the still-loading OpenAI tab with no time of its own.
872        // Dropping the global `last_refresh` clock is not observable from here
873        // (it was write-only) — that is asserted against the rendered header in
874        // `view::tests::header_refresh_*`.
875        let anthropic = TabId::vendor(VendorId::Anthropic);
876        let openai = TabId::vendor(VendorId::Openai);
877        let mut app = App::with_theme(vec![anthropic.clone(), openai], Theme::default());
878        let generation = app.tab_generation;
879        let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
880
881        assert!(app.apply_refresh(generation, &anthropic, ready_at(fetched_at)));
882        match &app.tabs[0] {
883            TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
884            other => panic!("expected Anthropic tab Ready, got {other:?}"),
885        }
886        assert!(matches!(app.tabs[1], TabState::Loading));
887    }
888
889    #[test]
890    fn select_primary_lands_on_default_account_tab() {
891        // With account tabs present, `primary = anthropic` selects the default
892        // Claude tab (index 0), not one of its account tabs.
893        let app = {
894            let tabs = tabs_from_config(&config_with_accounts(&["work"]));
895            let mut a = App::with_theme(tabs, Theme::default());
896            a.select_primary(Some(VendorId::Anthropic));
897            a
898        };
899        assert_eq!(app.active, 0);
900        assert_eq!(
901            app.active_tab_id(),
902            Some(&TabId::vendor(VendorId::Anthropic))
903        );
904    }
905}