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, Clone, PartialEq, Eq)]
42pub struct TabId {
43 pub vendor: VendorId,
44 pub account: Option<String>,
45}
46
47impl TabId {
48 pub fn vendor(vendor: VendorId) -> Self {
50 Self {
51 vendor,
52 account: None,
53 }
54 }
55
56 pub fn account(label: impl Into<String>) -> Self {
58 Self {
59 vendor: VendorId::Anthropic,
60 account: Some(label.into()),
61 }
62 }
63}
64
65pub 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 pub theme: Theme,
89 pub last_refresh: chrono::DateTime<chrono::Utc>,
90 pub quit: bool,
91 pub settings: Option<crate::tui::settings::SettingsState>,
93}
94
95impl App {
96 pub fn new(tabs_meta: Vec<TabId>) -> Self {
97 Self::with_theme(tabs_meta, Theme::default().merged_with_omarchy())
100 }
101
102 pub fn with_theme(tabs_meta: Vec<TabId>, theme: Theme) -> Self {
108 let n = tabs_meta.len();
109 Self {
110 tabs_meta,
111 active: 0,
112 tabs: vec![TabState::Loading; n],
113 theme,
114 last_refresh: Utc::now(),
115 quit: false,
116 settings: None,
117 }
118 }
119
120 pub fn new_with_primary(tabs_meta: Vec<TabId>, primary: Option<VendorId>) -> Self {
124 let mut app = Self::new(tabs_meta);
125 app.select_primary(primary);
126 app
127 }
128
129 pub fn active_tab_id(&self) -> Option<&TabId> {
130 self.tabs_meta.get(self.active)
131 }
132
133 pub fn active_vendor(&self) -> Option<VendorId> {
134 self.tabs_meta.get(self.active).map(|t| t.vendor)
135 }
136
137 pub fn set_tabs(&mut self, tabs_meta: Vec<TabId>) {
143 self.active = self.active.min(tabs_meta.len().saturating_sub(1));
144 self.tabs = vec![TabState::Loading; tabs_meta.len()];
145 self.tabs_meta = tabs_meta;
146 }
147
148 pub fn select_primary(&mut self, primary: Option<VendorId>) {
151 if let Some(p) = primary
152 && let Some(idx) = self.tabs_meta.iter().position(|t| t.vendor == p)
153 {
154 self.active = idx;
155 }
156 }
157
158 pub fn next_tab(&mut self) {
159 if !self.tabs_meta.is_empty() {
160 self.active = (self.active + 1) % self.tabs_meta.len();
161 }
162 }
163
164 pub fn prev_tab(&mut self) {
165 if !self.tabs_meta.is_empty() {
166 self.active = (self.active + self.tabs_meta.len() - 1) % self.tabs_meta.len();
167 }
168 }
169}
170
171pub async fn refresh_one(client: &Client, config: &Config, tab: &TabId) -> TabState {
173 match build_outcome(client, config, tab).await {
174 Ok(outcome) => {
175 let now = Utc::now();
180 let fetched_at = outcome
181 .cache_age
182 .map(|age| now - chrono::Duration::from_std(age).unwrap_or_default());
183 TabState::Ready(Box::new(ReadyTab {
184 snapshot: outcome.snapshot,
185 stale: outcome.stale,
186 last_error: outcome.last_error,
187 fetched_at,
188 }))
189 }
190 Err(e) => TabState::Error(e.to_string()),
191 }
192}
193
194async fn build_outcome(client: &Client, config: &Config, tab: &TabId) -> Result<VendorOutcome> {
195 match tab.vendor {
196 VendorId::Anthropic => {
197 let (creds_target, cache) = match tab.account.as_deref() {
203 Some(label) => config.anthropic.account_target(label)?,
204 None => {
205 let target = match config.anthropic.credentials_path.clone() {
206 Some(p) => crate::anthropic::creds::CredsTarget::Explicit(p),
207 None => crate::anthropic::creds::CredsTarget::Default(
208 crate::anthropic::creds::default_path().unwrap_or_default(),
209 ),
210 };
211 (target, crate::cache::Cache::for_vendor("anthropic")?)
212 }
213 };
214 let endpoints = crate::anthropic::fetch::Endpoints::default();
215 let outcome = crate::anthropic::fetch_snapshot(
216 client,
217 &creds_target,
218 &cache,
219 &endpoints,
220 DEFAULT_TTL,
221 )
222 .await?;
223 Ok(crate::vendor::VendorOutcome {
224 snapshot: crate::usage::VendorSnapshot::Anthropic(outcome.snapshot),
225 stale: outcome.stale,
226 last_error: outcome.last_error,
227 cache_age: outcome.cache_age,
228 })
229 }
230 VendorId::Openrouter => {
231 let api_key = crate::config::resolve_api_key(
232 "OpenRouter",
233 &config.openrouter.api_key_env,
234 config.openrouter.api_key.as_deref(),
235 )?;
236 let cache = crate::cache::Cache::for_vendor("openrouter")?;
237 let endpoints = crate::openrouter::fetch::Endpoints::default();
238 let outcome = crate::openrouter::fetch_snapshot(
239 client,
240 &api_key,
241 &cache,
242 &endpoints,
243 DEFAULT_TTL,
244 )
245 .await?;
246 Ok(outcome.into())
247 }
248 VendorId::Zai => {
249 let api_key = crate::config::resolve_api_key(
250 "Zai",
251 &config.zai.api_key_env,
252 config.zai.api_key.as_deref(),
253 )?;
254 let cache = crate::cache::Cache::for_vendor("zai")?;
255 let endpoints = crate::zai::fetch::Endpoints::default();
256 let outcome = crate::zai::fetch_snapshot(
257 client,
258 &api_key,
259 &cache,
260 &endpoints,
261 DEFAULT_TTL,
262 config.zai.plan_tier.as_deref(),
263 )
264 .await?;
265 Ok(outcome.into())
266 }
267 VendorId::Openai => {
268 let cache = crate::cache::Cache::for_vendor("openai")?;
269 let creds_path = config
270 .openai
271 .codex_auth_path
272 .clone()
273 .unwrap_or_else(|| crate::openai::creds::default_path().unwrap_or_default());
274 let endpoints = crate::openai::fetch::Endpoints::default();
275 let outcome =
276 crate::openai::fetch_snapshot(client, &creds_path, &cache, &endpoints, DEFAULT_TTL)
277 .await?;
278 Ok(outcome.into())
279 }
280 VendorId::Deepseek => {
281 let api_key = crate::config::resolve_api_key(
282 "DeepSeek",
283 &config.deepseek.api_key_env,
284 config.deepseek.api_key.as_deref(),
285 )?;
286 let cache = crate::cache::Cache::for_vendor("deepseek")?;
287 let endpoints = crate::deepseek::fetch::Endpoints::default();
288 let outcome =
289 crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
290 .await?;
291 Ok(outcome.into())
292 }
293 }
294}
295
296pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
308 fn select_primary_moves_to_enabled_vendor() {
309 let mut app = App::with_theme(
310 vec![
311 TabId::vendor(VendorId::Anthropic),
312 TabId::vendor(VendorId::Openrouter),
313 ],
314 Theme::default(),
315 );
316 app.select_primary(Some(VendorId::Openrouter));
317 assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
318 }
319
320 #[test]
321 fn select_primary_ignores_disabled_vendor() {
322 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
323 app.select_primary(Some(VendorId::Openai));
324 assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
325 }
326
327 fn config_with_accounts(labels: &[&str]) -> Config {
328 let mut config = Config::default();
329 config.openai.enabled = false;
332 config.zai.enabled = false;
333 config.openrouter.enabled = false;
334 config.anthropic.accounts = labels
335 .iter()
336 .map(|l| crate::config::AnthropicAccount {
337 label: (*l).to_string(),
338 credentials_path: format!("/creds/{l}.json").into(),
339 })
340 .collect();
341 config
342 }
343
344 #[test]
345 fn tabs_expand_anthropic_accounts_after_default() {
346 let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
348 assert_eq!(
349 tabs,
350 vec![
351 TabId::vendor(VendorId::Anthropic),
352 TabId::account("work"),
353 TabId::account("personal"),
354 ]
355 );
356 }
357
358 #[test]
359 fn tabs_without_accounts_are_just_enabled_vendors() {
360 let config = Config::default();
362 let tabs = tabs_from_config(&config);
363 let vendors: Vec<VendorId> = tabs.iter().map(|t| t.vendor).collect();
364 assert_eq!(vendors, config.enabled_vendors());
365 assert!(tabs.iter().all(|t| t.account.is_none()));
366 }
367
368 #[test]
369 fn set_tabs_resets_states_and_clamps_selection() {
370 let mut app = App::with_theme(
374 tabs_from_config(&config_with_accounts(&["work", "personal"])),
375 Theme::default(),
376 );
377 app.active = 2; app.tabs[0] = TabState::Error("old".into());
379
380 app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
381 assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
382 assert_eq!(app.active, 0, "selection clamped after shrink");
383 assert!(matches!(app.tabs[0], TabState::Loading));
384 }
385
386 #[test]
387 fn select_primary_lands_on_default_account_tab() {
388 let app = {
391 let tabs = tabs_from_config(&config_with_accounts(&["work"]));
392 let mut a = App::with_theme(tabs, Theme::default());
393 a.select_primary(Some(VendorId::Anthropic));
394 a
395 };
396 assert_eq!(app.active, 0);
397 assert_eq!(
398 app.active_tab_id(),
399 Some(&TabId::vendor(VendorId::Anthropic))
400 );
401 }
402}