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