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 if let Some(idx) = self.vendors.iter().position(|v| *v == p) {
91 self.active = idx;
92 }
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_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
227pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
239 fn select_primary_moves_to_enabled_vendor() {
240 let mut app =
241 App::with_theme(vec![VendorId::Anthropic, VendorId::Openrouter], Theme::default());
242 app.select_primary(Some(VendorId::Openrouter));
243 assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
244 }
245
246 #[test]
247 fn select_primary_ignores_disabled_vendor() {
248 let mut app = App::with_theme(vec![VendorId::Anthropic], Theme::default());
249 app.select_primary(Some(VendorId::Openai));
250 assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
251 }
252}