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: "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/// Token usage statistics for /cost command
84#[derive(Debug, Default, Clone)]
85pub struct TokenUsage {
86    /// Total prompt/input tokens
87    pub prompt_tokens: u64,
88    /// Total completion/output tokens  
89    pub completion_tokens: u64,
90    /// Number of requests made
91    pub request_count: u64,
92    /// Session start time
93    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    /// Add tokens from a request
105    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    /// Estimate token count from text (rough approximation: ~4 chars per token)
112    pub fn estimate_tokens(text: &str) -> u64 {
113        (text.len() as f64 / 4.0).ceil() as u64
114    }
115
116    /// Get total tokens
117    pub fn total_tokens(&self) -> u64 {
118        self.prompt_tokens + self.completion_tokens
119    }
120
121    /// Get session duration
122    pub fn session_duration(&self) -> std::time::Duration {
123        self.session_start
124            .map(|start| start.elapsed())
125            .unwrap_or_default()
126    }
127
128    /// Estimate cost based on model (rough estimates in USD)
129    /// Returns (input_cost, output_cost, total_cost)
130    pub fn estimate_cost(&self, model: &str) -> (f64, f64, f64) {
131        // Pricing per 1M tokens (as of Dec 2025, approximate)
132        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), // Default to GPT-4o pricing
141        };
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    /// Print cost report
150    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
183/// Interactive command picker state
184pub struct CommandPicker {
185    /// Current filter text (after the /)
186    pub filter: String,
187    /// Currently selected index
188    pub selected_index: usize,
189    /// Filtered commands
190    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    /// Update filter and refresh filtered commands
203    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        // Reset selection if out of bounds
214        if self.selected_index >= self.filtered_commands.len() {
215            self.selected_index = 0;
216        }
217    }
218
219    /// Move selection up
220    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    /// Move selection down  
227    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    /// Get currently selected command
234    pub fn selected_command(&self) -> Option<&'static SlashCommand> {
235        self.filtered_commands.get(self.selected_index).copied()
236    }
237
238    /// Render the picker suggestions below current line
239    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                // Selected item - highlighted with arrow
253                println!("  {}▸ /{:<15}{} {}{}{}", 
254                    ansi::PURPLE, cmd.name, ansi::RESET,
255                    ansi::PURPLE, cmd.description, ansi::RESET);
256            } else {
257                // Normal item - dimmed
258                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    /// Clear n lines above cursor
268    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
277/// Show interactive command picker and return selected command
278/// This is called when user types "/" - shows suggestions immediately
279/// Returns None if cancelled, Some(command_name) if selected
280pub fn show_command_picker(initial_filter: &str) -> Option<String> {
281    let mut picker = CommandPicker::new();
282    picker.set_filter(initial_filter);
283    
284    // Enable raw mode for real-time key handling
285    if terminal::enable_raw_mode().is_err() {
286        // Fallback to simple mode if raw mode fails
287        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    // Initial render
295    println!(); // Move to new line for suggestions
296    last_rendered_lines = picker.render_suggestions();
297    
298    // Move back up to input line and position cursor
299    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    // Move down to after suggestions
304    let _ = execute!(stdout, cursor::MoveDown(last_rendered_lines as u16 + 1));
305    
306    let result = loop {
307        // Wait for key event
308        if let Ok(Event::Key(key_event)) = event::read() {
309            match key_event.code {
310                KeyCode::Esc => {
311                    // Cancel
312                    break None;
313                }
314                KeyCode::Enter => {
315                    // Select current
316                    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                        // Backspace on just "/" - cancel
334                        break None;
335                    }
336                }
337                KeyCode::Char(c) => {
338                    // Add character to filter
339                    input_buffer.push(c);
340                    let filter = input_buffer.trim_start_matches('/');
341                    picker.set_filter(filter);
342                    
343                    // If there's an exact match and user typed enough, auto-select
344                    if picker.filtered_commands.len() == 1 {
345                        // Perfect match - could auto-complete
346                    }
347                }
348                KeyCode::Tab => {
349                    // Tab to auto-complete current selection
350                    if let Some(cmd) = picker.selected_command() {
351                        break Some(cmd.name.to_string());
352                    }
353                }
354                _ => {}
355            }
356            
357            // Clear old suggestions and re-render
358            picker.clear_lines(last_rendered_lines);
359            
360            // Re-render input line
361            let _ = execute!(stdout, Clear(ClearType::CurrentLine), MoveToColumn(0));
362            print!("{}You: {}{}", ansi::SUCCESS, ansi::RESET, input_buffer);
363            let _ = stdout.flush();
364            
365            // Render suggestions below
366            println!();
367            last_rendered_lines = picker.render_suggestions();
368            
369            // Move back to input line position
370            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            // Move down to after suggestions for next iteration
375            let _ = execute!(stdout, cursor::MoveDown(last_rendered_lines as u16 + 1));
376        }
377    };
378    
379    // Disable raw mode
380    let _ = terminal::disable_raw_mode();
381    
382    // Clean up display
383    picker.clear_lines(last_rendered_lines);
384    let _ = execute!(stdout, Clear(ClearType::CurrentLine), MoveToColumn(0));
385    let _ = stdout.flush();
386    
387    result
388}
389
390/// Fallback simple picker when raw mode is not available
391fn 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
421/// Check if a command matches a query (name or alias)
422pub 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}