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