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, MoveToColumn, MoveUp},
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: "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/// Whether a token count is actual (from API) or approximate (estimated)
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
91pub enum TokenCountType {
92    /// Actual count from API response
93    Actual,
94    /// Approximate count estimated from character count (~chars/4)
95    #[default]
96    Approximate,
97}
98
99/// Token usage statistics for /cost command
100/// Tracks actual vs approximate tokens similar to Forge
101#[derive(Debug, Default, Clone)]
102pub struct TokenUsage {
103    /// Total prompt/input tokens
104    pub prompt_tokens: u64,
105    /// Total completion/output tokens
106    pub completion_tokens: u64,
107    /// Cache read tokens (prompt caching)
108    pub cache_read_tokens: u64,
109    /// Cache creation tokens (prompt caching)
110    pub cache_creation_tokens: u64,
111    /// Thinking/reasoning tokens (extended thinking models)
112    pub thinking_tokens: u64,
113    /// Whether the counts are actual or approximate
114    pub count_type: TokenCountType,
115    /// Number of requests made
116    pub request_count: u64,
117    /// Session start time
118    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    /// Add actual tokens from API response
130    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 we have any actual counts, mark as actual
135        if input > 0 || output > 0 {
136            self.count_type = TokenCountType::Actual;
137        }
138    }
139
140    /// Add actual tokens with cache and thinking info
141    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    /// Add estimated tokens (when API doesn't return actual counts)
159    /// Only updates if we don't already have actual counts for this session
160    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        // Keep as Approximate unless we've received actual counts
165    }
166
167    /// Legacy method for compatibility - adds estimated tokens
168    pub fn add_request(&mut self, prompt: u64, completion: u64) {
169        self.add_estimated(prompt, completion);
170    }
171
172    /// Estimate token count from text (rough approximation: ~4 chars per token)
173    /// Matches Forge's approach: char_count.div_ceil(4)
174    pub fn estimate_tokens(text: &str) -> u64 {
175        text.len().div_ceil(4) as u64
176    }
177
178    /// Get total tokens (input + output, excluding cache/thinking)
179    pub fn total_tokens(&self) -> u64 {
180        self.prompt_tokens + self.completion_tokens
181    }
182
183    /// Get total tokens including cache reads (effective context size)
184    pub fn total_with_cache(&self) -> u64 {
185        self.prompt_tokens + self.completion_tokens + self.cache_read_tokens
186    }
187
188    /// Format total tokens for display (with ~ prefix if approximate)
189    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    /// Get a short display string like Forge: "~1.2k" or "15k"
197    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    /// Check if we have cache hits (prompt caching is working)
214    pub fn has_cache_hits(&self) -> bool {
215        self.cache_read_tokens > 0
216    }
217
218    /// Check if we have thinking tokens (extended thinking enabled)
219    pub fn has_thinking(&self) -> bool {
220        self.thinking_tokens > 0
221    }
222
223    /// Get session duration
224    pub fn session_duration(&self) -> std::time::Duration {
225        self.session_start
226            .map(|start| start.elapsed())
227            .unwrap_or_default()
228    }
229
230    /// Estimate cost based on model (rough estimates in USD)
231    /// Returns (input_cost, output_cost, total_cost)
232    pub fn estimate_cost(&self, model: &str) -> (f64, f64, f64) {
233        // Pricing per 1M tokens (as of Dec 2025, approximate)
234        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), // Default to GPT-4o pricing
243        };
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    /// Print cost report
252    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        // Determine accuracy indicator
257        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        // Show cache tokens if present
302        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        // Show thinking tokens if present
319        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        // Show note about accuracy
345        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
361/// Interactive command picker state
362pub struct CommandPicker {
363    /// Current filter text (after the /)
364    pub filter: String,
365    /// Currently selected index
366    pub selected_index: usize,
367    /// Filtered commands
368    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    /// Update filter and refresh filtered commands
387    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        // Reset selection if out of bounds
401        if self.selected_index >= self.filtered_commands.len() {
402            self.selected_index = 0;
403        }
404    }
405
406    /// Move selection up
407    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    /// Move selection down  
414    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    /// Get currently selected command
423    pub fn selected_command(&self) -> Option<&'static SlashCommand> {
424        self.filtered_commands.get(self.selected_index).copied()
425    }
426
427    /// Render the picker suggestions below current line
428    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                // Selected item - highlighted with arrow
442                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                // Normal item - dimmed
453                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    /// Clear n lines above cursor
468    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
477/// Show interactive command picker and return selected command
478/// This is called when user types "/" - shows suggestions immediately
479/// Returns None if cancelled, Some(command_name) if selected
480pub fn show_command_picker(initial_filter: &str) -> Option<String> {
481    let mut picker = CommandPicker::new();
482    picker.set_filter(initial_filter);
483
484    // Enable raw mode for real-time key handling
485    if terminal::enable_raw_mode().is_err() {
486        // Fallback to simple mode if raw mode fails
487        return show_simple_picker(&picker);
488    }
489
490    let mut stdout = io::stdout();
491    let mut input_buffer = format!("/{}", initial_filter);
492
493    // Initial render
494    println!(); // Move to new line for suggestions
495    let mut last_rendered_lines = picker.render_suggestions();
496
497    // Move back up to input line and position cursor
498    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    // Move down to after suggestions
507    let _ = execute!(stdout, cursor::MoveDown(last_rendered_lines as u16 + 1));
508
509    let result = loop {
510        // Wait for key event
511        if let Ok(Event::Key(key_event)) = event::read() {
512            match key_event.code {
513                KeyCode::Esc => {
514                    // Cancel
515                    break None;
516                }
517                KeyCode::Enter => {
518                    // Select current
519                    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                        // Backspace on just "/" - cancel
537                        break None;
538                    }
539                }
540                KeyCode::Char(c) => {
541                    // Add character to filter
542                    input_buffer.push(c);
543                    let filter = input_buffer.trim_start_matches('/');
544                    picker.set_filter(filter);
545
546                    // If there's an exact match and user typed enough, auto-select
547                    if picker.filtered_commands.len() == 1 {
548                        // Perfect match - could auto-complete
549                    }
550                }
551                KeyCode::Tab => {
552                    // Tab to auto-complete current selection
553                    if let Some(cmd) = picker.selected_command() {
554                        break Some(cmd.name.to_string());
555                    }
556                }
557                _ => {}
558            }
559
560            // Clear old suggestions and re-render
561            picker.clear_lines(last_rendered_lines);
562
563            // Re-render input line
564            let _ = execute!(stdout, Clear(ClearType::CurrentLine), MoveToColumn(0));
565            print!("{}You: {}{}", ansi::SUCCESS, ansi::RESET, input_buffer);
566            let _ = stdout.flush();
567
568            // Render suggestions below
569            println!();
570            last_rendered_lines = picker.render_suggestions();
571
572            // Move back to input line position
573            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            // Move down to after suggestions for next iteration
578            let _ = execute!(stdout, cursor::MoveDown(last_rendered_lines as u16 + 1));
579        }
580    };
581
582    // Disable raw mode
583    let _ = terminal::disable_raw_mode();
584
585    // Clean up display
586    picker.clear_lines(last_rendered_lines);
587    let _ = execute!(stdout, Clear(ClearType::CurrentLine), MoveToColumn(0));
588    let _ = stdout.flush();
589
590    result
591}
592
593/// Fallback simple picker when raw mode is not available
594fn 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
634/// Check if a command matches a query (name or alias)
635pub 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}