1use crate::agent::ui::colors::ansi;
10use crossterm::{
11 cursor::{self, MoveUp, MoveToColumn},
12 event::{self, Event, KeyCode},
13 execute,
14 terminal::{self, Clear, ClearType},
15};
16use std::io::{self, Write};
17
18#[derive(Clone)]
20pub struct SlashCommand {
21 pub name: &'static str,
23 pub alias: Option<&'static str>,
25 pub description: &'static str,
27 pub auto_execute: bool,
29}
30
31pub const SLASH_COMMANDS: &[SlashCommand] = &[
33 SlashCommand {
34 name: "model",
35 alias: Some("m"),
36 description: "Select a different AI model",
37 auto_execute: true,
38 },
39 SlashCommand {
40 name: "provider",
41 alias: Some("p"),
42 description: "Switch provider (OpenAI/Anthropic)",
43 auto_execute: true,
44 },
45 SlashCommand {
46 name: "cost",
47 alias: None,
48 description: "Show token usage and estimated cost",
49 auto_execute: true,
50 },
51 SlashCommand {
52 name: "clear",
53 alias: Some("c"),
54 description: "Clear conversation history",
55 auto_execute: true,
56 },
57 SlashCommand {
58 name: "help",
59 alias: Some("h"),
60 description: "Show available commands",
61 auto_execute: true,
62 },
63 SlashCommand {
64 name: "exit",
65 alias: Some("q"),
66 description: "Exit the chat",
67 auto_execute: true,
68 },
69];
70
71#[derive(Debug, Default, Clone)]
73pub struct TokenUsage {
74 pub prompt_tokens: u64,
76 pub completion_tokens: u64,
78 pub request_count: u64,
80 pub session_start: Option<std::time::Instant>,
82}
83
84impl TokenUsage {
85 pub fn new() -> Self {
86 Self {
87 session_start: Some(std::time::Instant::now()),
88 ..Default::default()
89 }
90 }
91
92 pub fn add_request(&mut self, prompt: u64, completion: u64) {
94 self.prompt_tokens += prompt;
95 self.completion_tokens += completion;
96 self.request_count += 1;
97 }
98
99 pub fn estimate_tokens(text: &str) -> u64 {
101 (text.len() as f64 / 4.0).ceil() as u64
102 }
103
104 pub fn total_tokens(&self) -> u64 {
106 self.prompt_tokens + self.completion_tokens
107 }
108
109 pub fn session_duration(&self) -> std::time::Duration {
111 self.session_start
112 .map(|start| start.elapsed())
113 .unwrap_or_default()
114 }
115
116 pub fn estimate_cost(&self, model: &str) -> (f64, f64, f64) {
119 let (input_per_m, output_per_m) = match model {
121 m if m.starts_with("gpt-5.2-mini") => (0.15, 0.60),
122 m if m.starts_with("gpt-5") => (2.50, 10.00),
123 m if m.starts_with("gpt-4o") => (2.50, 10.00),
124 m if m.starts_with("o1") => (15.00, 60.00),
125 m if m.contains("sonnet") => (3.00, 15.00),
126 m if m.contains("opus") => (15.00, 75.00),
127 m if m.contains("haiku") => (0.25, 1.25),
128 _ => (2.50, 10.00), };
130
131 let input_cost = (self.prompt_tokens as f64 / 1_000_000.0) * input_per_m;
132 let output_cost = (self.completion_tokens as f64 / 1_000_000.0) * output_per_m;
133
134 (input_cost, output_cost, input_cost + output_cost)
135 }
136
137 pub fn print_report(&self, model: &str) {
139 let duration = self.session_duration();
140 let (input_cost, output_cost, total_cost) = self.estimate_cost(model);
141
142 println!();
143 println!(" {}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}", ansi::PURPLE, ansi::RESET);
144 println!(" {}💰 Session Cost & Usage{}", ansi::PURPLE, ansi::RESET);
145 println!(" {}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}", ansi::PURPLE, ansi::RESET);
146 println!();
147 println!(" {}Model:{} {}", ansi::DIM, ansi::RESET, model);
148 println!(" {}Duration:{} {:02}:{:02}:{:02}",
149 ansi::DIM, ansi::RESET,
150 duration.as_secs() / 3600,
151 (duration.as_secs() % 3600) / 60,
152 duration.as_secs() % 60
153 );
154 println!(" {}Requests:{} {}", ansi::DIM, ansi::RESET, self.request_count);
155 println!();
156 println!(" {}Tokens:{}", ansi::CYAN, ansi::RESET);
157 println!(" Input: {:>10} tokens", self.prompt_tokens);
158 println!(" Output: {:>10} tokens", self.completion_tokens);
159 println!(" {}Total: {:>10} tokens{}", ansi::BOLD, self.total_tokens(), ansi::RESET);
160 println!();
161 println!(" {}Estimated Cost:{}", ansi::SUCCESS, ansi::RESET);
162 println!(" Input: ${:.4}", input_cost);
163 println!(" Output: ${:.4}", output_cost);
164 println!(" {}Total: ${:.4}{}", ansi::BOLD, total_cost, ansi::RESET);
165 println!();
166 println!(" {}(Estimates based on public API pricing){}", ansi::DIM, ansi::RESET);
167 println!();
168 }
169}
170
171pub struct CommandPicker {
173 pub filter: String,
175 pub selected_index: usize,
177 pub filtered_commands: Vec<&'static SlashCommand>,
179}
180
181impl CommandPicker {
182 pub fn new() -> Self {
183 Self {
184 filter: String::new(),
185 selected_index: 0,
186 filtered_commands: SLASH_COMMANDS.iter().collect(),
187 }
188 }
189
190 pub fn set_filter(&mut self, filter: &str) {
192 self.filter = filter.to_lowercase();
193 self.filtered_commands = SLASH_COMMANDS
194 .iter()
195 .filter(|cmd| {
196 cmd.name.starts_with(&self.filter) ||
197 cmd.alias.map(|a| a.starts_with(&self.filter)).unwrap_or(false)
198 })
199 .collect();
200
201 if self.selected_index >= self.filtered_commands.len() {
203 self.selected_index = 0;
204 }
205 }
206
207 pub fn move_up(&mut self) {
209 if !self.filtered_commands.is_empty() && self.selected_index > 0 {
210 self.selected_index -= 1;
211 }
212 }
213
214 pub fn move_down(&mut self) {
216 if !self.filtered_commands.is_empty() && self.selected_index < self.filtered_commands.len() - 1 {
217 self.selected_index += 1;
218 }
219 }
220
221 pub fn selected_command(&self) -> Option<&'static SlashCommand> {
223 self.filtered_commands.get(self.selected_index).copied()
224 }
225
226 pub fn render_suggestions(&self) -> usize {
228 let mut stdout = io::stdout();
229
230 if self.filtered_commands.is_empty() {
231 println!("\n {}No matching commands{}", ansi::DIM, ansi::RESET);
232 let _ = stdout.flush();
233 return 1;
234 }
235
236 for (i, cmd) in self.filtered_commands.iter().enumerate() {
237 let is_selected = i == self.selected_index;
238
239 if is_selected {
240 println!(" {}▸ /{:<15}{} {}{}{}",
242 ansi::PURPLE, cmd.name, ansi::RESET,
243 ansi::PURPLE, cmd.description, ansi::RESET);
244 } else {
245 println!(" {} /{:<15} {}{}",
247 ansi::DIM, cmd.name, cmd.description, ansi::RESET);
248 }
249 }
250
251 let _ = stdout.flush();
252 self.filtered_commands.len()
253 }
254
255 pub fn clear_lines(&self, num_lines: usize) {
257 let mut stdout = io::stdout();
258 for _ in 0..num_lines {
259 let _ = execute!(stdout, MoveUp(1), Clear(ClearType::CurrentLine));
260 }
261 let _ = stdout.flush();
262 }
263}
264
265pub fn show_command_picker(initial_filter: &str) -> Option<String> {
269 let mut picker = CommandPicker::new();
270 picker.set_filter(initial_filter);
271
272 if terminal::enable_raw_mode().is_err() {
274 return show_simple_picker(&picker);
276 }
277
278 let mut stdout = io::stdout();
279 let mut input_buffer = format!("/{}", initial_filter);
280 let mut last_rendered_lines = 0;
281
282 println!(); last_rendered_lines = picker.render_suggestions();
285
286 let _ = execute!(stdout, MoveUp(last_rendered_lines as u16 + 1), MoveToColumn(0));
288 print!("{}You: {}{}", ansi::SUCCESS, ansi::RESET, input_buffer);
289 let _ = stdout.flush();
290
291 let _ = execute!(stdout, cursor::MoveDown(last_rendered_lines as u16 + 1));
293
294 let result = loop {
295 if let Ok(Event::Key(key_event)) = event::read() {
297 match key_event.code {
298 KeyCode::Esc => {
299 break None;
301 }
302 KeyCode::Enter => {
303 if let Some(cmd) = picker.selected_command() {
305 break Some(cmd.name.to_string());
306 }
307 break None;
308 }
309 KeyCode::Up => {
310 picker.move_up();
311 }
312 KeyCode::Down => {
313 picker.move_down();
314 }
315 KeyCode::Backspace => {
316 if input_buffer.len() > 1 {
317 input_buffer.pop();
318 let filter = input_buffer.trim_start_matches('/');
319 picker.set_filter(filter);
320 } else {
321 break None;
323 }
324 }
325 KeyCode::Char(c) => {
326 input_buffer.push(c);
328 let filter = input_buffer.trim_start_matches('/');
329 picker.set_filter(filter);
330
331 if picker.filtered_commands.len() == 1 {
333 }
335 }
336 KeyCode::Tab => {
337 if let Some(cmd) = picker.selected_command() {
339 break Some(cmd.name.to_string());
340 }
341 }
342 _ => {}
343 }
344
345 picker.clear_lines(last_rendered_lines);
347
348 let _ = execute!(stdout, Clear(ClearType::CurrentLine), MoveToColumn(0));
350 print!("{}You: {}{}", ansi::SUCCESS, ansi::RESET, input_buffer);
351 let _ = stdout.flush();
352
353 println!();
355 last_rendered_lines = picker.render_suggestions();
356
357 let _ = execute!(stdout, MoveUp(last_rendered_lines as u16 + 1));
359 let _ = execute!(stdout, MoveToColumn((5 + input_buffer.len()) as u16));
360 let _ = stdout.flush();
361
362 let _ = execute!(stdout, cursor::MoveDown(last_rendered_lines as u16 + 1));
364 }
365 };
366
367 let _ = terminal::disable_raw_mode();
369
370 picker.clear_lines(last_rendered_lines);
372 let _ = execute!(stdout, Clear(ClearType::CurrentLine), MoveToColumn(0));
373 let _ = stdout.flush();
374
375 result
376}
377
378fn show_simple_picker(picker: &CommandPicker) -> Option<String> {
380 println!();
381 println!(" {}📋 Available Commands:{}", ansi::CYAN, ansi::RESET);
382 println!();
383
384 for (i, cmd) in picker.filtered_commands.iter().enumerate() {
385 print!(" {} {}/{:<12}", format!("[{}]", i + 1), ansi::PURPLE, cmd.name);
386 if let Some(alias) = cmd.alias {
387 print!(" ({})", alias);
388 }
389 println!("{} - {}{}{}", ansi::RESET, ansi::DIM, cmd.description, ansi::RESET);
390 }
391
392 println!();
393 print!(" Select (1-{}) or press Enter to cancel: ", picker.filtered_commands.len());
394 let _ = io::stdout().flush();
395
396 let mut input = String::new();
397 if io::stdin().read_line(&mut input).is_ok() {
398 let input = input.trim();
399 if let Ok(num) = input.parse::<usize>() {
400 if num >= 1 && num <= picker.filtered_commands.len() {
401 return Some(picker.filtered_commands[num - 1].name.to_string());
402 }
403 }
404 }
405
406 None
407}
408
409pub fn match_command(query: &str) -> Option<&'static SlashCommand> {
411 let query = query.trim_start_matches('/').to_lowercase();
412
413 SLASH_COMMANDS.iter().find(|cmd| {
414 cmd.name == query || cmd.alias.map(|a| a == query).unwrap_or(false)
415 })
416}