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 tab_generation: u64,
92 pub theme: Theme,
93 pub last_refresh: chrono::DateTime<chrono::Utc>,
94 pub quit: bool,
95 pub settings: Option<crate::tui::settings::SettingsState>,
97}
98
99impl App {
100 pub fn new(tabs_meta: Vec<TabId>) -> Self {
101 Self::with_theme(tabs_meta, Theme::default().merged_with_omarchy())
104 }
105
106 pub fn with_theme(tabs_meta: Vec<TabId>, theme: Theme) -> Self {
112 let n = tabs_meta.len();
113 Self {
114 tabs_meta,
115 active: 0,
116 tabs: vec![TabState::Loading; n],
117 tab_generation: 0,
118 theme,
119 last_refresh: Utc::now(),
120 quit: false,
121 settings: None,
122 }
123 }
124
125 pub fn new_with_primary(tabs_meta: Vec<TabId>, primary: Option<VendorId>) -> Self {
129 let mut app = Self::new(tabs_meta);
130 app.select_primary(primary);
131 app
132 }
133
134 pub fn active_tab_id(&self) -> Option<&TabId> {
135 self.tabs_meta.get(self.active)
136 }
137
138 pub fn active_vendor(&self) -> Option<VendorId> {
139 self.tabs_meta.get(self.active).map(|t| t.vendor)
140 }
141
142 pub fn set_tabs(&mut self, tabs_meta: Vec<TabId>) {
148 self.tab_generation = self.tab_generation.wrapping_add(1);
149 self.active = self.active.min(tabs_meta.len().saturating_sub(1));
150 self.tabs = vec![TabState::Loading; tabs_meta.len()];
151 self.tabs_meta = tabs_meta;
152 }
153
154 pub fn apply_refresh(&mut self, generation: u64, tab: &TabId, state: TabState) -> bool {
159 if generation != self.tab_generation {
160 return false;
161 }
162 let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
163 return false;
164 };
165 self.tabs[index] = state;
166 self.last_refresh = Utc::now();
167 true
168 }
169
170 pub fn select_primary(&mut self, primary: Option<VendorId>) {
173 if let Some(p) = primary
174 && let Some(idx) = self.tabs_meta.iter().position(|t| t.vendor == p)
175 {
176 self.active = idx;
177 }
178 }
179
180 pub fn next_tab(&mut self) {
181 if !self.tabs_meta.is_empty() {
182 self.active = (self.active + 1) % self.tabs_meta.len();
183 }
184 }
185
186 pub fn prev_tab(&mut self) {
187 if !self.tabs_meta.is_empty() {
188 self.active = (self.active + self.tabs_meta.len() - 1) % self.tabs_meta.len();
189 }
190 }
191}
192
193pub async fn refresh_one(client: &Client, config: &Config, tab: &TabId) -> TabState {
195 match build_outcome(client, config, tab).await {
196 Ok(outcome) => {
197 let now = Utc::now();
202 let fetched_at = outcome
203 .cache_age
204 .map(|age| now - chrono::Duration::from_std(age).unwrap_or_default());
205 TabState::Ready(Box::new(ReadyTab {
206 snapshot: outcome.snapshot,
207 stale: outcome.stale,
208 last_error: outcome.last_error,
209 fetched_at,
210 }))
211 }
212 Err(e) => TabState::Error(e.to_string()),
213 }
214}
215
216async fn build_outcome(client: &Client, config: &Config, tab: &TabId) -> Result<VendorOutcome> {
217 match tab.vendor {
218 VendorId::Anthropic => {
219 let (creds_target, cache) = match tab.account.as_deref() {
225 Some(label) => config.anthropic.account_target(label)?,
226 None => {
227 let target = match config.anthropic.credentials_path.clone() {
228 Some(p) => crate::anthropic::creds::CredsTarget::Explicit(p),
229 None => crate::anthropic::creds::CredsTarget::Default(
230 crate::anthropic::creds::default_path().unwrap_or_default(),
231 ),
232 };
233 (target, crate::cache::Cache::for_vendor("anthropic")?)
234 }
235 };
236 let endpoints = crate::anthropic::fetch::Endpoints::default();
237 let outcome = crate::anthropic::fetch_snapshot(
238 client,
239 &creds_target,
240 &cache,
241 &endpoints,
242 DEFAULT_TTL,
243 )
244 .await?;
245 Ok(crate::vendor::VendorOutcome {
246 snapshot: crate::usage::VendorSnapshot::Anthropic(outcome.snapshot),
247 stale: outcome.stale,
248 last_error: outcome.last_error,
249 cache_age: outcome.cache_age,
250 })
251 }
252 VendorId::Openrouter => {
253 let api_key = crate::config::resolve_api_key(
254 "OpenRouter",
255 &config.openrouter.api_key_env,
256 config.openrouter.api_key.as_deref(),
257 )?;
258 let cache = crate::cache::Cache::for_vendor("openrouter")?;
259 let endpoints = crate::openrouter::fetch::Endpoints::default();
260 let outcome = crate::openrouter::fetch_snapshot(
261 client,
262 &api_key,
263 &cache,
264 &endpoints,
265 DEFAULT_TTL,
266 )
267 .await?;
268 Ok(outcome.into())
269 }
270 VendorId::Zai => {
271 let api_key = crate::config::resolve_api_key(
272 "Zai",
273 &config.zai.api_key_env,
274 config.zai.api_key.as_deref(),
275 )?;
276 let cache = crate::cache::Cache::for_vendor("zai")?;
277 let endpoints = crate::zai::fetch::Endpoints::default();
278 let outcome = crate::zai::fetch_snapshot(
279 client,
280 &api_key,
281 &cache,
282 &endpoints,
283 DEFAULT_TTL,
284 config.zai.plan_tier.as_deref(),
285 )
286 .await?;
287 Ok(outcome.into())
288 }
289 VendorId::Openai => {
290 let cache = crate::cache::Cache::for_vendor("openai")?;
291 let creds_path = config
292 .openai
293 .codex_auth_path
294 .clone()
295 .unwrap_or_else(|| crate::openai::creds::default_path().unwrap_or_default());
296 let endpoints = crate::openai::fetch::Endpoints::default();
297 let outcome =
298 crate::openai::fetch_snapshot(client, &creds_path, &cache, &endpoints, DEFAULT_TTL)
299 .await?;
300 Ok(outcome.into())
301 }
302 VendorId::Deepseek => {
303 let api_key = crate::config::resolve_api_key(
304 "DeepSeek",
305 &config.deepseek.api_key_env,
306 config.deepseek.api_key.as_deref(),
307 )?;
308 let cache = crate::cache::Cache::for_vendor("deepseek")?;
309 let endpoints = crate::deepseek::fetch::Endpoints::default();
310 let outcome =
311 crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
312 .await?;
313 Ok(outcome.into())
314 }
315 VendorId::Kimi => {
316 let api_key = crate::config::resolve_api_key(
317 "Kimi",
318 &config.kimi.api_key_env,
319 config.kimi.api_key.as_deref(),
320 )?;
321 let cache = crate::cache::Cache::for_vendor("kimi")?;
322 let endpoints = crate::kimi::fetch::Endpoints::default();
323 let outcome =
324 crate::kimi::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
325 .await?;
326 Ok(outcome.into())
327 }
328 }
329}
330
331pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
343 fn select_primary_moves_to_enabled_vendor() {
344 let mut app = App::with_theme(
345 vec![
346 TabId::vendor(VendorId::Anthropic),
347 TabId::vendor(VendorId::Openrouter),
348 ],
349 Theme::default(),
350 );
351 app.select_primary(Some(VendorId::Openrouter));
352 assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
353 }
354
355 #[test]
356 fn select_primary_ignores_disabled_vendor() {
357 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
358 app.select_primary(Some(VendorId::Openai));
359 assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
360 }
361
362 fn config_with_accounts(labels: &[&str]) -> Config {
363 let mut config = Config::default();
364 config.openai.enabled = false;
367 config.zai.enabled = false;
368 config.openrouter.enabled = false;
369 config.anthropic.accounts = labels
370 .iter()
371 .map(|l| crate::config::AnthropicAccount {
372 label: (*l).to_string(),
373 credentials_path: format!("/creds/{l}.json").into(),
374 })
375 .collect();
376 config
377 }
378
379 #[test]
380 fn tabs_expand_anthropic_accounts_after_default() {
381 let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
383 assert_eq!(
384 tabs,
385 vec![
386 TabId::vendor(VendorId::Anthropic),
387 TabId::account("work"),
388 TabId::account("personal"),
389 ]
390 );
391 }
392
393 #[test]
394 fn tabs_without_accounts_are_just_enabled_vendors() {
395 let config = Config::default();
397 let tabs = tabs_from_config(&config);
398 let vendors: Vec<VendorId> = tabs.iter().map(|t| t.vendor).collect();
399 assert_eq!(vendors, config.enabled_vendors());
400 assert!(tabs.iter().all(|t| t.account.is_none()));
401 }
402
403 #[test]
404 fn set_tabs_resets_states_and_clamps_selection() {
405 let mut app = App::with_theme(
409 tabs_from_config(&config_with_accounts(&["work", "personal"])),
410 Theme::default(),
411 );
412 app.active = 2; app.tabs[0] = TabState::Error("old".into());
414
415 app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
416 assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
417 assert_eq!(app.active, 0, "selection clamped after shrink");
418 assert!(matches!(app.tabs[0], TabState::Loading));
419 }
420
421 #[test]
422 fn refresh_from_old_generation_is_discarded() {
423 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
424 let old_generation = app.tab_generation;
425 app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
426
427 assert!(!app.apply_refresh(
428 old_generation,
429 &TabId::vendor(VendorId::Anthropic),
430 TabState::Error("old result".into()),
431 ));
432 assert!(matches!(app.tabs[0], TabState::Loading));
433 }
434
435 #[test]
436 fn refresh_identity_mismatch_is_discarded() {
437 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
438 let generation = app.tab_generation;
439
440 assert!(!app.apply_refresh(
441 generation,
442 &TabId::vendor(VendorId::Openai),
443 TabState::Error("wrong tab".into()),
444 ));
445 assert!(matches!(app.tabs[0], TabState::Loading));
446 }
447
448 #[test]
449 fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
450 let anthropic = TabId::vendor(VendorId::Anthropic);
451 let openai = TabId::vendor(VendorId::Openai);
452 let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
453 let generation = app.tab_generation;
454
455 app.tabs_meta.swap(0, 1);
458 app.tabs.swap(0, 1);
459 assert!(app.apply_refresh(generation, &anthropic, TabState::Error("ready".into())));
460 assert!(matches!(app.tabs[0], TabState::Loading));
461 assert!(matches!(&app.tabs[1], TabState::Error(message) if message == "ready"));
462 }
463
464 #[test]
465 fn select_primary_lands_on_default_account_tab() {
466 let app = {
469 let tabs = tabs_from_config(&config_with_accounts(&["work"]));
470 let mut a = App::with_theme(tabs, Theme::default());
471 a.select_primary(Some(VendorId::Anthropic));
472 a
473 };
474 assert_eq!(app.active, 0);
475 assert_eq!(
476 app.active_tab_id(),
477 Some(&TabId::vendor(VendorId::Anthropic))
478 );
479 }
480}