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