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        tabs.push(TabId::vendor(vendor));
74        if vendor == VendorId::Anthropic {
75            for acct in &config.anthropic.accounts {
76                tabs.push(TabId::account(acct.label.clone()));
77            }
78        }
79    }
80    tabs
81}
82
83#[derive(Debug)]
84pub struct App {
85    pub tabs_meta: Vec<TabId>,
86    pub active: usize,
87    pub tabs: Vec<TabState>,
88    /// Monotonically increasing identity for a complete tab-set replacement.
89    /// Background fetches carry this with their tab identity so results from a
90    /// previous Settings reload cannot land in a new tab at the old index.
91    pub tab_generation: u64,
92    pub theme: Theme,
93    pub quit: bool,
94    /// When `Some`, the Settings overlay is open and consuming key events.
95    pub settings: Option<crate::tui::settings::SettingsState>,
96    /// Local context monitoring is separately opt-in and never changes the
97    /// vendor tab set.
98    pub context_enabled: bool,
99    /// Monotonic across overlay close/reopen cycles so an old detached scan
100    /// can never share the new overlay's first generation number.
101    pub context_generation: u64,
102    /// When `Some`, the local Claude Code context overlay owns keyboard input.
103    pub context: Option<crate::tui::context::ContextState>,
104}
105
106impl App {
107    pub fn new(tabs_meta: Vec<TabId>) -> Self {
108        // Production: resolve the palette from the environment (Omarchy theme
109        // if present, else One Dark).
110        Self::with_theme(tabs_meta, Theme::default().merged_with_omarchy())
111    }
112
113    /// Like [`App::new`] but with an explicit theme. Lets tests build an `App`
114    /// without reading the real Omarchy theme file
115    /// (`$HOME/.config/omarchy/current/theme/colors.toml`) — `new` resolves
116    /// that path and the `$HOME` env var via `merged_with_omarchy`, which is
117    /// not hermetic. Production code uses `new`/`new_with_primary`.
118    pub fn with_theme(tabs_meta: Vec<TabId>, theme: Theme) -> Self {
119        let n = tabs_meta.len();
120        Self {
121            tabs_meta,
122            active: 0,
123            tabs: vec![TabState::Loading; n],
124            tab_generation: 0,
125            theme,
126            quit: false,
127            settings: None,
128            context_enabled: false,
129            context_generation: 0,
130            context: None,
131        }
132    }
133
134    /// Construct with an initial active tab — usually `[ui] primary` from
135    /// config. Silently falls through to index 0 if the requested vendor
136    /// isn't present (e.g. it was disabled).
137    pub fn new_with_primary(tabs_meta: Vec<TabId>, primary: Option<VendorId>) -> Self {
138        let mut app = Self::new(tabs_meta);
139        app.select_primary(primary);
140        app
141    }
142
143    pub fn active_tab_id(&self) -> Option<&TabId> {
144        self.tabs_meta.get(self.active)
145    }
146
147    pub fn active_vendor(&self) -> Option<VendorId> {
148        self.tabs_meta.get(self.active).map(|t| t.vendor)
149    }
150
151    /// Replace the tab set — used after a Settings save reloads config, so
152    /// tabs added or removed in `config.toml` while the TUI is open (e.g. a
153    /// new `[[anthropic.accounts]]` entry) appear without a restart. Every
154    /// tab resets to `Loading` (the caller re-spawns fetches) and the
155    /// selection is clamped in case the list shrank.
156    pub fn set_tabs(&mut self, tabs_meta: Vec<TabId>) {
157        self.tab_generation = self.tab_generation.wrapping_add(1);
158        self.active = self.active.min(tabs_meta.len().saturating_sub(1));
159        self.tabs = vec![TabState::Loading; tabs_meta.len()];
160        self.tabs_meta = tabs_meta;
161    }
162
163    /// Apply an asynchronous refresh only when it still belongs to this tab
164    /// generation and the captured tab identity still exists. Lookup by
165    /// identity, rather than the old positional index, also makes a reordered
166    /// tab list safe.
167    pub fn apply_refresh(&mut self, generation: u64, tab: &TabId, state: TabState) -> bool {
168        if generation != self.tab_generation {
169            return false;
170        }
171        let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
172            return false;
173        };
174        self.tabs[index] = state;
175        true
176    }
177
178    /// Move to the first tab of `primary`'s vendor (the default account tab,
179    /// since it precedes any of that vendor's account tabs).
180    pub fn select_primary(&mut self, primary: Option<VendorId>) {
181        if let Some(p) = primary
182            && let Some(idx) = self.tabs_meta.iter().position(|t| t.vendor == p)
183        {
184            self.active = idx;
185        }
186    }
187
188    pub fn next_tab(&mut self) {
189        if !self.tabs_meta.is_empty() {
190            self.active = (self.active + 1) % self.tabs_meta.len();
191        }
192    }
193
194    pub fn prev_tab(&mut self) {
195        if !self.tabs_meta.is_empty() {
196            self.active = (self.active + self.tabs_meta.len() - 1) % self.tabs_meta.len();
197        }
198    }
199}
200
201/// Fetch and render one tab — returns a `TabState`.
202pub async fn refresh_one(client: &Client, config: &Config, tab: &TabId) -> TabState {
203    match build_outcome(client, config, tab).await {
204        Ok(outcome) => {
205            // Resolve the cache age (a duration from "now" at fetch time) into an
206            // absolute instant ONCE. Without this, sections_for would recompute
207            // `Utc::now() - cache_age` on every draw and the displayed time would
208            // tick upward in real time instead of holding at the last refresh.
209            let now = Utc::now();
210            let fetched_at = outcome
211                .cache_age
212                .map(|age| now - chrono::Duration::from_std(age).unwrap_or_default());
213            TabState::Ready(Box::new(ReadyTab {
214                snapshot: outcome.snapshot,
215                stale: outcome.stale,
216                last_error: outcome.last_error,
217                fetched_at,
218            }))
219        }
220        Err(e) => TabState::Error(e.to_string()),
221    }
222}
223
224async fn build_outcome(client: &Client, config: &Config, tab: &TabId) -> Result<VendorOutcome> {
225    match tab.vendor {
226        VendorId::Anthropic => {
227            // A named account resolves to its own file + `anthropic/<label>`
228            // cache, shared with the widget via `account_target` (#14/#17).
229            // The default tab keeps the pre-existing resolution: config
230            // `credentials_path` is an explicit strict read, and only the
231            // platform default gets the macOS Keychain fallback.
232            let (creds_target, cache) = match tab.account.as_deref() {
233                Some(label) => config.anthropic.account_target(label)?,
234                None => {
235                    let target = match config.anthropic.credentials_path.clone() {
236                        Some(p) => crate::anthropic::creds::CredsTarget::Explicit(p),
237                        None => crate::anthropic::creds::CredsTarget::Default(
238                            crate::anthropic::creds::default_path().unwrap_or_default(),
239                        ),
240                    };
241                    (target, crate::cache::Cache::for_vendor("anthropic")?)
242                }
243            };
244            let endpoints = crate::anthropic::fetch::Endpoints::default();
245            let outcome = crate::anthropic::fetch_snapshot(
246                client,
247                &creds_target,
248                &cache,
249                &endpoints,
250                DEFAULT_TTL,
251            )
252            .await?;
253            Ok(crate::vendor::VendorOutcome {
254                snapshot: crate::usage::VendorSnapshot::Anthropic(outcome.snapshot),
255                stale: outcome.stale,
256                last_error: outcome.last_error,
257                cache_age: outcome.cache_age,
258            })
259        }
260        VendorId::AnthropicApi => {
261            let key = crate::config::resolve_api_key(
262                "Anthropic_API",
263                &config.anthropic_api.api_key_env,
264                config.anthropic_api.api_key.as_deref(),
265            )?;
266            let cache = crate::cache::Cache::for_vendor("anthropic_api")?;
267            let endpoints = crate::anthropic_api::fetch::Endpoints::default();
268            let outcome = crate::anthropic_api::fetch_snapshot(
269                client,
270                &key,
271                &cache,
272                &endpoints,
273                DEFAULT_TTL,
274                config.anthropic_api.monthly_limit,
275            )
276            .await?;
277            Ok(outcome.into())
278        }
279        VendorId::Openrouter => {
280            let api_key = crate::config::resolve_api_key(
281                "OpenRouter",
282                &config.openrouter.api_key_env,
283                config.openrouter.api_key.as_deref(),
284            )?;
285            let cache = crate::cache::Cache::for_vendor("openrouter")?;
286            let endpoints = crate::openrouter::fetch::Endpoints::default();
287            let outcome = crate::openrouter::fetch_snapshot(
288                client,
289                &api_key,
290                &cache,
291                &endpoints,
292                DEFAULT_TTL,
293            )
294            .await?;
295            Ok(outcome.into())
296        }
297        VendorId::Zai => {
298            let api_key = crate::config::resolve_api_key(
299                "Zai",
300                &config.zai.api_key_env,
301                config.zai.api_key.as_deref(),
302            )?;
303            let cache = crate::cache::Cache::for_vendor("zai")?;
304            let endpoints = crate::zai::fetch::Endpoints::default();
305            let outcome = crate::zai::fetch_snapshot(
306                client,
307                &api_key,
308                &cache,
309                &endpoints,
310                DEFAULT_TTL,
311                config.zai.plan_tier.as_deref(),
312            )
313            .await?;
314            Ok(outcome.into())
315        }
316        VendorId::Openai => {
317            let cache = crate::cache::Cache::for_vendor("openai")?;
318            let creds_path = config
319                .openai
320                .codex_auth_path
321                .clone()
322                .unwrap_or_else(|| crate::openai::creds::default_path().unwrap_or_default());
323            let endpoints = crate::openai::fetch::Endpoints::default();
324            let outcome =
325                crate::openai::fetch_snapshot(client, &creds_path, &cache, &endpoints, DEFAULT_TTL)
326                    .await?;
327            Ok(outcome.into())
328        }
329        VendorId::Deepseek => {
330            let api_key = crate::config::resolve_api_key(
331                "DeepSeek",
332                &config.deepseek.api_key_env,
333                config.deepseek.api_key.as_deref(),
334            )?;
335            let cache = crate::cache::Cache::for_vendor("deepseek")?;
336            let endpoints = crate::deepseek::fetch::Endpoints::default();
337            let outcome =
338                crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
339                    .await?;
340            Ok(outcome.into())
341        }
342        VendorId::Kimi => {
343            let api_key = crate::config::resolve_api_key(
344                "Kimi",
345                &config.kimi.api_key_env,
346                config.kimi.api_key.as_deref(),
347            )?;
348            let cache = crate::cache::Cache::for_vendor("kimi")?;
349            let endpoints = crate::kimi::fetch::Endpoints::default();
350            let outcome =
351                crate::kimi::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
352                    .await?;
353            Ok(outcome.into())
354        }
355        VendorId::Kilo => {
356            let api_key = crate::config::resolve_api_key(
357                "Kilo",
358                &config.kilo.api_key_env,
359                config.kilo.api_key.as_deref(),
360            )?;
361            let cache = crate::cache::Cache::for_vendor("kilo")?;
362            let endpoints = crate::kilo::fetch::Endpoints::default();
363            let outcome = crate::kilo::fetch_snapshot(
364                client,
365                &api_key,
366                &cache,
367                &endpoints,
368                DEFAULT_TTL,
369                config.kilo.organization_id.as_deref(),
370            )
371            .await?;
372            Ok(outcome.into())
373        }
374        VendorId::Novita => {
375            let api_key = crate::config::resolve_api_key(
376                "Novita",
377                &config.novita.api_key_env,
378                config.novita.api_key.as_deref(),
379            )?;
380            let cache = crate::cache::Cache::for_vendor("novita")?;
381            let endpoints = crate::novita::fetch::Endpoints::default();
382            let outcome =
383                crate::novita::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
384                    .await?;
385            Ok(outcome.into())
386        }
387        VendorId::Moonshot => {
388            let api_key = crate::config::resolve_api_key(
389                "Moonshot",
390                &config.moonshot.api_key_env,
391                config.moonshot.api_key.as_deref(),
392            )?;
393            let cache = crate::cache::Cache::for_vendor("moonshot")?;
394            let (endpoints, currency) =
395                crate::moonshot::fetch::Endpoints::for_region(&config.moonshot.region);
396            let outcome = crate::moonshot::fetch_snapshot(
397                client,
398                &api_key,
399                &cache,
400                &endpoints,
401                DEFAULT_TTL,
402                currency,
403            )
404            .await?;
405            Ok(outcome.into())
406        }
407        VendorId::Grok => {
408            let key = crate::config::resolve_api_key(
409                "Grok",
410                &config.grok.api_key_env,
411                config.grok.api_key.as_deref(),
412            )?;
413            let cache = crate::cache::Cache::for_vendor("grok")?;
414            let endpoints = crate::grok::fetch::Endpoints::default();
415            let outcome = crate::grok::fetch_snapshot(
416                client,
417                &key,
418                &cache,
419                &endpoints,
420                DEFAULT_TTL,
421                config.grok.team_id.as_deref(),
422            )
423            .await?;
424            Ok(outcome.into())
425        }
426        VendorId::Antigravity => {
427            // No credentials: the local Antigravity server is the source.
428            let cache = crate::cache::Cache::for_vendor("antigravity")?;
429            let outcome = crate::antigravity::fetch_snapshot(client, &cache, DEFAULT_TTL).await?;
430            Ok(outcome.into())
431        }
432    }
433}
434
435/// Convenience for the watch-driven binary: how long to wait between
436/// automatic refreshes.
437pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use chrono::TimeZone;
443
444    // Use `App::with_theme(.., Theme::default())` rather than `App::new`, which
445    // would read the real Omarchy theme file + `$HOME`. The tab-selection logic
446    // under test is theme-agnostic.
447    #[test]
448    fn select_primary_moves_to_enabled_vendor() {
449        let mut app = App::with_theme(
450            vec![
451                TabId::vendor(VendorId::Anthropic),
452                TabId::vendor(VendorId::Openrouter),
453            ],
454            Theme::default(),
455        );
456        app.select_primary(Some(VendorId::Openrouter));
457        assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
458    }
459
460    #[test]
461    fn select_primary_ignores_disabled_vendor() {
462        let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
463        app.select_primary(Some(VendorId::Openai));
464        assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
465    }
466
467    fn config_with_accounts(labels: &[&str]) -> Config {
468        let mut config = Config::default();
469        // Keep only Anthropic enabled so the test asserts on account expansion,
470        // not on the full default vendor set.
471        config.openai.enabled = false;
472        config.zai.enabled = false;
473        config.openrouter.enabled = false;
474        config.anthropic.accounts = labels
475            .iter()
476            .map(|l| crate::config::AnthropicAccount {
477                label: (*l).to_string(),
478                credentials_path: format!("/creds/{l}.json").into(),
479            })
480            .collect();
481        config
482    }
483
484    #[test]
485    fn tabs_expand_anthropic_accounts_after_default() {
486        // Default Claude tab first, then each account in config order.
487        let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
488        assert_eq!(
489            tabs,
490            vec![
491                TabId::vendor(VendorId::Anthropic),
492                TabId::account("work"),
493                TabId::account("personal"),
494            ]
495        );
496    }
497
498    #[test]
499    fn tabs_without_accounts_are_just_enabled_vendors() {
500        // No [[anthropic.accounts]] → one tab per enabled vendor, unchanged.
501        let config = Config::default();
502        let tabs = tabs_from_config(&config);
503        let vendors: Vec<VendorId> = tabs.iter().map(|t| t.vendor).collect();
504        assert_eq!(vendors, config.enabled_vendors());
505        assert!(tabs.iter().all(|t| t.account.is_none()));
506    }
507
508    #[test]
509    fn set_tabs_resets_states_and_clamps_selection() {
510        // Simulates a Settings save that shrank the tab list: the selection
511        // must clamp into range and every tab must reset to Loading so the
512        // caller's spawn_all repopulates against the new config.
513        let mut app = App::with_theme(
514            tabs_from_config(&config_with_accounts(&["work", "personal"])),
515            Theme::default(),
516        );
517        app.active = 2; // "personal"
518        app.tabs[0] = TabState::Error("old".into());
519
520        app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
521        assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
522        assert_eq!(app.active, 0, "selection clamped after shrink");
523        assert!(matches!(app.tabs[0], TabState::Loading));
524    }
525
526    #[test]
527    fn refresh_from_old_generation_is_discarded() {
528        let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
529        let old_generation = app.tab_generation;
530        app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
531
532        assert!(!app.apply_refresh(
533            old_generation,
534            &TabId::vendor(VendorId::Anthropic),
535            TabState::Error("old result".into()),
536        ));
537        assert!(matches!(app.tabs[0], TabState::Loading));
538    }
539
540    #[test]
541    fn refresh_identity_mismatch_is_discarded() {
542        let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
543        let generation = app.tab_generation;
544
545        assert!(!app.apply_refresh(
546            generation,
547            &TabId::vendor(VendorId::Openai),
548            TabState::Error("wrong tab".into()),
549        ));
550        assert!(matches!(app.tabs[0], TabState::Loading));
551    }
552
553    #[test]
554    fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
555        let anthropic = TabId::vendor(VendorId::Anthropic);
556        let openai = TabId::vendor(VendorId::Openai);
557        let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
558        let generation = app.tab_generation;
559
560        // A reorder is safe because delivery resolves the captured identity,
561        // not a stale positional index.
562        app.tabs_meta.swap(0, 1);
563        app.tabs.swap(0, 1);
564        assert!(app.apply_refresh(generation, &anthropic, TabState::Error("ready".into())));
565        assert!(matches!(app.tabs[0], TabState::Loading));
566        assert!(matches!(&app.tabs[1], TabState::Error(message) if message == "ready"));
567    }
568
569    fn ready_at(fetched_at: chrono::DateTime<Utc>) -> TabState {
570        TabState::Ready(Box::new(ReadyTab {
571            snapshot: crate::usage::VendorSnapshot::Openrouter(crate::usage::OpenRouterSnapshot {
572                label: "test".into(),
573                total_credits: 0.0,
574                total_usage: 0.0,
575                usage_daily: 0.0,
576                usage_weekly: 0.0,
577                usage_monthly: 0.0,
578                is_free_tier: false,
579                limit: None,
580                limit_remaining: None,
581            }),
582            stale: false,
583            last_error: None,
584            fetched_at: Some(fetched_at),
585        }))
586    }
587
588    #[test]
589    fn apply_refresh_stamps_fetched_at_on_only_the_matching_tab() {
590        // Pins the per-tab `fetched_at` the header now reads: a landed Anthropic
591        // response leaves the still-loading OpenAI tab with no time of its own.
592        // Dropping the global `last_refresh` clock is not observable from here
593        // (it was write-only) — that is asserted against the rendered header in
594        // `view::tests::header_refresh_*`.
595        let anthropic = TabId::vendor(VendorId::Anthropic);
596        let openai = TabId::vendor(VendorId::Openai);
597        let mut app = App::with_theme(vec![anthropic.clone(), openai], Theme::default());
598        let generation = app.tab_generation;
599        let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
600
601        assert!(app.apply_refresh(generation, &anthropic, ready_at(fetched_at)));
602        match &app.tabs[0] {
603            TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
604            other => panic!("expected Anthropic tab Ready, got {other:?}"),
605        }
606        assert!(matches!(app.tabs[1], TabState::Loading));
607    }
608
609    #[test]
610    fn select_primary_lands_on_default_account_tab() {
611        // With account tabs present, `primary = anthropic` selects the default
612        // Claude tab (index 0), not one of its account tabs.
613        let app = {
614            let tabs = tabs_from_config(&config_with_accounts(&["work"]));
615            let mut a = App::with_theme(tabs, Theme::default());
616            a.select_primary(Some(VendorId::Anthropic));
617            a
618        };
619        assert_eq!(app.active, 0);
620        assert_eq!(
621            app.active_tab_id(),
622            Some(&TabId::vendor(VendorId::Anthropic))
623        );
624    }
625}