Skip to main content

codetether_agent/tui/
token_display.rs

1use crate::telemetry::{ContextLimit, CostEstimate, TOKEN_USAGE, TokenUsageSnapshot};
2use crate::tui::theme::Theme;
3use ratatui::{
4    Frame,
5    style::{Color, Modifier, Style},
6    text::{Line, Span},
7    widgets::{Paragraph, Wrap},
8};
9
10/// Enhanced token usage display with costs and warnings
11pub struct TokenDisplay {
12    model_context_limits: std::collections::HashMap<String, u64>,
13}
14
15impl TokenDisplay {
16    pub fn new() -> Self {
17        let mut limits = std::collections::HashMap::new();
18
19        // Common model context limits
20        limits.insert("gpt-4".to_string(), 128_000);
21        limits.insert("gpt-4-turbo".to_string(), 128_000);
22        limits.insert("gpt-4o".to_string(), 128_000);
23        limits.insert("gpt-4o-mini".to_string(), 128_000);
24        limits.insert("claude-3-5-sonnet".to_string(), 200_000);
25        limits.insert("claude-3-5-haiku".to_string(), 200_000);
26        limits.insert("claude-3-opus".to_string(), 200_000);
27        limits.insert("gemini-2.0-flash".to_string(), 1_000_000);
28        limits.insert("gemini-1.5-flash".to_string(), 1_000_000);
29        limits.insert("gemini-1.5-pro".to_string(), 2_000_000);
30        limits.insert("k1.5".to_string(), 200_000);
31        limits.insert("k1.6".to_string(), 200_000);
32
33        Self {
34            model_context_limits: limits,
35        }
36    }
37
38    /// Get context limit for a model
39    pub fn get_context_limit(&self, model: &str) -> Option<u64> {
40        self.model_context_limits.get(model).copied()
41    }
42
43    /// Get pricing for a model (returns $ per million tokens for input/output)
44    fn get_model_pricing(&self, model: &str) -> (f64, f64) {
45        match model.to_lowercase().as_str() {
46            m if m.contains("gpt-4o-mini") => (0.15, 0.60),      // $0.15 / $0.60 per million
47            m if m.contains("gpt-4o") => (2.50, 10.00),         // $2.50 / $10.00 per million
48            m if m.contains("gpt-4-turbo") => (10.00, 30.00),   // $10 / $30 per million
49            m if m.contains("gpt-4") => (30.00, 60.00),         // $30 / $60 per million
50            m if m.contains("claude-3-5-sonnet") => (3.00, 15.00), // $3 / $15 per million
51            m if m.contains("claude-3-5-haiku") => (0.80, 4.00), // $0.80 / $4 per million
52            m if m.contains("claude-3-opus") => (15.00, 75.00), // $15 / $75 per million
53            m if m.contains("gemini-2.0-flash") => (0.075, 0.30), // $0.075 / $0.30 per million
54            m if m.contains("gemini-1.5-flash") => (0.075, 0.30), // $0.075 / $0.30 per million
55            m if m.contains("gemini-1.5-pro") => (1.25, 5.00),  // $1.25 / $5 per million
56            m if m.contains("glm-4") => (0.50, 0.50),           // ZhipuAI GLM-4 ~$0.50/million
57            m if m.contains("k1.5") => (8.00, 8.00),            // Moonshot K1.5
58            m if m.contains("k1.6") => (6.00, 6.00),            // Moonshot K1.6
59            _ => (1.00, 3.00),                                   // Default fallback
60        }
61    }
62
63    /// Calculate cost for a model given input and output token counts
64    pub fn calculate_cost_for_tokens(&self, model: &str, input_tokens: u64, output_tokens: u64) -> CostEstimate {
65        let (input_price, output_price) = self.get_model_pricing(model);
66        CostEstimate::from_tokens(
67            &crate::telemetry::TokenCounts::new(input_tokens, output_tokens),
68            input_price,
69            output_price,
70        )
71    }
72
73    /// Create status bar content with token usage
74    pub fn create_status_bar(&self, theme: &Theme) -> Line<'_> {
75        let global_snapshot = TOKEN_USAGE.global_snapshot();
76        let model_snapshots = TOKEN_USAGE.model_snapshots();
77
78        let total_tokens = global_snapshot.totals.total();
79        let session_cost = self.calculate_session_cost();
80
81        let mut spans = Vec::new();
82
83        // Help indicator
84        spans.push(Span::styled(
85            " ? ",
86            Style::default()
87                .fg(theme.status_bar_foreground.to_color())
88                .bg(theme.status_bar_background.to_color()),
89        ));
90        spans.push(Span::raw(" Help "));
91
92        // Switch agent
93        spans.push(Span::styled(
94            " Tab ",
95            Style::default()
96                .fg(theme.status_bar_foreground.to_color())
97                .bg(theme.status_bar_background.to_color()),
98        ));
99        spans.push(Span::raw(" Switch Agent "));
100
101        // Quit
102        spans.push(Span::styled(
103            " Ctrl+C ",
104            Style::default()
105                .fg(theme.status_bar_foreground.to_color())
106                .bg(theme.status_bar_background.to_color()),
107        ));
108        spans.push(Span::raw(" Quit "));
109
110        // Token usage
111        spans.push(Span::styled(
112            format!(" Tokens: {} ", total_tokens),
113            Style::default().fg(theme.timestamp_color.to_color()),
114        ));
115
116        // Cost
117        spans.push(Span::styled(
118            format!(" Cost: {} ", session_cost.format_smart()),
119            Style::default().fg(theme.timestamp_color.to_color()),
120        ));
121
122        // Context warning if active model is near limit
123        if let Some(warning) = self.get_context_warning(&model_snapshots) {
124            spans.push(Span::styled(
125                format!(" {} ", warning),
126                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
127            ));
128        }
129
130        Line::from(spans)
131    }
132
133    /// Calculate total session cost across all models
134    pub fn calculate_session_cost(&self) -> CostEstimate {
135        let model_snapshots = TOKEN_USAGE.model_snapshots();
136        let mut total = CostEstimate::default();
137
138        for snapshot in model_snapshots {
139            let model_cost = self.calculate_cost_for_tokens(
140                &snapshot.name,
141                snapshot.totals.input,
142                snapshot.totals.output,
143            );
144            total.input_cost += model_cost.input_cost;
145            total.output_cost += model_cost.output_cost;
146            total.total_cost += model_cost.total_cost;
147        }
148
149        total
150    }
151
152    /// Get context warning for active model
153    fn get_context_warning(&self, model_snapshots: &[TokenUsageSnapshot]) -> Option<String> {
154        if model_snapshots.is_empty() {
155            return None;
156        }
157
158        // Use the model with highest usage as "active"
159        let active_model = model_snapshots.iter().max_by_key(|s| s.totals.total())?;
160
161        if let Some(limit) = self.get_context_limit(&active_model.name) {
162            let context = ContextLimit::new(active_model.totals.total(), limit);
163
164            if context.percentage >= 75.0 {
165                return Some(format!("⚠️ Context: {:.1}%", context.percentage));
166            }
167        }
168
169        None
170    }
171
172    /// Create detailed token usage display
173    pub fn create_detailed_display(&self) -> Vec<String> {
174        let mut lines = Vec::new();
175        let global_snapshot = TOKEN_USAGE.global_snapshot();
176        let model_snapshots = TOKEN_USAGE.model_snapshots();
177
178        lines.push("".to_string());
179        lines.push("  TOKEN USAGE & COSTS".to_string());
180        lines.push("  ===================".to_string());
181        lines.push("".to_string());
182
183        // Global totals
184        let total_cost = self.calculate_session_cost();
185        lines.push(format!(
186            "  Total: {} tokens ({} requests) - {}",
187            global_snapshot.totals.total(),
188            global_snapshot.request_count,
189            total_cost.format_currency()
190        ));
191        lines.push(format!(
192            "  Current: {} in / {} out",
193            global_snapshot.totals.input, global_snapshot.totals.output
194        ));
195        lines.push("".to_string());
196
197        // Per-model breakdown
198        if !model_snapshots.is_empty() {
199            lines.push("  BY MODEL:".to_string());
200
201            for snapshot in model_snapshots.iter().take(5) {
202                let model_cost = self.calculate_cost_for_tokens(
203                    &snapshot.name,
204                    snapshot.totals.input,
205                    snapshot.totals.output,
206                );
207                lines.push(format!(
208                    "    {}: {} tokens ({} requests) - {}",
209                    snapshot.name,
210                    snapshot.totals.total(),
211                    snapshot.request_count,
212                    model_cost.format_currency()
213                ));
214
215                // Context limit info
216                if let Some(limit) = self.get_context_limit(&snapshot.name) {
217                    let context = ContextLimit::new(snapshot.totals.total(), limit);
218                    if context.percentage >= 50.0 {
219                        lines.push(format!(
220                            "      Context: {:.1}% of {} tokens",
221                            context.percentage, limit
222                        ));
223                    }
224                }
225            }
226
227            if model_snapshots.len() > 5 {
228                lines.push(format!(
229                    "    ... and {} more models",
230                    model_snapshots.len() - 5
231                ));
232            }
233            lines.push("".to_string());
234        }
235
236        // Cost estimates
237        lines.push("  COST ESTIMATES:".to_string());
238        lines.push(format!(
239            "    Session total: {}",
240            total_cost.format_currency()
241        ));
242        lines.push("    Based on approximate pricing".to_string());
243
244        lines
245    }
246}
247
248impl Default for TokenDisplay {
249    fn default() -> Self {
250        Self::new()
251    }
252}