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 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#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum CommandType<'a> {
31 Shell(&'a str),
33 Slash {
35 command: &'a str,
36 args: Vec<&'a str>,
37 },
38 Regular,
40}
41
42impl<'a> CommandType<'a> {
43 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
68pub 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 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 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
169pub mod help {
171 pub const HEADER: &str = "\nš Available Commands:\n";
173
174 pub const SHELL_COMMANDS: &str = "\
176Shell Commands:
177 !<command> Execute shell command and stream output
178 Example: !ls -la
179";
180
181 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 pub const SESSION: &str = "\
193Session Management:
194 /session Show current session info
195";
196
197 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 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 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
227pub struct ToolDisplay {
229 pub name: String,
230 pub description: String,
231}
232
233pub 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); 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}