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 last_refresh: chrono::DateTime<chrono::Utc>,
94    pub quit: bool,
95    /// When `Some`, the Settings overlay is open and consuming key events.
96    pub settings: Option<crate::tui::settings::SettingsState>,
97}
98
99impl App {
100    pub fn new(tabs_meta: Vec<TabId>) -> Self {
101        // Production: resolve the palette from the environment (Omarchy theme
102        // if present, else One Dark).
103        Self::with_theme(tabs_meta, Theme::default().merged_with_omarchy())
104    }
105
106    /// Like [`App::new`] but with an explicit theme. Lets tests build an `App`
107    /// without reading the real Omarchy theme file
108    /// (`$HOME/.config/omarchy/current/theme/colors.toml`) — `new` resolves
109    /// that path and the `$HOME` env var via `merged_with_omarchy`, which is
110    /// not hermetic. Production code uses `new`/`new_with_primary`.
111    pub fn with_theme(tabs_meta: Vec<TabId>, theme: Theme) -> Self {
112        let n = tabs_meta.len();
113        Self {
114            tabs_meta,
115            active: 0,
116            tabs: vec![TabState::Loading; n],
117            tab_generation: 0,
118            theme,
119            last_refresh: Utc::now(),
120            quit: false,
121            settings: None,
122        }
123    }
124
125    /// Construct with an initial active tab — usually `[ui] primary` from
126    /// config. Silently falls through to index 0 if the requested vendor
127    /// isn't present (e.g. it was disabled).
128    pub fn new_with_primary(tabs_meta: Vec<TabId>, primary: Option<VendorId>) -> Self {
129        let mut app = Self::new(tabs_meta);
130        app.select_primary(primary);
131        app
132    }
133
134    pub fn active_tab_id(&self) -> Option<&TabId> {
135        self.tabs_meta.get(self.active)
136    }
137
138    pub fn active_vendor(&self) -> Option<VendorId> {
139        self.tabs_meta.get(self.active).map(|t| t.vendor)
140    }
141
142    /// Replace the tab set — used after a Settings save reloads config, so
143    /// tabs added or removed in `config.toml` while the TUI is open (e.g. a
144    /// new `[[anthropic.accounts]]` entry) appear without a restart. Every
145    /// tab resets to `Loading` (the caller re-spawns fetches) and the
146    /// selection is clamped in case the list shrank.
147    pub fn set_tabs(&mut self, tabs_meta: Vec<TabId>) {
148        self.tab_generation = self.tab_generation.wrapping_add(1);
149        self.active = self.active.min(tabs_meta.len().saturating_sub(1));
150        self.tabs = vec![TabState::Loading; tabs_meta.len()];
151        self.tabs_meta = tabs_meta;
152    }
153
154    /// Apply an asynchronous refresh only when it still belongs to this tab
155    /// generation and the captured tab identity still exists. Lookup by
156    /// identity, rather than the old positional index, also makes a reordered
157    /// tab list safe.
158    pub fn apply_refresh(&mut self, generation: u64, tab: &TabId, state: TabState) -> bool {
159        if generation != self.tab_generation {
160            return false;
161        }
162        let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
163            return false;
164        };
165        self.tabs[index] = state;
166        self.last_refresh = Utc::now();
167        true
168    }
169
170    /// Move to the first tab of `primary`'s vendor (the default account tab,
171    /// since it precedes any of that vendor's account tabs).
172    pub fn select_primary(&mut self, primary: Option<VendorId>) {
173        if let Some(p) = primary
174            && let Some(idx) = self.tabs_meta.iter().position(|t| t.vendor == p)
175        {
176            self.active = idx;
177        }
178    }
179
180    pub fn next_tab(&mut self) {
181        if !self.tabs_meta.is_empty() {
182            self.active = (self.active + 1) % self.tabs_meta.len();
183        }
184    }
185
186    pub fn prev_tab(&mut self) {
187        if !self.tabs_meta.is_empty() {
188            self.active = (self.active + self.tabs_meta.len() - 1) % self.tabs_meta.len();
189        }
190    }
191}
192
193/// Fetch and render one tab — returns a `TabState`.
194pub async fn refresh_one(client: &Client, config: &Config, tab: &TabId) -> TabState {
195    match build_outcome(client, config, tab).await {
196        Ok(outcome) => {
197            // Resolve the cache age (a duration from "now" at fetch time) into an
198            // absolute instant ONCE. Without this, sections_for would recompute
199            // `Utc::now() - cache_age` on every draw and the displayed time would
200            // tick upward in real time instead of holding at the last refresh.
201            let now = Utc::now();
202            let fetched_at = outcome
203                .cache_age
204                .map(|age| now - chrono::Duration::from_std(age).unwrap_or_default());
205            TabState::Ready(Box::new(ReadyTab {
206                snapshot: outcome.snapshot,
207                stale: outcome.stale,
208                last_error: outcome.last_error,
209                fetched_at,
210            }))
211        }
212        Err(e) => TabState::Error(e.to_string()),
213    }
214}
215
216async fn build_outcome(client: &Client, config: &Config, tab: &TabId) -> Result<VendorOutcome> {
217    match tab.vendor {
218        VendorId::Anthropic => {
219            // A named account resolves to its own file + `anthropic/<label>`
220            // cache, shared with the widget via `account_target` (#14/#17).
221            // The default tab keeps the pre-existing resolution: config
222            // `credentials_path` is an explicit strict read, and only the
223            // platform default gets the macOS Keychain fallback.
224            let (creds_target, cache) = match tab.account.as_deref() {
225                Some(label) => config.anthropic.account_target(label)?,
226                None => {
227                    let target = match config.anthropic.credentials_path.clone() {
228                        Some(p) => crate::anthropic::creds::CredsTarget::Explicit(p),
229                        None => crate::anthropic::creds::CredsTarget::Default(
230                            crate::anthropic::creds::default_path().unwrap_or_default(),
231                        ),
232                    };
233                    (target, crate::cache::Cache::for_vendor("anthropic")?)
234                }
235            };
236            let endpoints = crate::anthropic::fetch::Endpoints::default();
237            let outcome = crate::anthropic::fetch_snapshot(
238                client,
239                &creds_target,
240                &cache,
241                &endpoints,
242                DEFAULT_TTL,
243            )
244            .await?;
245            Ok(crate::vendor::VendorOutcome {
246                snapshot: crate::usage::VendorSnapshot::Anthropic(outcome.snapshot),
247                stale: outcome.stale,
248                last_error: outcome.last_error,
249                cache_age: outcome.cache_age,
250            })
251        }
252        VendorId::Openrouter => {
253            let api_key = crate::config::resolve_api_key(
254                "OpenRouter",
255                &config.openrouter.api_key_env,
256                config.openrouter.api_key.as_deref(),
257            )?;
258            let cache = crate::cache::Cache::for_vendor("openrouter")?;
259            let endpoints = crate::openrouter::fetch::Endpoints::default();
260            let outcome = crate::openrouter::fetch_snapshot(
261                client,
262                &api_key,
263                &cache,
264                &endpoints,
265                DEFAULT_TTL,
266            )
267            .await?;
268            Ok(outcome.into())
269        }
270        VendorId::Zai => {
271            let api_key = crate::config::resolve_api_key(
272                "Zai",
273                &config.zai.api_key_env,
274                config.zai.api_key.as_deref(),
275            )?;
276            let cache = crate::cache::Cache::for_vendor("zai")?;
277            let endpoints = crate::zai::fetch::Endpoints::default();
278            let outcome = crate::zai::fetch_snapshot(
279                client,
280                &api_key,
281                &cache,
282                &endpoints,
283                DEFAULT_TTL,
284                config.zai.plan_tier.as_deref(),
285            )
286            .await?;
287            Ok(outcome.into())
288        }
289        VendorId::Openai => {
290            let cache = crate::cache::Cache::for_vendor("openai")?;
291            let creds_path = config
292                .openai
293                .codex_auth_path
294                .clone()
295                .unwrap_or_else(|| crate::openai::creds::default_path().unwrap_or_default());
296            let endpoints = crate::openai::fetch::Endpoints::default();
297            let outcome =
298                crate::openai::fetch_snapshot(client, &creds_path, &cache, &endpoints, DEFAULT_TTL)
299                    .await?;
300            Ok(outcome.into())
301        }
302        VendorId::Deepseek => {
303            let api_key = crate::config::resolve_api_key(
304                "DeepSeek",
305                &config.deepseek.api_key_env,
306                config.deepseek.api_key.as_deref(),
307            )?;
308            let cache = crate::cache::Cache::for_vendor("deepseek")?;
309            let endpoints = crate::deepseek::fetch::Endpoints::default();
310            let outcome =
311                crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
312                    .await?;
313            Ok(outcome.into())
314        }
315        VendorId::Kimi => {
316            let api_key = crate::config::resolve_api_key(
317                "Kimi",
318                &config.kimi.api_key_env,
319                config.kimi.api_key.as_deref(),
320            )?;
321            let cache = crate::cache::Cache::for_vendor("kimi")?;
322            let endpoints = crate::kimi::fetch::Endpoints::default();
323            let outcome =
324                crate::kimi::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
325                    .await?;
326            Ok(outcome.into())
327        }
328    }
329}
330
331/// Convenience for the watch-driven binary: how long to wait between
332/// automatic refreshes.
333pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    // Use `App::with_theme(.., Theme::default())` rather than `App::new`, which
340    // would read the real Omarchy theme file + `$HOME`. The tab-selection logic
341    // under test is theme-agnostic.
342    #[test]
343    fn select_primary_moves_to_enabled_vendor() {
344        let mut app = App::with_theme(
345            vec![
346                TabId::vendor(VendorId::Anthropic),
347                TabId::vendor(VendorId::Openrouter),
348            ],
349            Theme::default(),
350        );
351        app.select_primary(Some(VendorId::Openrouter));
352        assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
353    }
354
355    #[test]
356    fn select_primary_ignores_disabled_vendor() {
357        let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
358        app.select_primary(Some(VendorId::Openai));
359        assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
360    }
361
362    fn config_with_accounts(labels: &[&str]) -> Config {
363        let mut config = Config::default();
364        // Keep only Anthropic enabled so the test asserts on account expansion,
365        // not on the full default vendor set.
366        config.openai.enabled = false;
367        config.zai.enabled = false;
368        config.openrouter.enabled = false;
369        config.anthropic.accounts = labels
370            .iter()
371            .map(|l| crate::config::AnthropicAccount {
372                label: (*l).to_string(),
373                credentials_path: format!("/creds/{l}.json").into(),
374            })
375            .collect();
376        config
377    }
378
379    #[test]
380    fn tabs_expand_anthropic_accounts_after_default() {
381        // Default Claude tab first, then each account in config order.
382        let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
383        assert_eq!(
384            tabs,
385            vec![
386                TabId::vendor(VendorId::Anthropic),
387                TabId::account("work"),
388                TabId::account("personal"),
389            ]
390        );
391    }
392
393    #[test]
394    fn tabs_without_accounts_are_just_enabled_vendors() {
395        // No [[anthropic.accounts]] → one tab per enabled vendor, unchanged.
396        let config = Config::default();
397        let tabs = tabs_from_config(&config);
398        let vendors: Vec<VendorId> = tabs.iter().map(|t| t.vendor).collect();
399        assert_eq!(vendors, config.enabled_vendors());
400        assert!(tabs.iter().all(|t| t.account.is_none()));
401    }
402
403    #[test]
404    fn set_tabs_resets_states_and_clamps_selection() {
405        // Simulates a Settings save that shrank the tab list: the selection
406        // must clamp into range and every tab must reset to Loading so the
407        // caller's spawn_all repopulates against the new config.
408        let mut app = App::with_theme(
409            tabs_from_config(&config_with_accounts(&["work", "personal"])),
410            Theme::default(),
411        );
412        app.active = 2; // "personal"
413        app.tabs[0] = TabState::Error("old".into());
414
415        app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
416        assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
417        assert_eq!(app.active, 0, "selection clamped after shrink");
418        assert!(matches!(app.tabs[0], TabState::Loading));
419    }
420
421    #[test]
422    fn refresh_from_old_generation_is_discarded() {
423        let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
424        let old_generation = app.tab_generation;
425        app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
426
427        assert!(!app.apply_refresh(
428            old_generation,
429            &TabId::vendor(VendorId::Anthropic),
430            TabState::Error("old result".into()),
431        ));
432        assert!(matches!(app.tabs[0], TabState::Loading));
433    }
434
435    #[test]
436    fn refresh_identity_mismatch_is_discarded() {
437        let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
438        let generation = app.tab_generation;
439
440        assert!(!app.apply_refresh(
441            generation,
442            &TabId::vendor(VendorId::Openai),
443            TabState::Error("wrong tab".into()),
444        ));
445        assert!(matches!(app.tabs[0], TabState::Loading));
446    }
447
448    #[test]
449    fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
450        let anthropic = TabId::vendor(VendorId::Anthropic);
451        let openai = TabId::vendor(VendorId::Openai);
452        let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
453        let generation = app.tab_generation;
454
455        // A reorder is safe because delivery resolves the captured identity,
456        // not a stale positional index.
457        app.tabs_meta.swap(0, 1);
458        app.tabs.swap(0, 1);
459        assert!(app.apply_refresh(generation, &anthropic, TabState::Error("ready".into())));
460        assert!(matches!(app.tabs[0], TabState::Loading));
461        assert!(matches!(&app.tabs[1], TabState::Error(message) if message == "ready"));
462    }
463
464    #[test]
465    fn select_primary_lands_on_default_account_tab() {
466        // With account tabs present, `primary = anthropic` selects the default
467        // Claude tab (index 0), not one of its account tabs.
468        let app = {
469            let tabs = tabs_from_config(&config_with_accounts(&["work"]));
470            let mut a = App::with_theme(tabs, Theme::default());
471            a.select_primary(Some(VendorId::Anthropic));
472            a
473        };
474        assert_eq!(app.active, 0);
475        assert_eq!(
476            app.active_tab_id(),
477            Some(&TabId::vendor(VendorId::Anthropic))
478        );
479    }
480}