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: "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/// Whether a token count is actual (from API) or approximate (estimated)
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
103pub enum TokenCountType {
104    /// Actual count from API response
105    Actual,
106    /// Approximate count estimated from character count (~chars/4)
107    #[default]
108    Approximate,
109}
110
111/// Token usage statistics for /cost command
112/// Tracks actual vs approximate tokens similar to Forge
113#[derive(Debug, Default, Clone)]
114pub struct TokenUsage {
115    /// Total prompt/input tokens
116    pub prompt_tokens: u64,
117    /// Total completion/output tokens
118    pub completion_tokens: u64,
119    /// Cache read tokens (prompt caching)
120    pub cache_read_tokens: u64,
121    /// Cache creation tokens (prompt caching)
122    pub cache_creation_tokens: u64,
123    /// Thinking/reasoning tokens (extended thinking models)
124    pub thinking_tokens: u64,
125    /// Whether the counts are actual or approximate
126    pub count_type: TokenCountType,
127    /// Number of requests made
128    pub request_count: u64,
129    /// Session start time
130    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    /// Add actual tokens from API response
142    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 we have any actual counts, mark as actual
147        if input > 0 || output > 0 {
148            self.count_type = TokenCountType::Actual;
149        }
150    }
151
152    /// Add actual tokens with cache and thinking info
153    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    /// Add estimated tokens (when API doesn't return actual counts)
171    /// Only updates if we don't already have actual counts for this session
172    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        // Keep as Approximate unless we've received actual counts
177    }
178
179    /// Legacy method for compatibility - adds estimated tokens
180    pub fn add_request(&mut self, prompt: u64, completion: u64) {
181        self.add_estimated(prompt, completion);
182    }
183
184    /// Estimate token count from text (rough approximation: ~4 chars per token)
185    /// Matches Forge's approach: char_count.div_ceil(4)
186    pub fn estimate_tokens(text: &str) -> u64 {
187        text.len().div_ceil(4) as u64
188    }
189
190    /// Get total tokens (input + output, excluding cache/thinking)
191    pub fn total_tokens(&self) -> u64 {
192        self.prompt_tokens + self.completion_tokens
193    }
194
195    /// Get total tokens including cache reads (effective context size)
196    pub fn total_with_cache(&self) -> u64 {
197        self.prompt_tokens + self.completion_tokens + self.cache_read_tokens
198    }
199
200    /// Format total tokens for display (with ~ prefix if approximate)
201    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    /// Get a short display string like Forge: "~1.2k" or "15k"
209    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    /// Check if we have cache hits (prompt caching is working)
226    pub fn has_cache_hits(&self) -> bool {
227        self.cache_read_tokens > 0
228    }
229
230    /// Check if we have thinking tokens (extended thinking enabled)
231    pub fn has_thinking(&self) -> bool {
232        self.thinking_tokens > 0
233    }
234
235    /// Get session duration
236    pub fn session_duration(&self) -> std::time::Duration {
237        self.session_start
238            .map(|start| start.elapsed())
239            .unwrap_or_default()
240    }
241
242    /// Estimate cost based on model (rough estimates in USD)
243    /// Returns (input_cost, output_cost, total_cost)
244    pub fn estimate_cost(&self, model: &str) -> (f64, f64, f64) {
245        // Pricing per 1M tokens (as of Dec 2025, approximate)
246        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), // Default to GPT-4o pricing
255        };
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    /// Print cost report
264    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        // Determine accuracy indicator
269        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        // Show cache tokens if present
314        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        // Show thinking tokens if present
331        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        // Show note about accuracy
357        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
373/// Interactive command picker state
374pub struct CommandPicker {
375    /// Current filter text (after the /)
376    pub filter: String,
377    /// Currently selected index
378    pub selected_index: usize,
379    /// Filtered commands
380    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    /// Update filter and refresh filtered commands
399    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        // Reset selection if out of bounds
413        if self.selected_index >= self.filtered_commands.len() {
414            self.selected_index = 0;
415        }
416    }
417
418    /// Move selection up
419    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    /// Move selection down  
426    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    /// Get currently selected command
435    pub fn selected_command(&self) -> Option<&'static SlashCommand> {
436        self.filtered_commands.get(self.selected_index).copied()
437    }
438
439    /// Render the picker suggestions below current line
440    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                // Selected item - highlighted with arrow
454                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                // Normal item - dimmed
465                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    /// Clear n lines above cursor
480    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
489/// Show interactive command picker and return selected command
490/// This is called when user types "/" - shows suggestions immediately
491/// Returns None if cancelled, Some(command_name) if selected
492pub fn show_command_picker(initial_filter: &str) -> Option<String> {
493    let mut picker = CommandPicker::new();
494    picker.set_filter(initial_filter);
495
496    // Enable raw mode for real-time key handling
497    if terminal::enable_raw_mode().is_err() {
498        // Fallback to simple mode if raw mode fails
499        return show_simple_picker(&picker);
500    }
501
502    let mut stdout = io::stdout();
503    let mut input_buffer = format!("/{}", initial_filter);
504
505    // Initial render
506    println!(); // Move to new line for suggestions
507    let mut last_rendered_lines = picker.render_suggestions();
508
509    // Move back up to input line and position cursor
510    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    // Move down to after suggestions
519    let _ = execute!(stdout, cursor::MoveDown(last_rendered_lines as u16 + 1));
520
521    let result = loop {
522        // Wait for key event
523        if let Ok(Event::Key(key_event)) = event::read() {
524            match key_event.code {
525                KeyCode::Esc => {
526                    // Cancel
527                    break None;
528                }
529                KeyCode::Enter => {
530                    // Select current
531                    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                        // Backspace on just "/" - cancel
549                        break None;
550                    }
551                }
552                KeyCode::Char(c) => {
553                    // Add character to filter
554                    input_buffer.push(c);
555                    let filter = input_buffer.trim_start_matches('/');
556                    picker.set_filter(filter);
557
558                    // If there's an exact match and user typed enough, auto-select
559                    if picker.filtered_commands.len() == 1 {
560                        // Perfect match - could auto-complete
561                    }
562                }
563                KeyCode::Tab => {
564                    // Tab to auto-complete current selection
565                    if let Some(cmd) = picker.selected_command() {
566                        break Some(cmd.name.to_string());
567                    }
568                }
569                _ => {}
570            }
571
572            // Clear old suggestions and re-render
573            picker.clear_lines(last_rendered_lines);
574
575            // Re-render input line
576            let _ = execute!(stdout, Clear(ClearType::CurrentLine), MoveToColumn(0));
577            print!("{}You: {}{}", ansi::SUCCESS, ansi::RESET, input_buffer);
578            let _ = stdout.flush();
579
580            // Render suggestions below
581            println!();
582            last_rendered_lines = picker.render_suggestions();
583
584            // Move back to input line position
585            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            // Move down to after suggestions for next iteration
590            let _ = execute!(stdout, cursor::MoveDown(last_rendered_lines as u16 + 1));
591        }
592    };
593
594    // Disable raw mode
595    let _ = terminal::disable_raw_mode();
596
597    // Clean up display
598    picker.clear_lines(last_rendered_lines);
599    let _ = execute!(stdout, Clear(ClearType::CurrentLine), MoveToColumn(0));
600    let _ = stdout.flush();
601
602    result
603}
604
605/// Fallback simple picker when raw mode is not available
606fn 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
646/// Check if a command matches a query (name or alias)
647pub 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}