1use 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#[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 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 pub settings: Option<crate::tui::settings::SettingsState>,
48}
49
50impl App {
51 pub fn new(vendors: Vec<VendorId>) -> Self {
52 Self::with_theme(vendors, Theme::default().merged_with_omarchy())
55 }
56
57 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 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
109pub async fn refresh_one(client: &Client, config: &Config, vendor: VendorId) -> TabState {
111 match build_outcome(client, config, vendor).await {
112 Ok(outcome) => {
113 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_target = match config.anthropic.credentials_path.clone() {
143 Some(p) => crate::anthropic::creds::CredsTarget::Explicit(p),
144 None => crate::anthropic::creds::CredsTarget::Default(
145 crate::anthropic::creds::default_path().unwrap_or_default(),
146 ),
147 };
148 let endpoints = crate::anthropic::fetch::Endpoints::default();
149 let outcome = crate::anthropic::fetch_snapshot(
150 client,
151 &creds_target,
152 &cache,
153 &endpoints,
154 DEFAULT_TTL,
155 )
156 .await?;
157 Ok(crate::vendor::VendorOutcome {
158 snapshot: crate::usage::VendorSnapshot::Anthropic(outcome.snapshot),
159 stale: outcome.stale,
160 last_error: outcome.last_error,
161 cache_age: outcome.cache_age,
162 })
163 }
164 VendorId::Openrouter => {
165 let api_key = crate::config::resolve_api_key(
166 "OpenRouter",
167 &config.openrouter.api_key_env,
168 config.openrouter.api_key.as_deref(),
169 )?;
170 let cache = crate::cache::Cache::for_vendor("openrouter")?;
171 let endpoints = crate::openrouter::fetch::Endpoints::default();
172 let outcome = crate::openrouter::fetch_snapshot(
173 client,
174 &api_key,
175 &cache,
176 &endpoints,
177 DEFAULT_TTL,
178 )
179 .await?;
180 Ok(outcome.into())
181 }
182 VendorId::Zai => {
183 let api_key = crate::config::resolve_api_key(
184 "Zai",
185 &config.zai.api_key_env,
186 config.zai.api_key.as_deref(),
187 )?;
188 let cache = crate::cache::Cache::for_vendor("zai")?;
189 let endpoints = crate::zai::fetch::Endpoints::default();
190 let outcome = crate::zai::fetch_snapshot(
191 client,
192 &api_key,
193 &cache,
194 &endpoints,
195 DEFAULT_TTL,
196 config.zai.plan_tier.as_deref(),
197 )
198 .await?;
199 Ok(outcome.into())
200 }
201 VendorId::Openai => {
202 let cache = crate::cache::Cache::for_vendor("openai")?;
203 let creds_path = config
204 .openai
205 .codex_auth_path
206 .clone()
207 .unwrap_or_else(|| crate::openai::creds::default_path().unwrap_or_default());
208 let endpoints = crate::openai::fetch::Endpoints::default();
209 let outcome =
210 crate::openai::fetch_snapshot(client, &creds_path, &cache, &endpoints, DEFAULT_TTL)
211 .await?;
212 Ok(outcome.into())
213 }
214 VendorId::Deepseek => {
215 let api_key = crate::config::resolve_api_key(
216 "DeepSeek",
217 &config.deepseek.api_key_env,
218 config.deepseek.api_key.as_deref(),
219 )?;
220 let cache = crate::cache::Cache::for_vendor("deepseek")?;
221 let endpoints = crate::deepseek::fetch::Endpoints::default();
222 let outcome =
223 crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
224 .await?;
225 Ok(outcome.into())
226 }
227 }
228}
229
230pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
242 fn select_primary_moves_to_enabled_vendor() {
243 let mut app = App::with_theme(
244 vec![VendorId::Anthropic, VendorId::Openrouter],
245 Theme::default(),
246 );
247 app.select_primary(Some(VendorId::Openrouter));
248 assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
249 }
250
251 #[test]
252 fn select_primary_ignores_disabled_vendor() {
253 let mut app = App::with_theme(vec![VendorId::Anthropic], Theme::default());
254 app.select_primary(Some(VendorId::Openai));
255 assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
256 }
257}