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: "reset",
65 alias: Some("r"),
66 description: "Reset provider credentials",
67 auto_execute: true,
68 },
69 SlashCommand {
70 name: "profile",
71 alias: None,
72 description: "Manage provider profiles (multiple configs)",
73 auto_execute: true,
74 },
75 SlashCommand {
76 name: "exit",
77 alias: Some("q"),
78 description: "Exit the chat",
79 auto_execute: true,
80 },
81];
82
83#[derive(Debug, Default, Clone)]
85pub struct TokenUsage {
86 pub prompt_tokens: u64,
88 pub completion_tokens: u64,
90 pub request_count: u64,
92 pub session_start: Option<std::time::Instant>,
94}
95
96impl TokenUsage {
97 pub fn new() -> Self {
98 Self {
99 session_start: Some(std::time::Instant::now()),
100 ..Default::default()
101 }
102 }
103
104 pub fn add_request(&mut self, prompt: u64, completion: u64) {
106 self.prompt_tokens += prompt;
107 self.completion_tokens += completion;
108 self.request_count += 1;
109 }
110
111 pub fn estimate_tokens(text: &str) -> u64 {
113 (text.len() as f64 / 4.0).ceil() as u64
114 }
115
116 pub fn total_tokens(&self) -> u64 {
118 self.prompt_tokens + self.completion_tokens
119 }
120
121 pub fn session_duration(&self) -> std::time::Duration {
123 self.session_start
124 .map(|start| start.elapsed())
125 .unwrap_or_default()
126 }
127
128 pub fn estimate_cost(&self, model: &str) -> (f64, f64, f64) {
131 let (input_per_m, output_per_m) = match model {
133 m if m.starts_with("gpt-5.2-mini") => (0.15, 0.60),
134 m if m.starts_with("gpt-5") => (2.50, 10.00),
135 m if m.starts_with("gpt-4o") => (2.50, 10.00),
136 m if m.starts_with("o1") => (15.00, 60.00),
137 m if m.contains("sonnet") => (3.00, 15.00),
138 m if m.contains("opus") => (15.00, 75.00),
139 m if m.contains("haiku") => (0.25, 1.25),
140 _ => (2.50, 10.00), };
142
143 let input_cost = (self.prompt_tokens as f64 / 1_000_000.0) * input_per_m;
144 let output_cost = (self.completion_tokens as f64 / 1_000_000.0) * output_per_m;
145
146 (input_cost, output_cost, input_cost + output_cost)
147 }
148
149 pub fn print_report(&self, model: &str) {
151 let duration = self.session_duration();
152 let (input_cost, output_cost, total_cost) = self.estimate_cost(model);
153
154 println!();
155 println!(" {}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}", ansi::PURPLE, ansi::RESET);
156 println!(" {}💰 Session Cost & Usage{}", ansi::PURPLE, ansi::RESET);
157 println!(" {}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}", ansi::PURPLE, ansi::RESET);
158 println!();
159 println!(" {}Model:{} {}", ansi::DIM, ansi::RESET, model);
160 println!(" {}Duration:{} {:02}:{:02}:{:02}",
161 ansi::DIM, ansi::RESET,
162 duration.as_secs() / 3600,
163 (duration.as_secs() % 3600) / 60,
164 duration.as_secs() % 60
165 );
166 println!(" {}Requests:{} {}", ansi::DIM, ansi::RESET, self.request_count);
167 println!();
168 println!(" {}Tokens:{}", ansi::CYAN, ansi::RESET);
169 println!(" Input: {:>10} tokens", self.prompt_tokens);
170 println!(" Output: {:>10} tokens", self.completion_tokens);
171 println!(" {}Total: {:>10} tokens{}", ansi::BOLD, self.total_tokens(), ansi::RESET);
172 println!();
173 println!(" {}Estimated Cost:{}", ansi::SUCCESS, ansi::RESET);
174 println!(" Input: ${:.4}", input_cost);
175 println!(" Output: ${:.4}", output_cost);
176 println!(" {}Total: ${:.4}{}", ansi::BOLD, total_cost, ansi::RESET);
177 println!();
178 println!(" {}(Estimates based on public API pricing){}", ansi::DIM, ansi::RESET);
179 println!();
180 }
181}
182
183pub struct CommandPicker {
185 pub filter: String,
187 pub selected_index: usize,
189 pub filtered_commands: Vec<&'static SlashCommand>,
191}
192
193impl CommandPicker {
194 pub fn new() -> Self {
195 Self {
196 filter: String::new(),
197 selected_index: 0,
198 filtered_commands: SLASH_COMMANDS.iter().collect(),
199 }
200 }
201
202 pub fn set_filter(&mut self, filter: &str) {
204 self.filter = filter.to_lowercase();
205 self.filtered_commands = SLASH_COMMANDS
206 .iter()
207 .filter(|cmd| {
208 cmd.name.starts_with(&self.filter) ||
209 cmd.alias.map(|a| a.starts_with(&self.filter)).unwrap_or(false)
210 })
211 .collect();
212
213 if self.selected_index >= self.filtered_commands.len() {
215 self.selected_index = 0;
216 }
217 }
218
219 pub fn move_up(&mut self) {
221 if !self.filtered_commands.is_empty() && self.selected_index > 0 {
222 self.selected_index -= 1;
223 }
224 }
225
226 pub fn move_down(&mut self) {
228 if !self.filtered_commands.is_empty() && self.selected_index < self.filtered_commands.len() - 1 {
229 self.selected_index += 1;
230 }
231 }
232
233 pub fn selected_command(&self) -> Option<&'static SlashCommand> {
235 self.filtered_commands.get(self.selected_index).copied()
236 }
237
238 pub fn render_suggestions(&self) -> usize {
240 let mut stdout = io::stdout();
241
242 if self.filtered_commands.is_empty() {
243 println!("\n {}No matching commands{}", ansi::DIM, ansi::RESET);
244 let _ = stdout.flush();
245 return 1;
246 }
247
248 for (i, cmd) in self.filtered_commands.iter().enumerate() {
249 let is_selected = i == self.selected_index;
250
251 if is_selected {
252 println!(" {}▸ /{:<15}{} {}{}{}",
254 ansi::PURPLE, cmd.name, ansi::RESET,
255 ansi::PURPLE, cmd.description, ansi::RESET);
256 } else {
257 println!(" {} /{:<15} {}{}",
259 ansi::DIM, cmd.name, cmd.description, ansi::RESET);
260 }
261 }
262
263 let _ = stdout.flush();
264 self.filtered_commands.len()
265 }
266
267 pub fn clear_lines(&self, num_lines: usize) {
269 let mut stdout = io::stdout();
270 for _ in 0..num_lines {
271 let _ = execute!(stdout, MoveUp(1), Clear(ClearType::CurrentLine));
272 }
273 let _ = stdout.flush();
274 }
275}
276
277pub fn show_command_picker(initial_filter: &str) -> Option<String> {
281 let mut picker = CommandPicker::new();
282 picker.set_filter(initial_filter);
283
284 if terminal::enable_raw_mode().is_err() {
286 return show_simple_picker(&picker);
288 }
289
290 let mut stdout = io::stdout();
291 let mut input_buffer = format!("/{}", initial_filter);
292 let mut last_rendered_lines = 0;
293
294 println!(); last_rendered_lines = picker.render_suggestions();
297
298 let _ = execute!(stdout, MoveUp(last_rendered_lines as u16 + 1), MoveToColumn(0));
300 print!("{}You: {}{}", ansi::SUCCESS, ansi::RESET, input_buffer);
301 let _ = stdout.flush();
302
303 let _ = execute!(stdout, cursor::MoveDown(last_rendered_lines as u16 + 1));
305
306 let result = loop {
307 if let Ok(Event::Key(key_event)) = event::read() {
309 match key_event.code {
310 KeyCode::Esc => {
311 break None;
313 }
314 KeyCode::Enter => {
315 if let Some(cmd) = picker.selected_command() {
317 break Some(cmd.name.to_string());
318 }
319 break None;
320 }
321 KeyCode::Up => {
322 picker.move_up();
323 }
324 KeyCode::Down => {
325 picker.move_down();
326 }
327 KeyCode::Backspace => {
328 if input_buffer.len() > 1 {
329 input_buffer.pop();
330 let filter = input_buffer.trim_start_matches('/');
331 picker.set_filter(filter);
332 } else {
333 break None;
335 }
336 }
337 KeyCode::Char(c) => {
338 input_buffer.push(c);
340 let filter = input_buffer.trim_start_matches('/');
341 picker.set_filter(filter);
342
343 if picker.filtered_commands.len() == 1 {
345 }
347 }
348 KeyCode::Tab => {
349 if let Some(cmd) = picker.selected_command() {
351 break Some(cmd.name.to_string());
352 }
353 }
354 _ => {}
355 }
356
357 picker.clear_lines(last_rendered_lines);
359
360 let _ = execute!(stdout, Clear(ClearType::CurrentLine), MoveToColumn(0));
362 print!("{}You: {}{}", ansi::SUCCESS, ansi::RESET, input_buffer);
363 let _ = stdout.flush();
364
365 println!();
367 last_rendered_lines = picker.render_suggestions();
368
369 let _ = execute!(stdout, MoveUp(last_rendered_lines as u16 + 1));
371 let _ = execute!(stdout, MoveToColumn((5 + input_buffer.len()) as u16));
372 let _ = stdout.flush();
373
374 let _ = execute!(stdout, cursor::MoveDown(last_rendered_lines as u16 + 1));
376 }
377 };
378
379 let _ = terminal::disable_raw_mode();
381
382 picker.clear_lines(last_rendered_lines);
384 let _ = execute!(stdout, Clear(ClearType::CurrentLine), MoveToColumn(0));
385 let _ = stdout.flush();
386
387 result
388}
389
390fn show_simple_picker(picker: &CommandPicker) -> Option<String> {
392 println!();
393 println!(" {}📋 Available Commands:{}", ansi::CYAN, ansi::RESET);
394 println!();
395
396 for (i, cmd) in picker.filtered_commands.iter().enumerate() {
397 print!(" {} {}/{:<12}", format!("[{}]", i + 1), ansi::PURPLE, cmd.name);
398 if let Some(alias) = cmd.alias {
399 print!(" ({})", alias);
400 }
401 println!("{} - {}{}{}", ansi::RESET, ansi::DIM, cmd.description, ansi::RESET);
402 }
403
404 println!();
405 print!(" Select (1-{}) or press Enter to cancel: ", picker.filtered_commands.len());
406 let _ = io::stdout().flush();
407
408 let mut input = String::new();
409 if io::stdin().read_line(&mut input).is_ok() {
410 let input = input.trim();
411 if let Ok(num) = input.parse::<usize>() {
412 if num >= 1 && num <= picker.filtered_commands.len() {
413 return Some(picker.filtered_commands[num - 1].name.to_string());
414 }
415 }
416 }
417
418 None
419}
420
421pub fn match_command(query: &str) -> Option<&'static SlashCommand> {
423 let query = query.trim_start_matches('/').to_lowercase();
424
425 SLASH_COMMANDS.iter().find(|cmd| {
426 cmd.name == query || cmd.alias.map(|a| a == query).unwrap_or(false)
427 })
428}