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