mixtape_cli/repl/
commands.rs

1use crate::error::CliError;
2use mixtape_core::Agent;
3use std::sync::{Arc, Mutex};
4use tokio::io::{AsyncBufReadExt, BufReader};
5use tokio::process::Command;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Verbosity {
9    Quiet,
10    Normal,
11    Verbose,
12}
13
14impl Verbosity {
15    /// Parse a verbosity level from a string
16    ///
17    /// Returns Some(Verbosity) for valid inputs, None for invalid.
18    pub fn parse(s: &str) -> Option<Self> {
19        match s {
20            "quiet" => Some(Self::Quiet),
21            "normal" => Some(Self::Normal),
22            "verbose" => Some(Self::Verbose),
23            _ => None,
24        }
25    }
26}
27
28/// Classify an input line as a special command type
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum CommandType<'a> {
31    /// Shell command starting with !
32    Shell(&'a str),
33    /// Slash command with name and arguments
34    Slash {
35        command: &'a str,
36        args: Vec<&'a str>,
37    },
38    /// Regular input to send to agent
39    Regular,
40}
41
42impl<'a> CommandType<'a> {
43    /// Parse an input line into a command type
44    pub fn parse(input: &'a str) -> Self {
45        if let Some(shell_cmd) = input.strip_prefix('!') {
46            return Self::Shell(shell_cmd);
47        }
48
49        if input.starts_with('/') {
50            let parts: Vec<&str> = input.split_whitespace().collect();
51            if !parts.is_empty() {
52                return Self::Slash {
53                    command: parts[0],
54                    args: parts[1..].to_vec(),
55                };
56            }
57        }
58
59        Self::Regular
60    }
61}
62
63pub enum SpecialCommandResult {
64    Exit,
65    Continue,
66}
67
68/// Handle special commands (! and /)
69///
70/// Returns Some(result) if this was a special command,
71/// None if it should be sent to the agent.
72pub async fn handle_special_command(
73    input: &str,
74    agent: &Agent,
75    verbosity: &Arc<Mutex<Verbosity>>,
76) -> Result<Option<SpecialCommandResult>, CliError> {
77    match CommandType::parse(input) {
78        CommandType::Shell(shell_cmd) => {
79            execute_shell_command(shell_cmd).await?;
80            Ok(Some(SpecialCommandResult::Continue))
81        }
82        CommandType::Slash { command, args } => {
83            let args = args.as_slice();
84            match command {
85                "/exit" | "/quit" => Ok(Some(SpecialCommandResult::Exit)),
86                "/help" => {
87                    show_help();
88                    Ok(Some(SpecialCommandResult::Continue))
89                }
90                "/tools" => {
91                    show_tools(agent);
92                    Ok(Some(SpecialCommandResult::Continue))
93                }
94                "/history" => {
95                    show_history(agent, args).await?;
96                    Ok(Some(SpecialCommandResult::Continue))
97                }
98                "/clear" => {
99                    clear_session(agent).await?;
100                    Ok(Some(SpecialCommandResult::Continue))
101                }
102                "/verbosity" => {
103                    update_verbosity(verbosity, args);
104                    Ok(Some(SpecialCommandResult::Continue))
105                }
106                "/session" => {
107                    show_session_info(agent).await?;
108                    Ok(Some(SpecialCommandResult::Continue))
109                }
110                _ => {
111                    eprintln!(
112                        "Unknown command: {}. Type /help for available commands.",
113                        command
114                    );
115                    Ok(Some(SpecialCommandResult::Continue))
116                }
117            }
118        }
119        CommandType::Regular => Ok(None),
120    }
121}
122
123async fn execute_shell_command(cmd: &str) -> Result<(), CliError> {
124    println!("\nšŸ’» Executing: {}\n", cmd);
125
126    let mut child = Command::new("sh")
127        .arg("-c")
128        .arg(cmd)
129        .stdout(std::process::Stdio::piped())
130        .stderr(std::process::Stdio::piped())
131        .spawn()?;
132
133    // Stream stdout
134    if let Some(stdout) = child.stdout.take() {
135        let reader = BufReader::new(stdout);
136        let mut lines = reader.lines();
137
138        while let Some(line) = lines.next_line().await? {
139            println!("{}", line);
140        }
141    }
142
143    // Stream stderr
144    if let Some(stderr) = child.stderr.take() {
145        let reader = BufReader::new(stderr);
146        let mut lines = reader.lines();
147
148        while let Some(line) = lines.next_line().await? {
149            eprintln!("{}", line);
150        }
151    }
152
153    let status = child.wait().await?;
154
155    if !status.success() {
156        eprintln!("\nāŒ Command exited with status: {}", status);
157    }
158
159    println!();
160    Ok(())
161}
162
163async fn clear_session(agent: &Agent) -> Result<(), CliError> {
164    agent.clear_session().await?;
165    println!("Session cleared.");
166    Ok(())
167}
168
169/// Help text sections for the CLI
170pub mod help {
171    /// Header for the help display
172    pub const HEADER: &str = "\nšŸ“– Available Commands:\n";
173
174    /// Shell commands section
175    pub const SHELL_COMMANDS: &str = "\
176Shell Commands:
177  !<command>        Execute shell command and stream output
178  Example: !ls -la
179";
180
181    /// Navigation commands section
182    pub const NAVIGATION: &str = "\
183Navigation:
184  /help             Show this help message
185  /tools            List all available tools
186  /history [n]      Show last n messages (default: 10)
187  /clear            Clear current session history
188  /verbosity [level]  Set output verbosity (quiet|normal|verbose)
189";
190
191    /// Session management section
192    pub const SESSION: &str = "\
193Session Management:
194  /session          Show current session info
195";
196
197    /// Exit commands section
198    pub const EXIT: &str = "\
199Exit:
200  /exit, /quit      Exit and save session
201  Ctrl+C            Interrupt current operation
202  Ctrl+D            Exit
203";
204
205    /// Keyboard shortcuts section
206    pub const KEYBOARD: &str = "\
207Keyboard Shortcuts:
208  Up/Down           Navigate command history
209  Ctrl+R            Reverse search history
210  Ctrl+C            Interrupt (doesn't exit)
211  Ctrl+D            Exit
212";
213
214    /// Get the complete help text
215    pub fn full_text() -> String {
216        format!(
217            "{}{}\n{}\n{}\n{}\n{}",
218            HEADER, SHELL_COMMANDS, NAVIGATION, SESSION, EXIT, KEYBOARD
219        )
220    }
221}
222
223fn show_help() {
224    print!("{}", help::full_text());
225}
226
227/// Tool info for display purposes
228pub struct ToolDisplay {
229    pub name: String,
230    pub description: String,
231}
232
233/// Format a list of tools for display
234pub fn format_tool_list(tools: &[ToolDisplay]) -> String {
235    let mut output = String::from("\nšŸ”§ Available Tools:\n\n");
236
237    if tools.is_empty() {
238        output.push_str("  No tools configured\n");
239    } else {
240        for tool in tools {
241            output.push_str(&format!("  {} - {}\n", tool.name, tool.description));
242        }
243    }
244
245    output
246}
247
248fn show_tools(agent: &Agent) {
249    let tools: Vec<ToolDisplay> = agent
250        .list_tools()
251        .into_iter()
252        .map(|t| ToolDisplay {
253            name: t.name.clone(),
254            description: t.description.clone(),
255        })
256        .collect();
257
258    print!("{}", format_tool_list(&tools));
259}
260
261fn update_verbosity(verbosity: &Arc<Mutex<Verbosity>>, args: &[&str]) {
262    if args.is_empty() {
263        let current = *verbosity.lock().unwrap();
264        println!("Verbosity: {:?}", current);
265        return;
266    }
267
268    match Verbosity::parse(args[0]) {
269        Some(level) => {
270            *verbosity.lock().unwrap() = level;
271            println!("Verbosity set to {:?}", level);
272        }
273        None => {
274            println!(
275                "Unknown verbosity level: {} (quiet|normal|verbose)",
276                args[0]
277            );
278        }
279    }
280}
281
282async fn show_history(agent: &Agent, args: &[&str]) -> Result<(), CliError> {
283    let limit: usize = args.first().and_then(|s| s.parse().ok()).unwrap_or(10);
284
285    let history = agent.get_session_history(limit).await?;
286
287    if history.is_empty() {
288        println!("\nNo conversation history yet.\n");
289    } else {
290        println!("\nšŸ“œ Conversation History (last {}):\n", limit);
291        for (idx, msg) in history.iter().enumerate() {
292            let role = match msg.role {
293                mixtape_core::MessageRole::User => "User",
294                mixtape_core::MessageRole::Assistant => "Assistant",
295                mixtape_core::MessageRole::System => "System",
296            };
297
298            let content = if msg.content.len() > 100 {
299                format!("{}...", &msg.content[..100])
300            } else {
301                msg.content.clone()
302            };
303
304            if msg.role == mixtape_core::MessageRole::User {
305                println!("{}", user_input_margin_line());
306                println!(
307                    "{}",
308                    user_input_line(&format!("{}. {}: {}", idx + 1, role, content))
309                );
310                println!("{}", user_input_margin_line());
311            } else {
312                println!("{}. {}: {}", idx + 1, role, content);
313            }
314        }
315        println!();
316    }
317
318    Ok(())
319}
320
321fn user_input_margin_line() -> &'static str {
322    "\x1b[48;5;236m\x1b[2K\x1b[0m"
323}
324
325fn user_input_line(text: &str) -> String {
326    format!("\x1b[48;5;236m  {}{}\x1b[0m", text, "\x1b[0K")
327}
328
329async fn show_session_info(agent: &Agent) -> Result<(), CliError> {
330    let usage = agent.get_context_usage();
331
332    println!("\nšŸ“Š Session Info:\n");
333
334    if let Some(info) = agent.get_session_info().await? {
335        let short_id = &info.id[..8.min(info.id.len())];
336        println!("  Session:  {}", short_id);
337        println!("  Messages: {}", info.message_count);
338    } else {
339        println!("  Session:  (memory only)");
340        println!("  Messages: {}", usage.total_messages);
341    }
342
343    println!(
344        "  Context:  {:.1}k / {}k tokens ({}%)",
345        usage.context_tokens as f64 / 1000.0,
346        usage.max_context_tokens / 1000,
347        (usage.usage_percentage * 100.0) as u32
348    );
349    println!();
350
351    Ok(())
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    mod verbosity_parse_tests {
359        use super::*;
360
361        #[test]
362        fn parses_quiet() {
363            assert_eq!(Verbosity::parse("quiet"), Some(Verbosity::Quiet));
364        }
365
366        #[test]
367        fn parses_normal() {
368            assert_eq!(Verbosity::parse("normal"), Some(Verbosity::Normal));
369        }
370
371        #[test]
372        fn parses_verbose() {
373            assert_eq!(Verbosity::parse("verbose"), Some(Verbosity::Verbose));
374        }
375
376        #[test]
377        fn rejects_invalid() {
378            assert_eq!(Verbosity::parse("invalid"), None);
379            assert_eq!(Verbosity::parse("QUIET"), None); // case sensitive
380            assert_eq!(Verbosity::parse(""), None);
381            assert_eq!(Verbosity::parse("q"), None);
382        }
383    }
384
385    mod command_type_parse_tests {
386        use super::*;
387
388        #[test]
389        fn shell_command() {
390            let cmd = CommandType::parse("!ls -la");
391            assert_eq!(cmd, CommandType::Shell("ls -la"));
392        }
393
394        #[test]
395        fn shell_command_empty() {
396            let cmd = CommandType::parse("!");
397            assert_eq!(cmd, CommandType::Shell(""));
398        }
399
400        #[test]
401        fn slash_command_no_args() {
402            let cmd = CommandType::parse("/help");
403            assert_eq!(
404                cmd,
405                CommandType::Slash {
406                    command: "/help",
407                    args: vec![]
408                }
409            );
410        }
411
412        #[test]
413        fn slash_command_with_args() {
414            let cmd = CommandType::parse("/verbosity quiet");
415            assert_eq!(
416                cmd,
417                CommandType::Slash {
418                    command: "/verbosity",
419                    args: vec!["quiet"]
420                }
421            );
422        }
423
424        #[test]
425        fn slash_command_multiple_args() {
426            let cmd = CommandType::parse("/history 10 20");
427            assert_eq!(
428                cmd,
429                CommandType::Slash {
430                    command: "/history",
431                    args: vec!["10", "20"]
432                }
433            );
434        }
435
436        #[test]
437        fn regular_input() {
438            let cmd = CommandType::parse("hello world");
439            assert_eq!(cmd, CommandType::Regular);
440        }
441
442        #[test]
443        fn regular_input_empty() {
444            let cmd = CommandType::parse("");
445            assert_eq!(cmd, CommandType::Regular);
446        }
447
448        #[test]
449        fn regular_input_with_slash_in_middle() {
450            let cmd = CommandType::parse("path/to/file");
451            assert_eq!(cmd, CommandType::Regular);
452        }
453
454        #[test]
455        fn regular_input_with_exclamation_in_middle() {
456            let cmd = CommandType::parse("hello! world");
457            assert_eq!(cmd, CommandType::Regular);
458        }
459    }
460
461    mod user_input_formatting_tests {
462        use super::*;
463
464        #[test]
465        fn margin_line_has_ansi_codes() {
466            let line = user_input_margin_line();
467            assert!(line.contains("\x1b[48;5;236m"));
468            assert!(line.contains("\x1b[2K"));
469        }
470
471        #[test]
472        fn input_line_wraps_text() {
473            let line = user_input_line("hello");
474            assert!(line.contains("hello"));
475            assert!(line.starts_with("\x1b[48;5;236m"));
476            assert!(line.ends_with("\x1b[0m"));
477        }
478    }
479
480    mod help_text_tests {
481        use super::*;
482
483        #[test]
484        fn header_has_emoji() {
485            assert!(help::HEADER.contains("šŸ“–"));
486        }
487
488        #[test]
489        fn shell_commands_documents_bang_syntax() {
490            assert!(help::SHELL_COMMANDS.contains("!<command>"));
491            assert!(help::SHELL_COMMANDS.contains("!ls"));
492        }
493
494        #[test]
495        fn navigation_lists_all_slash_commands() {
496            assert!(help::NAVIGATION.contains("/help"));
497            assert!(help::NAVIGATION.contains("/tools"));
498            assert!(help::NAVIGATION.contains("/history"));
499            assert!(help::NAVIGATION.contains("/clear"));
500            assert!(help::NAVIGATION.contains("/verbosity"));
501        }
502
503        #[test]
504        fn session_documents_session_command() {
505            assert!(help::SESSION.contains("/session"));
506        }
507
508        #[test]
509        fn exit_documents_exit_commands() {
510            assert!(help::EXIT.contains("/exit"));
511            assert!(help::EXIT.contains("/quit"));
512            assert!(help::EXIT.contains("Ctrl+C"));
513            assert!(help::EXIT.contains("Ctrl+D"));
514        }
515
516        #[test]
517        fn keyboard_documents_shortcuts() {
518            assert!(help::KEYBOARD.contains("Up/Down"));
519            assert!(help::KEYBOARD.contains("Ctrl+R"));
520        }
521
522        #[test]
523        fn full_text_contains_all_sections() {
524            let full = help::full_text();
525            assert!(full.contains("šŸ“–"));
526            assert!(full.contains("!<command>"));
527            assert!(full.contains("/help"));
528            assert!(full.contains("/session"));
529            assert!(full.contains("/exit"));
530            assert!(full.contains("Up/Down"));
531        }
532    }
533
534    mod format_tool_list_tests {
535        use super::*;
536
537        #[test]
538        fn empty_list_shows_no_tools_message() {
539            let output = format_tool_list(&[]);
540            assert!(output.contains("No tools configured"));
541        }
542
543        #[test]
544        fn single_tool_formatted() {
545            let tools = vec![ToolDisplay {
546                name: "read_file".to_string(),
547                description: "Read a file".to_string(),
548            }];
549            let output = format_tool_list(&tools);
550            assert!(output.contains("read_file - Read a file"));
551        }
552
553        #[test]
554        fn multiple_tools_formatted() {
555            let tools = vec![
556                ToolDisplay {
557                    name: "read_file".to_string(),
558                    description: "Read a file".to_string(),
559                },
560                ToolDisplay {
561                    name: "write_file".to_string(),
562                    description: "Write a file".to_string(),
563                },
564            ];
565            let output = format_tool_list(&tools);
566            assert!(output.contains("read_file - Read a file"));
567            assert!(output.contains("write_file - Write a file"));
568        }
569
570        #[test]
571        fn header_has_emoji() {
572            let output = format_tool_list(&[]);
573            assert!(output.contains("šŸ”§"));
574            assert!(output.contains("Available Tools"));
575        }
576
577        #[test]
578        fn tools_are_indented() {
579            let tools = vec![ToolDisplay {
580                name: "test".to_string(),
581                description: "Test tool".to_string(),
582            }];
583            let output = format_tool_list(&tools);
584            assert!(output.contains("  test"));
585        }
586    }
587}