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#[derive(Debug)]
39pub struct App {
40    pub vendors: Vec<VendorId>,
41    pub active: usize,
42    pub tabs: Vec<TabState>,
43    pub theme: Theme,
44    pub last_refresh: chrono::DateTime<chrono::Utc>,
45    pub quit: bool,
46    /// When `Some`, the Settings overlay is open and consuming key events.
47    pub settings: Option<crate::tui::settings::SettingsState>,
48}
49
50impl App {
51    pub fn new(vendors: Vec<VendorId>) -> Self {
52        let n = vendors.len();
53        Self {
54            vendors,
55            active: 0,
56            tabs: vec![TabState::Loading; n],
57            theme: Theme::default().merged_with_omarchy(),
58            last_refresh: Utc::now(),
59            quit: false,
60            settings: None,
61        }
62    }
63
64    /// Construct with an initial active tab — usually `[ui] primary` from
65    /// config. Silently falls through to index 0 if the requested vendor
66    /// isn't in `vendors` (e.g. it was disabled).
67    pub fn new_with_primary(vendors: Vec<VendorId>, primary: Option<VendorId>) -> Self {
68        let mut app = Self::new(vendors);
69        app.select_primary(primary);
70        app
71    }
72
73    pub fn active_vendor(&self) -> Option<VendorId> {
74        self.vendors.get(self.active).copied()
75    }
76
77    pub fn select_primary(&mut self, primary: Option<VendorId>) {
78        if let Some(p) = primary {
79            if let Some(idx) = self.vendors.iter().position(|v| *v == p) {
80                self.active = idx;
81            }
82        }
83    }
84
85    pub fn next_tab(&mut self) {
86        if !self.vendors.is_empty() {
87            self.active = (self.active + 1) % self.vendors.len();
88        }
89    }
90
91    pub fn prev_tab(&mut self) {
92        if !self.vendors.is_empty() {
93            self.active = (self.active + self.vendors.len() - 1) % self.vendors.len();
94        }
95    }
96}
97
98/// Fetch and render one vendor — returns a `TabState`.
99pub async fn refresh_one(client: &Client, config: &Config, vendor: VendorId) -> TabState {
100    match build_outcome(client, config, vendor).await {
101        Ok(outcome) => {
102            // Resolve the cache age (a duration from "now" at fetch time) into an
103            // absolute instant ONCE. Without this, sections_for would recompute
104            // `Utc::now() - cache_age` on every draw and the displayed time would
105            // tick upward in real time instead of holding at the last refresh.
106            let now = Utc::now();
107            let fetched_at = outcome
108                .cache_age
109                .map(|age| now - chrono::Duration::from_std(age).unwrap_or_default());
110            TabState::Ready(Box::new(ReadyTab {
111                snapshot: outcome.snapshot,
112                stale: outcome.stale,
113                last_error: outcome.last_error,
114                fetched_at,
115            }))
116        }
117        Err(e) => TabState::Error(e.to_string()),
118    }
119}
120
121async fn build_outcome(
122    client: &Client,
123    config: &Config,
124    vendor: VendorId,
125) -> Result<VendorOutcome> {
126    match vendor {
127        VendorId::Anthropic => {
128            let cache = crate::cache::Cache::for_vendor("anthropic")?;
129            let creds_path = config
130                .anthropic
131                .credentials_path
132                .clone()
133                .unwrap_or_else(|| crate::anthropic::creds::default_path().unwrap_or_default());
134            let endpoints = crate::anthropic::fetch::Endpoints::default();
135            let outcome = crate::anthropic::fetch_snapshot(
136                client,
137                &creds_path,
138                &cache,
139                &endpoints,
140                DEFAULT_TTL,
141            )
142            .await?;
143            Ok(crate::vendor::VendorOutcome {
144                snapshot: crate::usage::VendorSnapshot::Anthropic(outcome.snapshot),
145                stale: outcome.stale,
146                last_error: outcome.last_error,
147                cache_age: outcome.cache_age,
148            })
149        }
150        VendorId::Openrouter => {
151            let api_key = crate::config::resolve_api_key(
152                "OpenRouter",
153                &config.openrouter.api_key_env,
154                config.openrouter.api_key.as_deref(),
155            )?;
156            let cache = crate::cache::Cache::for_vendor("openrouter")?;
157            let endpoints = crate::openrouter::fetch::Endpoints::default();
158            let outcome = crate::openrouter::fetch_snapshot(
159                client,
160                &api_key,
161                &cache,
162                &endpoints,
163                DEFAULT_TTL,
164            )
165            .await?;
166            Ok(outcome.into())
167        }
168        VendorId::Zai => {
169            let api_key = crate::config::resolve_api_key(
170                "Zai",
171                &config.zai.api_key_env,
172                config.zai.api_key.as_deref(),
173            )?;
174            let cache = crate::cache::Cache::for_vendor("zai")?;
175            let endpoints = crate::zai::fetch::Endpoints::default();
176            let outcome = crate::zai::fetch_snapshot(
177                client,
178                &api_key,
179                &cache,
180                &endpoints,
181                DEFAULT_TTL,
182                config.zai.plan_tier.as_deref(),
183            )
184            .await?;
185            Ok(outcome.into())
186        }
187        VendorId::Openai => {
188            let cache = crate::cache::Cache::for_vendor("openai")?;
189            let creds_path = config
190                .openai
191                .codex_auth_path
192                .clone()
193                .unwrap_or_else(|| crate::openai::creds::default_path().unwrap_or_default());
194            let endpoints = crate::openai::fetch::Endpoints::default();
195            let outcome =
196                crate::openai::fetch_snapshot(client, &creds_path, &cache, &endpoints, DEFAULT_TTL)
197                    .await?;
198            Ok(outcome.into())
199        }
200        VendorId::Deepseek => {
201            let api_key = crate::config::resolve_api_key(
202                "DeepSeek",
203                &config.deepseek.api_key_env,
204                config.deepseek.api_key.as_deref(),
205            )?;
206            let cache = crate::cache::Cache::for_vendor("deepseek")?;
207            let endpoints = crate::deepseek::fetch::Endpoints::default();
208            let outcome =
209                crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
210                    .await?;
211            Ok(outcome.into())
212        }
213    }
214}
215
216/// Convenience for the watch-driven binary: how long to wait between
217/// automatic refreshes.
218pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn select_primary_moves_to_enabled_vendor() {
226        let mut app = App::new(vec![VendorId::Anthropic, VendorId::Openrouter]);
227        app.select_primary(Some(VendorId::Openrouter));
228        assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
229    }
230
231    #[test]
232    fn select_primary_ignores_disabled_vendor() {
233        let mut app = App::new(vec![VendorId::Anthropic]);
234        app.select_primary(Some(VendorId::Openai));
235        assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
236    }
237}