syncable_cli/agent/
commands.rs

1//! Slash command definitions and interactive command picker
2//!
3//! Provides Gemini CLI-style "/" command system with:
4//! - Interactive command picker when typing "/"
5//! - Arrow key navigation
6//! - Auto-complete on Enter
7//! - Token usage tracking via /cost
8
9use 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/// A slash command definition
19#[derive(Clone)]
20pub struct SlashCommand {
21    /// Command name (without the /)
22    pub name: &'static str,
23    /// Short alias (e.g., "m" for "model")
24    pub alias: Option<&'static str>,
25    /// Description shown in picker
26    pub description: &'static str,
27    /// Whether this command auto-executes on selection (vs. inserting text)
28    pub auto_execute: bool,
29}
30
31/// All available slash commands
32pub 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/// Token usage statistics for /cost command
72#[derive(Debug, Default, Clone)]
73pub struct TokenUsage {
74    /// Total prompt/input tokens
75    pub prompt_tokens: u64,
76    /// Total completion/output tokens  
77    pub completion_tokens: u64,
78    /// Number of requests made
79    pub request_count: u64,
80    /// Session start time
81    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    /// Add tokens from a request
93    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    /// Estimate token count from text (rough approximation: ~4 chars per token)
100    pub fn estimate_tokens(text: &str) -> u64 {
101        (text.len() as f64 / 4.0).ceil() as u64
102    }
103
104    /// Get total tokens
105    pub fn total_tokens(&self) -> u64 {
106        self.prompt_tokens + self.completion_tokens
107    }
108
109    /// Get session duration
110    pub fn session_duration(&self) -> std::time::Duration {
111        self.session_start
112            .map(|start| start.elapsed())
113            .unwrap_or_default()
114    }
115
116    /// Estimate cost based on model (rough estimates in USD)
117    /// Returns (input_cost, output_cost, total_cost)
118    pub fn estimate_cost(&self, model: &str) -> (f64, f64, f64) {
119        // Pricing per 1M tokens (as of Dec 2025, approximate)
120        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), // Default to GPT-4o pricing
129        };
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    /// Print cost report
138    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
171/// Interactive command picker state
172pub struct CommandPicker {
173    /// Current filter text (after the /)
174    pub filter: String,
175    /// Currently selected index
176    pub selected_index: usize,
177    /// Filtered commands
178    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    /// Update filter and refresh filtered commands
191    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        // Reset selection if out of bounds
202        if self.selected_index >= self.filtered_commands.len() {
203            self.selected_index = 0;
204        }
205    }
206
207    /// Move selection up
208    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    /// Move selection down  
215    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    /// Get currently selected command
222    pub fn selected_command(&self) -> Option<&'static SlashCommand> {
223        self.filtered_commands.get(self.selected_index).copied()
224    }
225
226    /// Render the picker suggestions below current line
227    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                // Selected item - highlighted with arrow
241                println!("  {}▸ /{:<15}{} {}{}{}", 
242                    ansi::PURPLE, cmd.name, ansi::RESET,
243                    ansi::PURPLE, cmd.description, ansi::RESET);
244            } else {
245                // Normal item - dimmed
246                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    /// Clear n lines above cursor
256    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
265/// Show interactive command picker and return selected command
266/// This is called when user types "/" - shows suggestions immediately
267/// Returns None if cancelled, Some(command_name) if selected
268pub fn show_command_picker(initial_filter: &str) -> Option<String> {
269    let mut picker = CommandPicker::new();
270    picker.set_filter(initial_filter);
271    
272    // Enable raw mode for real-time key handling
273    if terminal::enable_raw_mode().is_err() {
274        // Fallback to simple mode if raw mode fails
275        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    // Initial render
283    println!(); // Move to new line for suggestions
284    last_rendered_lines = picker.render_suggestions();
285    
286    // Move back up to input line and position cursor
287    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    // Move down to after suggestions
292    let _ = execute!(stdout, cursor::MoveDown(last_rendered_lines as u16 + 1));
293    
294    let result = loop {
295        // Wait for key event
296        if let Ok(Event::Key(key_event)) = event::read() {
297            match key_event.code {
298                KeyCode::Esc => {
299                    // Cancel
300                    break None;
301                }
302                KeyCode::Enter => {
303                    // Select current
304                    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                        // Backspace on just "/" - cancel
322                        break None;
323                    }
324                }
325                KeyCode::Char(c) => {
326                    // Add character to filter
327                    input_buffer.push(c);
328                    let filter = input_buffer.trim_start_matches('/');
329                    picker.set_filter(filter);
330                    
331                    // If there's an exact match and user typed enough, auto-select
332                    if picker.filtered_commands.len() == 1 {
333                        // Perfect match - could auto-complete
334                    }
335                }
336                KeyCode::Tab => {
337                    // Tab to auto-complete current selection
338                    if let Some(cmd) = picker.selected_command() {
339                        break Some(cmd.name.to_string());
340                    }
341                }
342                _ => {}
343            }
344            
345            // Clear old suggestions and re-render
346            picker.clear_lines(last_rendered_lines);
347            
348            // Re-render input line
349            let _ = execute!(stdout, Clear(ClearType::CurrentLine), MoveToColumn(0));
350            print!("{}You: {}{}", ansi::SUCCESS, ansi::RESET, input_buffer);
351            let _ = stdout.flush();
352            
353            // Render suggestions below
354            println!();
355            last_rendered_lines = picker.render_suggestions();
356            
357            // Move back to input line position
358            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            // Move down to after suggestions for next iteration
363            let _ = execute!(stdout, cursor::MoveDown(last_rendered_lines as u16 + 1));
364        }
365    };
366    
367    // Disable raw mode
368    let _ = terminal::disable_raw_mode();
369    
370    // Clean up display
371    picker.clear_lines(last_rendered_lines);
372    let _ = execute!(stdout, Clear(ClearType::CurrentLine), MoveToColumn(0));
373    let _ = stdout.flush();
374    
375    result
376}
377
378/// Fallback simple picker when raw mode is not available
379fn 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
409/// Check if a command matches a query (name or alias)
410pub 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}