1use crate::agent::ui::colors::ansi;
10use crossterm::{
11 cursor::{self, MoveToColumn, MoveUp},
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: "plans",
77 alias: None,
78 description: "Show incomplete plans and continue",
79 auto_execute: true,
80 },
81 SlashCommand {
82 name: "exit",
83 alias: Some("q"),
84 description: "Exit the chat",
85 auto_execute: true,
86 },
87];
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
91pub enum TokenCountType {
92 Actual,
94 #[default]
96 Approximate,
97}
98
99#[derive(Debug, Default, Clone)]
102pub struct TokenUsage {
103 pub prompt_tokens: u64,
105 pub completion_tokens: u64,
107 pub cache_read_tokens: u64,
109 pub cache_creation_tokens: u64,
111 pub thinking_tokens: u64,
113 pub count_type: TokenCountType,
115 pub request_count: u64,
117 pub session_start: Option<std::time::Instant>,
119}
120
121impl TokenUsage {
122 pub fn new() -> Self {
123 Self {
124 session_start: Some(std::time::Instant::now()),
125 ..Default::default()
126 }
127 }
128
129 pub fn add_actual(&mut self, input: u64, output: u64) {
131 self.prompt_tokens += input;
132 self.completion_tokens += output;
133 self.request_count += 1;
134 if input > 0 || output > 0 {
136 self.count_type = TokenCountType::Actual;
137 }
138 }
139
140 pub fn add_actual_extended(
142 &mut self,
143 input: u64,
144 output: u64,
145 cache_read: u64,
146 cache_creation: u64,
147 thinking: u64,
148 ) {
149 self.prompt_tokens += input;
150 self.completion_tokens += output;
151 self.cache_read_tokens += cache_read;
152 self.cache_creation_tokens += cache_creation;
153 self.thinking_tokens += thinking;
154 self.request_count += 1;
155 self.count_type = TokenCountType::Actual;
156 }
157
158 pub fn add_estimated(&mut self, prompt: u64, completion: u64) {
161 self.prompt_tokens += prompt;
162 self.completion_tokens += completion;
163 self.request_count += 1;
164 }
166
167 pub fn add_request(&mut self, prompt: u64, completion: u64) {
169 self.add_estimated(prompt, completion);
170 }
171
172 pub fn estimate_tokens(text: &str) -> u64 {
175 text.len().div_ceil(4) as u64
176 }
177
178 pub fn total_tokens(&self) -> u64 {
180 self.prompt_tokens + self.completion_tokens
181 }
182
183 pub fn total_with_cache(&self) -> u64 {
185 self.prompt_tokens + self.completion_tokens + self.cache_read_tokens
186 }
187
188 pub fn format_total(&self) -> String {
190 match self.count_type {
191 TokenCountType::Actual => format!("{}", self.total_tokens()),
192 TokenCountType::Approximate => format!("~{}", self.total_tokens()),
193 }
194 }
195
196 pub fn format_compact(&self) -> String {
198 let total = self.total_tokens();
199 let prefix = match self.count_type {
200 TokenCountType::Actual => "",
201 TokenCountType::Approximate => "~",
202 };
203
204 if total >= 1_000_000 {
205 format!("{}{:.1}M", prefix, total as f64 / 1_000_000.0)
206 } else if total >= 1_000 {
207 format!("{}{:.1}k", prefix, total as f64 / 1_000.0)
208 } else {
209 format!("{}{}", prefix, total)
210 }
211 }
212
213 pub fn has_cache_hits(&self) -> bool {
215 self.cache_read_tokens > 0
216 }
217
218 pub fn has_thinking(&self) -> bool {
220 self.thinking_tokens > 0
221 }
222
223 pub fn session_duration(&self) -> std::time::Duration {
225 self.session_start
226 .map(|start| start.elapsed())
227 .unwrap_or_default()
228 }
229
230 pub fn estimate_cost(&self, model: &str) -> (f64, f64, f64) {
233 let (input_per_m, output_per_m) = match model {
235 m if m.starts_with("gpt-5.2-mini") => (0.15, 0.60),
236 m if m.starts_with("gpt-5") => (2.50, 10.00),
237 m if m.starts_with("gpt-4o") => (2.50, 10.00),
238 m if m.starts_with("o1") => (15.00, 60.00),
239 m if m.contains("sonnet") => (3.00, 15.00),
240 m if m.contains("opus") => (15.00, 75.00),
241 m if m.contains("haiku") => (0.25, 1.25),
242 _ => (2.50, 10.00), };
244
245 let input_cost = (self.prompt_tokens as f64 / 1_000_000.0) * input_per_m;
246 let output_cost = (self.completion_tokens as f64 / 1_000_000.0) * output_per_m;
247
248 (input_cost, output_cost, input_cost + output_cost)
249 }
250
251 pub fn print_report(&self, model: &str) {
253 let duration = self.session_duration();
254 let (input_cost, output_cost, total_cost) = self.estimate_cost(model);
255
256 let accuracy_note = match self.count_type {
258 TokenCountType::Actual => format!("{}actual counts{}", ansi::SUCCESS, ansi::RESET),
259 TokenCountType::Approximate => format!("{}~approximate{}", ansi::DIM, ansi::RESET),
260 };
261
262 println!();
263 println!(
264 " {}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}",
265 ansi::PURPLE,
266 ansi::RESET
267 );
268 println!(" {}💰 Session Cost & Usage{}", ansi::PURPLE, ansi::RESET);
269 println!(
270 " {}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}",
271 ansi::PURPLE,
272 ansi::RESET
273 );
274 println!();
275 println!(" {}Model:{} {}", ansi::DIM, ansi::RESET, model);
276 println!(
277 " {}Duration:{} {:02}:{:02}:{:02}",
278 ansi::DIM,
279 ansi::RESET,
280 duration.as_secs() / 3600,
281 (duration.as_secs() % 3600) / 60,
282 duration.as_secs() % 60
283 );
284 println!(
285 " {}Requests:{} {}",
286 ansi::DIM,
287 ansi::RESET,
288 self.request_count
289 );
290 println!();
291 println!(
292 " {}Tokens{} ({}){}:",
293 ansi::CYAN,
294 ansi::RESET,
295 accuracy_note,
296 ansi::RESET
297 );
298 println!(" Input: {:>10} tokens", self.prompt_tokens);
299 println!(" Output: {:>10} tokens", self.completion_tokens);
300
301 if self.cache_read_tokens > 0 || self.cache_creation_tokens > 0 {
303 println!();
304 println!(" {}Cache:{}", ansi::CYAN, ansi::RESET);
305 if self.cache_read_tokens > 0 {
306 println!(
307 " Read: {:>10} tokens {}(saved){}",
308 self.cache_read_tokens,
309 ansi::SUCCESS,
310 ansi::RESET
311 );
312 }
313 if self.cache_creation_tokens > 0 {
314 println!(" Created: {:>10} tokens", self.cache_creation_tokens);
315 }
316 }
317
318 if self.thinking_tokens > 0 {
320 println!();
321 println!(" {}Thinking:{}", ansi::CYAN, ansi::RESET);
322 println!(" Reasoning:{:>10} tokens", self.thinking_tokens);
323 }
324
325 println!();
326 println!(
327 " {}Total: {:>10} tokens{}",
328 ansi::BOLD,
329 self.format_total(),
330 ansi::RESET
331 );
332 println!();
333 println!(" {}Estimated Cost:{}", ansi::SUCCESS, ansi::RESET);
334 println!(" Input: ${:.4}", input_cost);
335 println!(" Output: ${:.4}", output_cost);
336 println!(
337 " {}Total: ${:.4}{}",
338 ansi::BOLD,
339 total_cost,
340 ansi::RESET
341 );
342 println!();
343
344 match self.count_type {
346 TokenCountType::Actual => {
347 println!(" {}(Based on actual API usage){}", ansi::DIM, ansi::RESET);
348 }
349 TokenCountType::Approximate => {
350 println!(
351 " {}(Estimates based on ~4 chars/token){}",
352 ansi::DIM,
353 ansi::RESET
354 );
355 }
356 }
357 println!();
358 }
359}
360
361pub struct CommandPicker {
363 pub filter: String,
365 pub selected_index: usize,
367 pub filtered_commands: Vec<&'static SlashCommand>,
369}
370
371impl Default for CommandPicker {
372 fn default() -> Self {
373 Self {
374 filter: String::new(),
375 selected_index: 0,
376 filtered_commands: SLASH_COMMANDS.iter().collect(),
377 }
378 }
379}
380
381impl CommandPicker {
382 pub fn new() -> Self {
383 Self::default()
384 }
385
386 pub fn set_filter(&mut self, filter: &str) {
388 self.filter = filter.to_lowercase();
389 self.filtered_commands = SLASH_COMMANDS
390 .iter()
391 .filter(|cmd| {
392 cmd.name.starts_with(&self.filter)
393 || cmd
394 .alias
395 .map(|a| a.starts_with(&self.filter))
396 .unwrap_or(false)
397 })
398 .collect();
399
400 if self.selected_index >= self.filtered_commands.len() {
402 self.selected_index = 0;
403 }
404 }
405
406 pub fn move_up(&mut self) {
408 if !self.filtered_commands.is_empty() && self.selected_index > 0 {
409 self.selected_index -= 1;
410 }
411 }
412
413 pub fn move_down(&mut self) {
415 if !self.filtered_commands.is_empty()
416 && self.selected_index < self.filtered_commands.len() - 1
417 {
418 self.selected_index += 1;
419 }
420 }
421
422 pub fn selected_command(&self) -> Option<&'static SlashCommand> {
424 self.filtered_commands.get(self.selected_index).copied()
425 }
426
427 pub fn render_suggestions(&self) -> usize {
429 let mut stdout = io::stdout();
430
431 if self.filtered_commands.is_empty() {
432 println!("\n {}No matching commands{}", ansi::DIM, ansi::RESET);
433 let _ = stdout.flush();
434 return 1;
435 }
436
437 for (i, cmd) in self.filtered_commands.iter().enumerate() {
438 let is_selected = i == self.selected_index;
439
440 if is_selected {
441 println!(
443 " {}▸ /{:<15}{} {}{}{}",
444 ansi::PURPLE,
445 cmd.name,
446 ansi::RESET,
447 ansi::PURPLE,
448 cmd.description,
449 ansi::RESET
450 );
451 } else {
452 println!(
454 " {} /{:<15} {}{}",
455 ansi::DIM,
456 cmd.name,
457 cmd.description,
458 ansi::RESET
459 );
460 }
461 }
462
463 let _ = stdout.flush();
464 self.filtered_commands.len()
465 }
466
467 pub fn clear_lines(&self, num_lines: usize) {
469 let mut stdout = io::stdout();
470 for _ in 0..num_lines {
471 let _ = execute!(stdout, MoveUp(1), Clear(ClearType::CurrentLine));
472 }
473 let _ = stdout.flush();
474 }
475}
476
477pub fn show_command_picker(initial_filter: &str) -> Option<String> {
481 let mut picker = CommandPicker::new();
482 picker.set_filter(initial_filter);
483
484 if terminal::enable_raw_mode().is_err() {
486 return show_simple_picker(&picker);
488 }
489
490 let mut stdout = io::stdout();
491 let mut input_buffer = format!("/{}", initial_filter);
492
493 println!(); let mut last_rendered_lines = picker.render_suggestions();
496
497 let _ = execute!(
499 stdout,
500 MoveUp(last_rendered_lines as u16 + 1),
501 MoveToColumn(0)
502 );
503 print!("{}You: {}{}", ansi::SUCCESS, ansi::RESET, input_buffer);
504 let _ = stdout.flush();
505
506 let _ = execute!(stdout, cursor::MoveDown(last_rendered_lines as u16 + 1));
508
509 let result = loop {
510 if let Ok(Event::Key(key_event)) = event::read() {
512 match key_event.code {
513 KeyCode::Esc => {
514 break None;
516 }
517 KeyCode::Enter => {
518 if let Some(cmd) = picker.selected_command() {
520 break Some(cmd.name.to_string());
521 }
522 break None;
523 }
524 KeyCode::Up => {
525 picker.move_up();
526 }
527 KeyCode::Down => {
528 picker.move_down();
529 }
530 KeyCode::Backspace => {
531 if input_buffer.len() > 1 {
532 input_buffer.pop();
533 let filter = input_buffer.trim_start_matches('/');
534 picker.set_filter(filter);
535 } else {
536 break None;
538 }
539 }
540 KeyCode::Char(c) => {
541 input_buffer.push(c);
543 let filter = input_buffer.trim_start_matches('/');
544 picker.set_filter(filter);
545
546 if picker.filtered_commands.len() == 1 {
548 }
550 }
551 KeyCode::Tab => {
552 if let Some(cmd) = picker.selected_command() {
554 break Some(cmd.name.to_string());
555 }
556 }
557 _ => {}
558 }
559
560 picker.clear_lines(last_rendered_lines);
562
563 let _ = execute!(stdout, Clear(ClearType::CurrentLine), MoveToColumn(0));
565 print!("{}You: {}{}", ansi::SUCCESS, ansi::RESET, input_buffer);
566 let _ = stdout.flush();
567
568 println!();
570 last_rendered_lines = picker.render_suggestions();
571
572 let _ = execute!(stdout, MoveUp(last_rendered_lines as u16 + 1));
574 let _ = execute!(stdout, MoveToColumn((5 + input_buffer.len()) as u16));
575 let _ = stdout.flush();
576
577 let _ = execute!(stdout, cursor::MoveDown(last_rendered_lines as u16 + 1));
579 }
580 };
581
582 let _ = terminal::disable_raw_mode();
584
585 picker.clear_lines(last_rendered_lines);
587 let _ = execute!(stdout, Clear(ClearType::CurrentLine), MoveToColumn(0));
588 let _ = stdout.flush();
589
590 result
591}
592
593fn show_simple_picker(picker: &CommandPicker) -> Option<String> {
595 println!();
596 println!(" {}📋 Available Commands:{}", ansi::CYAN, ansi::RESET);
597 println!();
598
599 for (i, cmd) in picker.filtered_commands.iter().enumerate() {
600 print!(" [{}] {}/{:<12}", i + 1, ansi::PURPLE, cmd.name);
601 if let Some(alias) = cmd.alias {
602 print!(" ({})", alias);
603 }
604 println!(
605 "{} - {}{}{}",
606 ansi::RESET,
607 ansi::DIM,
608 cmd.description,
609 ansi::RESET
610 );
611 }
612
613 println!();
614 print!(
615 " Select (1-{}) or press Enter to cancel: ",
616 picker.filtered_commands.len()
617 );
618 let _ = io::stdout().flush();
619
620 let mut input = String::new();
621 if io::stdin().read_line(&mut input).is_ok() {
622 let input = input.trim();
623 if let Ok(num) = input.parse::<usize>()
624 && num >= 1
625 && num <= picker.filtered_commands.len()
626 {
627 return Some(picker.filtered_commands[num - 1].name.to_string());
628 }
629 }
630
631 None
632}
633
634pub fn match_command(query: &str) -> Option<&'static SlashCommand> {
636 let query = query.trim_start_matches('/').to_lowercase();
637
638 SLASH_COMMANDS
639 .iter()
640 .find(|cmd| cmd.name == query || cmd.alias.map(|a| a == query).unwrap_or(false))
641}