Skip to main content

oxi/
footer_data.rs

1//! Footer data provider for TUI status display
2//!
3//! Provides utilities for gathering and formatting footer data
4//! such as model info, token usage, and keybinding hints.
5
6use std::collections::HashMap;
7use std::path::PathBuf;
8use std::sync::atomic::{AtomicU32, Ordering};
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12use crate::git_utils;
13
14/// Footer data containing all status information for the TUI footer display
15#[derive(Debug, Clone)]
16pub struct FooterData {
17    /// Current model name (e.g., "claude-sonnet-4-20250501")
18    pub model_name: String,
19    /// Provider name (e.g., "anthropic", "openai")
20    pub provider_name: String,
21    /// Thinking level setting (e.g., "off", "low", "medium", "high")
22    pub thinking_level: String,
23    /// Current session name (if set)
24    pub session_name: Option<String>,
25    /// Current git branch (from git_utils)
26    pub git_branch: Option<String>,
27    /// Current working directory (abbreviated)
28    pub pwd: Option<String>,
29    /// Input tokens used in current session
30    pub input_tokens: Arc<AtomicU32>,
31    /// Output tokens used in current session
32    pub output_tokens: Arc<AtomicU32>,
33    /// Cache read tokens (from model caching)
34    pub cache_read_tokens: Arc<AtomicU32>,
35    /// Cache write tokens (from model caching)
36    pub cache_write_tokens: Arc<AtomicU32>,
37    /// Context window usage percentage (0.0 - 100.0)
38    pub context_window_pct: f32,
39    /// Total estimated cost in USD
40    pub total_cost: f64,
41    /// Session duration in seconds
42    pub session_duration_secs: u64,
43    /// Extension status messages (key -> text)
44    pub extension_statuses: HashMap<String, String>,
45}
46
47impl Default for FooterData {
48    fn default() -> Self {
49        Self {
50            model_name: String::new(),
51            provider_name: String::new(),
52            thinking_level: "off".to_string(),
53            session_name: None,
54            git_branch: None,
55            pwd: None,
56            input_tokens: Arc::new(AtomicU32::new(0)),
57            output_tokens: Arc::new(AtomicU32::new(0)),
58            cache_read_tokens: Arc::new(AtomicU32::new(0)),
59            cache_write_tokens: Arc::new(AtomicU32::new(0)),
60            context_window_pct: 0.0,
61            total_cost: 0.0,
62            session_duration_secs: 0,
63            extension_statuses: HashMap::new(),
64        }
65    }
66}
67
68impl FooterData {
69    /// Create a new empty FooterData
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Create with model information
75    pub fn with_model(mut self, model_name: &str, provider_name: &str) -> Self {
76        self.model_name = model_name.to_string();
77        self.provider_name = provider_name.to_string();
78        self
79    }
80
81    /// Set the thinking level
82    pub fn with_thinking_level(mut self, level: &str) -> Self {
83        self.thinking_level = level.to_string();
84        self
85    }
86
87    /// Set the session name
88    pub fn with_session_name(mut self, name: Option<String>) -> Self {
89        self.session_name = name;
90        self
91    }
92
93    /// Set git branch from git_utils
94    pub fn with_git_branch(mut self, cwd: &PathBuf) -> Self {
95        // Note: get_current_branch already returns Option<String>, no .ok() needed
96        self.git_branch = git_utils::get_current_branch(cwd);
97        self
98    }
99
100    /// Set working directory (abbreviated)
101    pub fn with_pwd(mut self, pwd: Option<String>) -> Self {
102        self.pwd = pwd;
103        self
104    }
105
106    /// Get current input tokens
107    pub fn get_input_tokens(&self) -> u32 {
108        self.input_tokens.load(Ordering::Relaxed)
109    }
110
111    /// Get current output tokens
112    pub fn get_output_tokens(&self) -> u32 {
113        self.output_tokens.load(Ordering::Relaxed)
114    }
115
116    /// Get cache read tokens
117    pub fn get_cache_read_tokens(&self) -> u32 {
118        self.cache_read_tokens.load(Ordering::Relaxed)
119    }
120
121    /// Get cache write tokens
122    pub fn get_cache_write_tokens(&self) -> u32 {
123        self.cache_write_tokens.load(Ordering::Relaxed)
124    }
125
126    /// Update token counts during streaming
127    pub fn update_tokens(&self, input: u32, output: u32) {
128        self.input_tokens.store(input, Ordering::Relaxed);
129        self.output_tokens.store(output, Ordering::Relaxed);
130    }
131
132    /// Update cache tokens during streaming
133    pub fn update_cache_tokens(&self, read: u32, write: u32) {
134        self.cache_read_tokens.store(read, Ordering::Relaxed);
135        self.cache_write_tokens.store(write, Ordering::Relaxed);
136    }
137
138    /// Update all token counts at once
139    pub fn update_all_tokens(&self, input: u32, output: u32, cache_read: u32, cache_write: u32) {
140        self.update_tokens(input, output);
141        self.update_cache_tokens(cache_read, cache_write);
142    }
143
144    /// Set context window percentage
145    pub fn set_context_window_pct(&mut self, pct: f32) {
146        self.context_window_pct = pct.clamp(0.0, 100.0);
147    }
148
149    /// Set total cost
150    pub fn set_total_cost(&mut self, cost: f64) {
151        self.total_cost = cost;
152    }
153
154    /// Set session duration in seconds
155    pub fn set_session_duration(&mut self, secs: u64) {
156        self.session_duration_secs = secs;
157    }
158
159    /// Add an extension status message
160    pub fn set_extension_status(&mut self, key: &str, value: Option<&str>) {
161        if let Some(v) = value {
162            self.extension_statuses.insert(key.to_string(), v.to_string());
163        } else {
164            self.extension_statuses.remove(key);
165        }
166    }
167
168    /// Clear all extension statuses
169    pub fn clear_extension_statuses(&mut self) {
170        self.extension_statuses.clear();
171    }
172
173    /// Format tokens for display (e.g., "1.2k", "500")
174    pub fn format_tokens(&self) -> String {
175        let input = self.get_input_tokens();
176        let output = self.get_output_tokens();
177        let cache_read = self.get_cache_read_tokens();
178        let cache_write = self.get_cache_write_tokens();
179
180        let mut parts = Vec::new();
181        if input > 0 {
182            parts.push(format!("↑{}", Self::format_token_count(input)));
183        }
184        if output > 0 {
185            parts.push(format!("↓{}", Self::format_token_count(output)));
186        }
187        if cache_read > 0 {
188            parts.push(format!("R{}", Self::format_token_count(cache_read)));
189        }
190        if cache_write > 0 {
191            parts.push(format!("W{}", Self::format_token_count(cache_write)));
192        }
193        parts.join(" ")
194    }
195
196    /// Format a token count for display
197    fn format_token_count(count: u32) -> String {
198        if count < 1000 {
199            count.to_string()
200        } else if count < 10000 {
201            format!("{:.1}k", count as f32 / 1000.0)
202        } else if count < 1_000_000 {
203            format!("{}k", count / 1000)
204        } else {
205            format!("{:.1}M", count as f32 / 1_000_000.0)
206        }
207    }
208
209    /// Format context window percentage for display
210    pub fn format_context_window(&self) -> String {
211        if self.context_window_pct > 0.0 {
212            format!("{:.1}%", self.context_window_pct)
213        } else {
214            String::from("0%")
215        }
216    }
217
218    /// Check if any data is present (excluding defaults)
219    pub fn has_data(&self) -> bool {
220        self.model_name.is_empty() == false
221            || self.get_input_tokens() > 0
222            || self.get_output_tokens() > 0
223            || self.total_cost > 0.0
224    }
225
226    /// Get total tokens
227    pub fn total_tokens(&self) -> u32 {
228        self.get_input_tokens() + self.get_output_tokens()
229    }
230
231    /// Render the footer as display strings for the TUI
232    /// Returns up to 2 lines: [pwd_line, stats_line]
233    pub fn render_lines(&self, width: usize) -> Vec<String> {
234        let mut lines = Vec::new();
235
236        // Build pwd line
237        let mut pwd_parts = Vec::new();
238        if let Some(ref pwd) = self.pwd {
239            pwd_parts.push(pwd.clone());
240        }
241        if let Some(ref branch) = self.git_branch {
242            pwd_parts.push(format!("({})", branch));
243        }
244        if let Some(ref session) = self.session_name {
245            pwd_parts.push(format!("• {}", session));
246        }
247        let pwd_line = if pwd_parts.is_empty() {
248            String::new()
249        } else {
250            pwd_parts.join(" ")
251        };
252        lines.push(pwd_line);
253
254        // Build stats line
255        let mut stats_parts = Vec::new();
256
257        // Token stats
258        let token_str = self.format_tokens();
259        if !token_str.is_empty() {
260            stats_parts.push(token_str);
261        }
262
263        // Cost
264        if self.total_cost > 0.0 {
265            stats_parts.push(format!("${:.3}", self.total_cost));
266        }
267
268        // Context window
269        if self.context_window_pct > 0.0 {
270            stats_parts.push(format!("ctx:{}", self.format_context_window()));
271        }
272
273        // Model info on the right
274        let mut right_parts = Vec::new();
275        if !self.model_name.is_empty() {
276            if self.provider_name.is_empty() {
277                right_parts.push(self.model_name.clone());
278            } else {
279                right_parts.push(format!("({}) {}", self.provider_name, self.model_name));
280            }
281        }
282
283        // Thinking level indicator
284        if self.thinking_level != "off" && !self.model_name.is_empty() {
285            right_parts.push(format!("thinking:{}", self.thinking_level));
286        }
287
288        // Combine stats and model info
289        let stats_line = if stats_parts.is_empty() && right_parts.is_empty() {
290            String::new()
291        } else if stats_parts.is_empty() {
292            right_parts.join(" ")
293        } else if right_parts.is_empty() {
294            stats_parts.join(" ")
295        } else {
296            format!("{}  {}", stats_parts.join(" "), right_parts.join(" "))
297        };
298        lines.push(stats_line);
299
300        // Extension statuses (if any) on additional lines
301        for (key, value) in &self.extension_statuses {
302            lines.push(format!("[{}] {}", key, value));
303        }
304
305        lines
306    }
307}
308
309/// A keybinding hint for display
310#[derive(Debug, Clone)]
311pub struct KeybindingHint {
312    /// The key sequence
313    pub keys: String,
314    /// Description of the action
315    pub description: String,
316}
317
318impl KeybindingHint {
319    pub fn new(keys: &str, description: &str) -> Self {
320        Self {
321            keys: keys.to_string(),
322            description: description.to_string(),
323        }
324    }
325}
326
327/// Session timer for tracking duration
328pub struct SessionTimer {
329    start: Instant,
330}
331
332impl SessionTimer {
333    /// Create a new timer starting now
334    pub fn new() -> Self {
335        Self {
336            start: Instant::now(),
337        }
338    }
339
340    /// Get the elapsed duration
341    pub fn elapsed(&self) -> Duration {
342        self.start.elapsed()
343    }
344
345    /// Reset the timer
346    pub fn reset(&mut self) {
347        self.start = Instant::now();
348    }
349}
350
351impl Default for SessionTimer {
352    fn default() -> Self {
353        Self::new()
354    }
355}
356
357/// Format a duration in a human-readable format
358pub fn format_duration(duration: Duration) -> String {
359    let total_secs = duration.as_secs();
360
361    if total_secs < 60 {
362        return format!("{}s", total_secs);
363    }
364
365    let minutes = total_secs / 60;
366    if minutes < 60 {
367        let seconds = total_secs % 60;
368        return format!("{}m {}s", minutes, seconds);
369    }
370
371    let hours = minutes / 60;
372    let mins = minutes % 60;
373    if hours < 24 {
374        return format!("{}h {}m", hours, mins);
375    }
376
377    let days = hours / 24;
378    let hrs = hours % 24;
379    format!("{}d {}h", days, hrs)
380}
381
382/// Cost estimation for different models
383/// Note: These are rough estimates based on public pricing
384pub struct CostEstimator {
385    /// Price per 1M input tokens
386    input_price_per_m: HashMap<String, f64>,
387    /// Price per 1M output tokens
388    output_price_per_m: HashMap<String, f64>,
389}
390
391impl CostEstimator {
392    /// Create a new cost estimator with default prices
393    pub fn new() -> Self {
394        let mut input_price_per_m = HashMap::new();
395        let mut output_price_per_m = HashMap::new();
396
397        // Anthropic models (approximate)
398        input_price_per_m.insert("claude".to_string(), 3.0);
399        output_price_per_m.insert("claude".to_string(), 15.0);
400
401        // OpenAI models
402        input_price_per_m.insert("gpt-4".to_string(), 30.0);
403        output_price_per_m.insert("gpt-4".to_string(), 60.0);
404        input_price_per_m.insert("gpt-3.5".to_string(), 0.5);
405        output_price_per_m.insert("gpt-3.5".to_string(), 1.5);
406
407        // Google models
408        input_price_per_m.insert("gemini".to_string(), 0.125);
409        output_price_per_m.insert("gemini".to_string(), 0.5);
410
411        Self {
412            input_price_per_m,
413            output_price_per_m,
414        }
415    }
416
417    /// Estimate cost for a given model and token usage
418    pub fn estimate(&self, model: &str, input_tokens: u32, output_tokens: u32) -> Option<f64> {
419        // Find the matching price tier
420        let model_lower = model.to_lowercase();
421
422        let input_price = self
423            .input_price_per_m
424            .iter()
425            .find(|(name, _)| model_lower.contains(&name.to_lowercase()))
426            .map(|(_, price)| *price);
427
428        let output_price = self
429            .output_price_per_m
430            .iter()
431            .find(|(name, _)| model_lower.contains(&name.to_lowercase()))
432            .map(|(_, price)| *price);
433
434        match (input_price, output_price) {
435            (Some(inp), Some(outp)) => {
436                let input_cost = (input_tokens as f64 / 1_000_000.0) * inp;
437                let output_cost = (output_tokens as f64 / 1_000_000.0) * outp;
438                Some(input_cost + output_cost)
439            }
440            _ => None,
441        }
442    }
443}
444
445impl Default for CostEstimator {
446    fn default() -> Self {
447        Self::new()
448    }
449}
450
451/// Footer data provider trait
452pub trait FooterDataProvider: Send + Sync {
453    /// Get the current footer data
454    fn get_footer_data(&self) -> FooterData;
455
456    /// Get the model name
457    fn get_model_name(&self) -> Option<String>;
458
459    /// Get git branch
460    fn get_git_branch(&self) -> Option<String>;
461
462    /// Get token counts
463    fn get_token_counts(&self) -> (u32, u32);
464
465    /// Get session duration
466    fn get_session_duration(&self) -> Duration;
467
468    /// Get keybinding hints
469    fn get_keybinding_hints(&self) -> Vec<KeybindingHint>;
470}
471
472/// Simple footer data provider implementation
473pub struct SimpleFooterDataProvider {
474    model_name: Option<String>,
475    provider_name: Option<String>,
476    git_branch: Option<String>,
477    pwd: Option<String>,
478    input_tokens: Arc<AtomicU32>,
479    output_tokens: u32,
480    cache_read_tokens: u32,
481    cache_write_tokens: u32,
482    session_timer: SessionTimer,
483    keybinding_hints: Vec<KeybindingHint>,
484    extension_statuses: HashMap<String, String>,
485    available_providers: usize,
486    thinking_level: String,
487    session_name: Option<String>,
488}
489
490impl SimpleFooterDataProvider {
491    /// Create a new provider
492    pub fn new() -> Self {
493        Self {
494            model_name: None,
495            provider_name: None,
496            git_branch: None,
497            pwd: None,
498            input_tokens: Arc::new(AtomicU32::new(0)),
499            output_tokens: 0,
500            cache_read_tokens: 0,
501            cache_write_tokens: 0,
502            session_timer: SessionTimer::new(),
503            keybinding_hints: Vec::new(),
504            extension_statuses: HashMap::new(),
505            available_providers: 0,
506            thinking_level: "off".to_string(),
507            session_name: None,
508        }
509    }
510
511    /// Set the model name
512    pub fn with_model(mut self, model: Option<String>, provider: Option<String>) -> Self {
513        self.model_name = model;
514        self.provider_name = provider;
515        self
516    }
517
518    /// Set the git branch
519    pub fn with_git_branch(mut self, branch: Option<String>) -> Self {
520        self.git_branch = branch;
521        self
522    }
523
524    /// Set working directory
525    pub fn with_pwd(mut self, pwd: Option<String>) -> Self {
526        self.pwd = pwd;
527        self
528    }
529
530    /// Set token counts
531    pub fn with_tokens(mut self, input: u32, output: u32) -> Self {
532        self.input_tokens = Arc::new(AtomicU32::new(input));
533        self.output_tokens = output;
534        self
535    }
536
537    /// Add a keybinding hint
538    pub fn add_hint(mut self, keys: &str, description: &str) -> Self {
539        self.keybinding_hints.push(KeybindingHint::new(keys, description));
540        self
541    }
542
543    /// Set the available provider count
544    pub fn with_providers(mut self, count: usize) -> Self {
545        self.available_providers = count;
546        self
547    }
548
549    /// Update token counts
550    pub fn update_tokens(&mut self, input: u32, output: u32) {
551        self.input_tokens = Arc::new(AtomicU32::new(input));
552        self.output_tokens = output;
553    }
554
555    /// Update cache tokens
556    pub fn update_cache_tokens(&mut self, read: u32, write: u32) {
557        self.cache_read_tokens = read;
558        self.cache_write_tokens = write;
559    }
560
561    /// Add an extension status
562    pub fn set_extension_status(&mut self, key: &str, status: Option<&str>) {
563        if let Some(s) = status {
564            self.extension_statuses.insert(key.to_string(), s.to_string());
565        } else {
566            self.extension_statuses.remove(key);
567        }
568    }
569
570    /// Set thinking level
571    pub fn set_thinking_level(&mut self, level: &str) {
572        self.thinking_level = level.to_string();
573    }
574
575    /// Set session name
576    pub fn set_session_name(&mut self, name: Option<String>) {
577        self.session_name = name;
578    }
579
580    /// Set provider count
581    pub fn set_available_providers(&mut self, count: usize) {
582        self.available_providers = count;
583    }
584}
585
586impl Default for SimpleFooterDataProvider {
587    fn default() -> Self {
588        Self::new()
589    }
590}
591
592impl FooterDataProvider for SimpleFooterDataProvider {
593    fn get_footer_data(&self) -> FooterData {
594        let mut data = FooterData {
595            model_name: self.model_name.clone().unwrap_or_default(),
596            provider_name: self.provider_name.clone().unwrap_or_default(),
597            thinking_level: self.thinking_level.clone(),
598            session_name: self.session_name.clone(),
599            git_branch: self.git_branch.clone(),
600            pwd: self.pwd.clone(),
601            input_tokens: Arc::clone(&self.input_tokens),
602            output_tokens: Arc::new(AtomicU32::new(self.output_tokens)),
603            cache_read_tokens: Arc::new(AtomicU32::new(self.cache_read_tokens)),
604            cache_write_tokens: Arc::new(AtomicU32::new(self.cache_write_tokens)),
605            context_window_pct: 0.0,
606            total_cost: 0.0,
607            session_duration_secs: self.session_timer.elapsed().as_secs(),
608            extension_statuses: self.extension_statuses.clone(),
609        };
610
611        // Calculate cost if model is available
612        if let Some(ref model) = self.model_name {
613            let cost_estimator = CostEstimator::new();
614            if let Some(cost) = cost_estimator.estimate(
615                model,
616                self.input_tokens.load(Ordering::Relaxed),
617                self.output_tokens,
618            ) {
619                data.total_cost = cost;
620            }
621        }
622
623        data
624    }
625
626    fn get_model_name(&self) -> Option<String> {
627        self.model_name.clone()
628    }
629
630    fn get_git_branch(&self) -> Option<String> {
631        self.git_branch.clone()
632    }
633
634    fn get_token_counts(&self) -> (u32, u32) {
635        (self.input_tokens.load(Ordering::Relaxed), self.output_tokens)
636    }
637
638    fn get_session_duration(&self) -> Duration {
639        self.session_timer.elapsed()
640    }
641
642    fn get_keybinding_hints(&self) -> Vec<KeybindingHint> {
643        self.keybinding_hints.clone()
644    }
645}
646
647/// Extension status helper
648pub struct ExtensionStatusTracker {
649    statuses: HashMap<String, String>,
650}
651
652impl ExtensionStatusTracker {
653    pub fn new() -> Self {
654        Self {
655            statuses: HashMap::new(),
656        }
657    }
658
659    pub fn set(&mut self, extension: &str, status: &str) {
660        self.statuses.insert(extension.to_string(), status.to_string());
661    }
662
663    pub fn clear(&mut self, extension: &str) {
664        self.statuses.remove(extension);
665    }
666
667    pub fn get_all(&self) -> &HashMap<String, String> {
668        &self.statuses
669    }
670}
671
672impl Default for ExtensionStatusTracker {
673    fn default() -> Self {
674        Self::new()
675    }
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681
682    #[test]
683    fn test_footer_data_new() {
684        let data = FooterData::new();
685        assert!(data.model_name.is_empty());
686        assert!(data.provider_name.is_empty());
687        assert_eq!(data.get_input_tokens(), 0);
688        assert_eq!(data.get_output_tokens(), 0);
689    }
690
691    #[test]
692    fn test_footer_data_with_model() {
693        let data = FooterData::new().with_model("claude-3.5-sonnet", "anthropic");
694        assert_eq!(data.model_name, "claude-3.5-sonnet");
695        assert_eq!(data.provider_name, "anthropic");
696    }
697
698    #[test]
699    fn test_footer_data_with_thinking_level() {
700        let data = FooterData::new().with_thinking_level("medium");
701        assert_eq!(data.thinking_level, "medium");
702    }
703
704    #[test]
705    fn test_footer_data_with_session_name() {
706        let data = FooterData::new().with_session_name(Some("my-session".to_string()));
707        assert_eq!(data.session_name, Some("my-session".to_string()));
708    }
709
710    #[test]
711    fn test_footer_data_with_pwd() {
712        let data = FooterData::new().with_pwd(Some("~/projects/oxi".to_string()));
713        assert_eq!(data.pwd, Some("~/projects/oxi".to_string()));
714    }
715
716    #[test]
717    fn test_footer_data_update_tokens() {
718        let data = FooterData::new();
719        data.update_tokens(1000, 500);
720        assert_eq!(data.get_input_tokens(), 1000);
721        assert_eq!(data.get_output_tokens(), 500);
722    }
723
724    #[test]
725    fn test_footer_data_update_cache_tokens() {
726        let data = FooterData::new();
727        data.update_cache_tokens(2000, 100);
728        assert_eq!(data.get_cache_read_tokens(), 2000);
729        assert_eq!(data.get_cache_write_tokens(), 100);
730    }
731
732    #[test]
733    fn test_footer_data_update_all_tokens() {
734        let data = FooterData::new();
735        data.update_all_tokens(1000, 500, 2000, 100);
736        assert_eq!(data.get_input_tokens(), 1000);
737        assert_eq!(data.get_output_tokens(), 500);
738        assert_eq!(data.get_cache_read_tokens(), 2000);
739        assert_eq!(data.get_cache_write_tokens(), 100);
740    }
741
742    #[test]
743    fn test_footer_data_total_tokens() {
744        let data = FooterData::new();
745        data.update_tokens(100, 50);
746        assert_eq!(data.total_tokens(), 150);
747    }
748
749    #[test]
750    fn test_footer_data_format_tokens() {
751        let data = FooterData::new();
752        data.update_tokens(1500, 2500);
753        let formatted = data.format_tokens();
754        assert!(formatted.contains("↑1.5k") || formatted.contains("↑1500"));
755        assert!(formatted.contains("↓2.5k") || formatted.contains("↓2500"));
756    }
757
758    #[test]
759    fn test_footer_data_format_context_window() {
760        let mut data = FooterData::new();
761        data.set_context_window_pct(75.5);
762        assert_eq!(data.format_context_window(), "75.5%");
763    }
764
765    #[test]
766    fn test_footer_data_has_data() {
767        let mut data = FooterData::new();
768        assert!(!data.has_data());
769        data.model_name = "gpt-4".to_string();
770        assert!(data.has_data());
771    }
772
773    #[test]
774    fn test_footer_data_render_lines() {
775        let mut data = FooterData::new();
776        data.model_name = "gpt-4".to_string();
777        data.provider_name = "openai".to_string();
778        data.update_tokens(100, 50);
779        data.set_total_cost(0.01);
780        data.pwd = Some("~/projects".to_string());
781
782        let lines = data.render_lines(80);
783        assert!(lines.len() >= 2);
784        assert!(lines[0].contains("~/projects"));
785        assert!(lines[1].contains("gpt-4"));
786    }
787
788    #[test]
789    fn test_footer_data_extension_status() {
790        let mut data = FooterData::new();
791        data.set_extension_status("ext1", Some("working"));
792        assert_eq!(data.extension_statuses.get("ext1"), Some(&"working".to_string()));
793        data.set_extension_status("ext1", None);
794        assert!(data.extension_statuses.get("ext1").is_none());
795    }
796
797    #[test]
798    fn test_footer_data_set_context_window_pct() {
799        let mut data = FooterData::new();
800        data.set_context_window_pct(150.0); // Should be clamped to 100
801        assert_eq!(data.context_window_pct, 100.0);
802        data.set_context_window_pct(-10.0); // Should be clamped to 0
803        assert_eq!(data.context_window_pct, 0.0);
804    }
805
806    #[test]
807    fn test_footer_data_set_total_cost() {
808        let mut data = FooterData::new();
809        data.set_total_cost(1.234);
810        assert_eq!(data.total_cost, 1.234);
811    }
812
813    #[test]
814    fn test_footer_data_set_session_duration() {
815        let mut data = FooterData::new();
816        data.set_session_duration(3600);
817        assert_eq!(data.session_duration_secs, 3600);
818    }
819
820    #[test]
821    fn test_session_timer() {
822        let timer = SessionTimer::new();
823        std::thread::sleep(Duration::from_millis(10));
824        let elapsed = timer.elapsed();
825        assert!(elapsed.as_millis() >= 10);
826    }
827
828    #[test]
829    fn test_session_timer_reset() {
830        let mut timer = SessionTimer::new();
831        std::thread::sleep(Duration::from_millis(10));
832        timer.reset();
833        let elapsed = timer.elapsed();
834        assert!(elapsed.as_millis() < 10);
835    }
836
837    #[test]
838    fn test_format_duration() {
839        assert_eq!(format_duration(Duration::from_secs(30)), "30s");
840        assert_eq!(format_duration(Duration::from_secs(90)), "1m 30s");
841        assert_eq!(format_duration(Duration::from_secs(3661)), "1h 1m");
842        assert_eq!(format_duration(Duration::from_secs(86401)), "1d 0h");
843    }
844
845    #[test]
846    fn test_cost_estimator() {
847        let estimator = CostEstimator::new();
848
849        // Test with Claude model
850        let cost = estimator.estimate("claude-3.5-sonnet", 1_000_000, 1_000_000);
851        assert!(cost.is_some());
852        assert!(cost.unwrap() > 0.0);
853
854        // Test with unknown model
855        let cost = estimator.estimate("unknown-model", 1000, 500);
856        assert!(cost.is_none());
857    }
858
859    #[test]
860    fn test_cost_estimator_gpt4() {
861        let estimator = CostEstimator::new();
862        let cost = estimator.estimate("gpt-4-turbo", 500_000, 200_000);
863        assert!(cost.is_some());
864        let val = cost.unwrap();
865        // gpt-4: $30/M input, $60/M output
866        // 0.5M * $30 + 0.2M * $60 = $15 + $12 = $27
867        assert!((val - 27.0).abs() < 0.1);
868    }
869
870    #[test]
871    fn test_keybinding_hint() {
872        let hint = KeybindingHint::new("Ctrl+C", "Cancel");
873        assert_eq!(hint.keys, "Ctrl+C");
874        assert_eq!(hint.description, "Cancel");
875    }
876
877    #[test]
878    fn test_simple_provider() {
879        let provider = SimpleFooterDataProvider::new()
880            .with_model(Some("gpt-4".to_string()), Some("openai".to_string()))
881            .with_tokens(100, 50);
882
883        assert_eq!(provider.get_model_name(), Some("gpt-4".to_string()));
884        assert_eq!(provider.get_token_counts(), (100, 50));
885    }
886
887    #[test]
888    fn test_simple_provider_update() {
889        let mut provider = SimpleFooterDataProvider::new();
890        provider.update_tokens(200, 100);
891        provider.update_cache_tokens(500, 50);
892        assert_eq!(provider.get_token_counts(), (200, 100));
893    }
894
895    #[test]
896    fn test_simple_provider_footer_data() {
897        let provider = SimpleFooterDataProvider::new()
898            .with_model(Some("claude".to_string()), Some("anthropic".to_string()))
899            .with_tokens(1000, 500);
900
901        let footer = provider.get_footer_data();
902        assert_eq!(footer.model_name, "claude");
903        assert!(footer.total_cost > 0.0);
904    }
905
906    #[test]
907    fn test_extension_status_tracker() {
908        let mut tracker = ExtensionStatusTracker::new();
909
910        tracker.set("my-extension", "Working...");
911        assert_eq!(tracker.get_all().get("my-extension"), Some(&"Working...".to_string()));
912
913        tracker.clear("my-extension");
914        assert!(tracker.get_all().get("my-extension").is_none());
915    }
916
917    #[test]
918    fn test_simple_provider_thinking_level() {
919        let mut provider = SimpleFooterDataProvider::new();
920        provider.set_thinking_level("high");
921        let footer = provider.get_footer_data();
922        assert_eq!(footer.thinking_level, "high");
923    }
924
925    #[test]
926    fn test_simple_provider_session_name() {
927        let mut provider = SimpleFooterDataProvider::new();
928        provider.set_session_name(Some("my-session".to_string()));
929        let footer = provider.get_footer_data();
930        assert_eq!(footer.session_name, Some("my-session".to_string()));
931    }
932}