From 0d3d9ca5aea38aff88f5c9a191320f098f37e7d3 Mon Sep 17 00:00:00 2001 From: Kalvin C Date: Fri, 28 Feb 2025 10:09:27 -0800 Subject: [PATCH] feat(cli): add tab based slash command completion and prompt info completion (#1436) --- crates/goose-cli/src/session/completion.rs | 553 +++++++++++++++++++++ crates/goose-cli/src/session/input.rs | 51 +- crates/goose-cli/src/session/mod.rs | 118 ++++- crates/goose-cli/src/session/output.rs | 2 +- 4 files changed, 711 insertions(+), 13 deletions(-) create mode 100644 crates/goose-cli/src/session/completion.rs diff --git a/crates/goose-cli/src/session/completion.rs b/crates/goose-cli/src/session/completion.rs new file mode 100644 index 00000000..5230a70e --- /dev/null +++ b/crates/goose-cli/src/session/completion.rs @@ -0,0 +1,553 @@ +use rustyline::completion::{Completer, Pair}; +use rustyline::highlight::{CmdKind, Highlighter}; +use rustyline::hint::Hinter; +use rustyline::validate::Validator; +use rustyline::{Helper, Result}; +use std::borrow::Cow; +use std::sync::Arc; + +use super::CompletionCache; + +/// Completer for Goose CLI commands +pub struct GooseCompleter { + completion_cache: Arc>, +} + +impl GooseCompleter { + /// Create a new GooseCompleter with a reference to the Session's completion cache + pub fn new(completion_cache: Arc>) -> Self { + Self { completion_cache } + } + + /// Complete prompt names for the /prompt command + fn complete_prompt_names(&self, line: &str) -> Result<(usize, Vec)> { + // Get the prefix of the prompt name being typed + let prefix = if line.len() > 8 { &line[8..] } else { "" }; + + // Get available prompts from cache + let cache = self.completion_cache.read().unwrap(); + + // Create completion candidates that match the prefix + let candidates: Vec = cache + .prompts + .iter() + .flat_map(|(_, names)| names) + .filter(|name| name.starts_with(prefix.trim())) + .map(|name| Pair { + display: name.clone(), + replacement: name.clone(), + }) + .collect(); + + Ok((8, candidates)) + } + + /// Complete flags for the /prompt command + fn complete_prompt_flags(&self, line: &str) -> Result<(usize, Vec)> { + // Get the last part of the line + let parts: Vec<&str> = line.split_whitespace().collect(); + if let Some(last_part) = parts.last() { + // If the last part starts with '-', it might be a partial flag + if last_part.starts_with('-') { + // Define available flags + let flags = ["--info"]; + + // Find flags that match the prefix + let matching_flags: Vec = flags + .iter() + .filter(|flag| flag.starts_with(last_part)) + .map(|flag| Pair { + display: flag.to_string(), + replacement: flag.to_string(), + }) + .collect(); + + if !matching_flags.is_empty() { + // Return matches for the partial flag + // The position is the start of the last word + let pos = line.len() - last_part.len(); + return Ok((pos, matching_flags)); + } + } + } + + // No flag completions available + Ok((line.len(), vec![])) + } + + /// Complete slash commands + fn complete_slash_commands(&self, line: &str) -> Result<(usize, Vec)> { + // Define available slash commands + let commands = [ + "/exit", + "/quit", + "/help", + "/?", + "/t", + "/extension", + "/builtin", + "/prompts", + "/prompt", + ]; + + // Find commands that match the prefix + let matching_commands: Vec = commands + .iter() + .filter(|cmd| cmd.starts_with(line)) + .map(|cmd| Pair { + display: cmd.to_string(), + replacement: format!("{} ", cmd), // Add a space after the command + }) + .collect(); + + if !matching_commands.is_empty() { + return Ok((0, matching_commands)); + } + + // No command completions available + Ok((line.len(), vec![])) + } + + /// Complete argument keys for a specific prompt + fn complete_argument_keys(&self, line: &str) -> Result<(usize, Vec)> { + let parts: Vec<&str> = line[8..].split_whitespace().collect(); + + // We need at least the prompt name + if parts.is_empty() { + return Ok((line.len(), vec![])); + } + + let prompt_name = parts[0]; + + // Get prompt info from cache + let cache = self.completion_cache.read().unwrap(); + let prompt_info = cache.prompt_info.get(prompt_name).cloned(); + + if let Some(info) = prompt_info { + if let Some(args) = info.arguments { + // Find required arguments that haven't been provided yet + let existing_args: Vec<&str> = parts + .iter() + .skip(1) + .filter_map(|part| { + if part.contains('=') { + Some(part.split('=').next().unwrap()) + } else { + None + } + }) + .collect(); + + // Check if we're trying to complete a partial argument name + if let Some(last_part) = parts.last() { + // ignore if last_part starts with = / \ for suggestions + if let Some(c) = last_part.chars().next() { + if matches!(c, '=' | '/' | '\\') { + return Ok((line.len(), vec![])); + } + } + + // If the last part doesn't contain '=', it might be a partial argument name + if !last_part.contains('=') { + // Find arguments that match the prefix + let matching_args: Vec = args + .iter() + .filter(|arg| { + arg.name.starts_with(last_part) + && !existing_args.contains(&arg.name.as_str()) + }) + .map(|arg| Pair { + display: format!("{}=", arg.name), + replacement: format!("{}=", arg.name), + }) + .collect(); + + if !matching_args.is_empty() { + // Return matches for the partial argument name + // The position is the start of the last word + let pos = line.len() - last_part.len(); + return Ok((pos, matching_args)); + } + + // If we have a partial argument that doesn't match anything, + // return an empty list rather than suggesting unrelated arguments + if !last_part.is_empty() && *last_part != prompt_name { + return Ok((line.len(), vec![])); + } + } + } + + // If no partial match or no last part, suggest all required arguments + // Use a reference to avoid moving args + let mut candidates: Vec<_> = Vec::new(); + for arg in &args { + if arg.required.unwrap_or(false) && !existing_args.contains(&arg.name.as_str()) + { + candidates.push(Pair { + display: format!("{}=", arg.name), + replacement: format!("{}=", arg.name), + }); + } + } + + if !candidates.is_empty() { + return Ok((line.len(), candidates)); + } + + // If no required arguments left, suggest all optional ones + // Use a reference to avoid moving args + for arg in &args { + if !arg.required.unwrap_or(true) && !existing_args.contains(&arg.name.as_str()) + { + candidates.push(Pair { + display: format!("{}=", arg.name), + replacement: format!("{}=", arg.name), + }); + } + } + return Ok((line.len(), candidates)); + } + } + + // No completions available + Ok((line.len(), vec![])) + } +} + +impl Completer for GooseCompleter { + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &rustyline::Context<'_>, + ) -> Result<(usize, Vec)> { + // If the cursor is not at the end of the line, don't try to complete + if pos < line.len() { + return Ok((pos, vec![])); + } + + // If the line starts with '/', it might be a slash command + if line.starts_with('/') { + // If it's just a partial slash command (no space yet) + if !line.contains(' ') { + return self.complete_slash_commands(line); + } + + // Handle /prompt command + if line.starts_with("/prompt") { + // If we're just after "/prompt" with or without a space + if line == "/prompt" || line == "/prompt " { + return self.complete_prompt_names(line); + } + + // Get the parts of the command + let parts: Vec<&str> = line.split_whitespace().collect(); + + // If we're typing a prompt name (only one part after /prompt) + if parts.len() == 2 && !line.ends_with(' ') { + return self.complete_prompt_names(line); + } + + // Check if we might be typing a flag + if let Some(last_part) = parts.last() { + if last_part.starts_with('-') { + return self.complete_prompt_flags(line); + } + } + + // If we have a prompt name and need argument completion + if parts.len() >= 2 { + return self.complete_argument_keys(line); + } + } + + // Handle /prompts command + if line.starts_with("/prompts") { + // If we're just after "/prompts" with a space + if line == "/prompts " { + // Suggest the --extension flag + return Ok(( + line.len(), + vec![Pair { + display: "--extension".to_string(), + replacement: "--extension ".to_string(), + }], + )); + } + + // Check if we might be typing the --extension flag + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() == 2 + && parts[1].starts_with('-') + && "--extension".starts_with(parts[1]) + { + return Ok(( + line.len() - parts[1].len(), + vec![Pair { + display: "--extension".to_string(), + replacement: "--extension ".to_string(), + }], + )); + } + } + } + + // Default: no completions + Ok((pos, vec![])) + } +} + +// Implement the Helper trait which is required by rustyline +impl Helper for GooseCompleter {} + +// Implement required traits with default implementations +impl Hinter for GooseCompleter { + type Hint = String; + + fn hint(&self, _line: &str, _pos: usize, _ctx: &rustyline::Context<'_>) -> Option { + None + } +} + +impl Highlighter for GooseCompleter { + fn highlight_prompt<'b, 's: 'b, 'p: 'b>( + &'s self, + prompt: &'p str, + _default: bool, + ) -> Cow<'b, str> { + Cow::Borrowed(prompt) + } + + fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> { + Cow::Borrowed(hint) + } + + fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> { + Cow::Borrowed(line) + } + + fn highlight_char(&self, _line: &str, _pos: usize, _cmd_kind: CmdKind) -> bool { + false + } +} + +impl Validator for GooseCompleter { + fn validate( + &self, + _ctx: &mut rustyline::validate::ValidationContext, + ) -> rustyline::Result { + Ok(rustyline::validate::ValidationResult::Valid(None)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::session::output; + use mcp_core::prompt::PromptArgument; + use std::sync::{Arc, RwLock}; + + // Helper function to create a test completion cache + fn create_test_cache() -> Arc> { + let mut cache = CompletionCache::new(); + + // Add some test prompts + let mut extension1_prompts = Vec::new(); + extension1_prompts.push("test_prompt1".to_string()); + extension1_prompts.push("test_prompt2".to_string()); + cache + .prompts + .insert("extension1".to_string(), extension1_prompts); + + let mut extension2_prompts = Vec::new(); + extension2_prompts.push("other_prompt".to_string()); + cache + .prompts + .insert("extension2".to_string(), extension2_prompts); + + // Add prompt info with arguments + let test_prompt1_args = vec![ + PromptArgument { + name: "required_arg".to_string(), + description: Some("A required argument".to_string()), + required: Some(true), + }, + PromptArgument { + name: "optional_arg".to_string(), + description: Some("An optional argument".to_string()), + required: Some(false), + }, + ]; + + let test_prompt1_info = output::PromptInfo { + name: "test_prompt1".to_string(), + description: Some("Test prompt 1 description".to_string()), + arguments: Some(test_prompt1_args), + extension: Some("extension1".to_string()), + }; + cache + .prompt_info + .insert("test_prompt1".to_string(), test_prompt1_info); + + let test_prompt2_info = output::PromptInfo { + name: "test_prompt2".to_string(), + description: Some("Test prompt 2 description".to_string()), + arguments: None, + extension: Some("extension1".to_string()), + }; + cache + .prompt_info + .insert("test_prompt2".to_string(), test_prompt2_info); + + let other_prompt_info = output::PromptInfo { + name: "other_prompt".to_string(), + description: Some("Other prompt description".to_string()), + arguments: None, + extension: Some("extension2".to_string()), + }; + cache + .prompt_info + .insert("other_prompt".to_string(), other_prompt_info); + + Arc::new(RwLock::new(cache)) + } + + #[test] + fn test_complete_slash_commands() { + let cache = create_test_cache(); + let completer = GooseCompleter::new(cache); + + // Test complete match + let (pos, candidates) = completer.complete_slash_commands("/exit").unwrap(); + assert_eq!(pos, 0); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].display, "/exit"); + assert_eq!(candidates[0].replacement, "/exit "); + + // Test partial match + let (pos, candidates) = completer.complete_slash_commands("/e").unwrap(); + assert_eq!(pos, 0); + // There might be multiple commands starting with "e" like "/exit" and "/extension" + assert!(candidates.len() >= 1); + + // Test multiple matches + let (pos, candidates) = completer.complete_slash_commands("/").unwrap(); + assert_eq!(pos, 0); + assert!(candidates.len() > 1); + + // Test no match + let (_pos, candidates) = completer.complete_slash_commands("/nonexistent").unwrap(); + assert_eq!(candidates.len(), 0); + } + + #[test] + fn test_complete_prompt_names() { + let cache = create_test_cache(); + let completer = GooseCompleter::new(cache); + + // Test with just "/prompt " + let (pos, candidates) = completer.complete_prompt_names("/prompt ").unwrap(); + assert_eq!(pos, 8); + assert_eq!(candidates.len(), 3); // All prompts + + // Test with partial prompt name + let (pos, candidates) = completer.complete_prompt_names("/prompt test").unwrap(); + assert_eq!(pos, 8); + assert_eq!(candidates.len(), 2); // test_prompt1 and test_prompt2 + + // Test with specific prompt name + let (pos, candidates) = completer + .complete_prompt_names("/prompt test_prompt1") + .unwrap(); + assert_eq!(pos, 8); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].display, "test_prompt1"); + + // Test with no match + let (pos, candidates) = completer + .complete_prompt_names("/prompt nonexistent") + .unwrap(); + assert_eq!(pos, 8); + assert_eq!(candidates.len(), 0); + } + + #[test] + fn test_complete_prompt_flags() { + let cache = create_test_cache(); + let completer = GooseCompleter::new(cache); + + // Test with partial flag + let (_pos, candidates) = completer + .complete_prompt_flags("/prompt test_prompt1 --") + .unwrap(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].display, "--info"); + + // Test with exact flag + let (_pos, candidates) = completer + .complete_prompt_flags("/prompt test_prompt1 --info") + .unwrap(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].display, "--info"); + + // Test with no match + let (_pos, candidates) = completer + .complete_prompt_flags("/prompt test_prompt1 --nonexistent") + .unwrap(); + assert_eq!(candidates.len(), 0); + + // Test with no flag + let (_pos, candidates) = completer + .complete_prompt_flags("/prompt test_prompt1") + .unwrap(); + assert_eq!(candidates.len(), 0); + } + + #[test] + fn test_complete_argument_keys() { + let cache = create_test_cache(); + let completer = GooseCompleter::new(cache); + + // Test with just a prompt name (no space after) + // This case doesn't return any candidates in the current implementation + let (_pos, candidates) = completer + .complete_argument_keys("/prompt test_prompt1") + .unwrap(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].display, "required_arg="); + + // Test with partial argument + let (_pos, candidates) = completer + .complete_argument_keys("/prompt test_prompt1 req") + .unwrap(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].display, "required_arg="); + + // Test with one argument already provided + let (_pos, candidates) = completer + .complete_argument_keys("/prompt test_prompt1 required_arg=value") + .unwrap(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].display, "optional_arg="); + + // Test with all arguments provided + let (_pos, candidates) = completer + .complete_argument_keys("/prompt test_prompt1 required_arg=value optional_arg=value") + .unwrap(); + assert_eq!(candidates.len(), 0); + + // Test with prompt that has no arguments + let (_pos, candidates) = completer + .complete_argument_keys("/prompt test_prompt2") + .unwrap(); + assert_eq!(candidates.len(), 0); + + // Test with nonexistent prompt + let (_pos, candidates) = completer + .complete_argument_keys("/prompt nonexistent") + .unwrap(); + assert_eq!(candidates.len(), 0); + } +} diff --git a/crates/goose-cli/src/session/input.rs b/crates/goose-cli/src/session/input.rs index 38fd2ea5..aa7e20b5 100644 --- a/crates/goose-cli/src/session/input.rs +++ b/crates/goose-cli/src/session/input.rs @@ -3,6 +3,8 @@ use rustyline::Editor; use shlex; use std::collections::HashMap; +use super::completion::GooseCompleter; + #[derive(Debug)] pub enum InputResult { Message(String), @@ -11,7 +13,7 @@ pub enum InputResult { AddBuiltin(String), ToggleTheme, Retry, - ListPrompts, + ListPrompts(Option), PromptCommand(PromptCommandOptions), } @@ -23,7 +25,7 @@ pub struct PromptCommandOptions { } pub fn get_input( - editor: &mut Editor<(), rustyline::history::DefaultHistory>, + editor: &mut Editor, ) -> Result { // Ensure Ctrl-J binding is set for newlines editor.bind_sequence( @@ -70,7 +72,12 @@ fn handle_slash_command(input: &str) -> Option { Some(InputResult::Retry) } "/t" => Some(InputResult::ToggleTheme), - "/prompts" => Some(InputResult::ListPrompts), + "/prompts" => Some(InputResult::ListPrompts(None)), + s if s.starts_with("/prompts ") => { + // Parse arguments for /prompts command + let args = s.strip_prefix("/prompts ").unwrap_or_default(); + parse_prompts_command(args) + } s if s.starts_with("/prompt") => { if s == "/prompt" { // No arguments case @@ -93,6 +100,21 @@ fn handle_slash_command(input: &str) -> Option { } } +fn parse_prompts_command(args: &str) -> Option { + let parts: Vec = shlex::split(args).unwrap_or_default(); + + // Look for --extension flag + for i in 0..parts.len() { + if parts[i] == "--extension" && i + 1 < parts.len() { + // Return the extension name that follows the flag + return Some(InputResult::ListPrompts(Some(parts[i + 1].clone()))); + } + } + + // If we got here, there was no valid --extension flag + Some(InputResult::ListPrompts(None)) +} + fn parse_prompt_command(args: &str) -> Option { let parts: Vec = shlex::split(args).unwrap_or_default(); @@ -138,8 +160,8 @@ fn print_help() { /t - Toggle Light/Dark/Ansi theme /extension - Add a stdio extension (format: ENV1=val1 command args...) /builtin - Add builtin extensions by name (comma-separated) -/prompts - List all available prompts by name -/prompt [--info] [key=value...] - Get prompt info or execute a prompt +/prompts [--extension ] - List all available prompts, optionally filtered by extension +/prompt [--info] [key=value...] - Get prompt info or execute a prompt /? or /help - Display this help message Navigation: @@ -199,6 +221,25 @@ mod tests { assert!(handle_slash_command("/unknown").is_none()); } + #[test] + fn test_prompts_command() { + // Test basic prompts command + if let Some(InputResult::ListPrompts(extension)) = handle_slash_command("/prompts") { + assert!(extension.is_none()); + } else { + panic!("Expected ListPrompts"); + } + + // Test prompts with extension filter + if let Some(InputResult::ListPrompts(extension)) = + handle_slash_command("/prompts --extension test") + { + assert_eq!(extension, Some("test".to_string())); + } else { + panic!("Expected ListPrompts with extension"); + } + } + #[test] fn test_prompt_command() { // Test basic prompt info command diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index b3826d2b..7a0720b8 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -1,4 +1,5 @@ mod builder; +mod completion; mod input; mod output; mod prompt; @@ -9,6 +10,7 @@ pub use builder::build_session; pub use storage::Identifier; use anyhow::Result; +use completion::GooseCompleter; use etcetera::choose_app_strategy; use goose::agents::extension::{Envs, ExtensionConfig}; use goose::agents::Agent; @@ -20,6 +22,8 @@ use rand::{distributions::Alphanumeric, Rng}; use serde_json::Value; use std::collections::HashMap; use std::path::PathBuf; +use std::sync::Arc; +use std::time::Instant; use tokio; use crate::log_usage::log_usage; @@ -28,6 +32,25 @@ pub struct Session { agent: Box, messages: Vec, session_file: PathBuf, + // Cache for completion data - using std::sync for thread safety without async + completion_cache: Arc>, +} + +// Cache structure for completion data +struct CompletionCache { + prompts: HashMap>, + prompt_info: HashMap, + last_updated: Instant, +} + +impl CompletionCache { + fn new() -> Self { + Self { + prompts: HashMap::new(), + prompt_info: HashMap::new(), + last_updated: Instant::now(), + } + } } impl Session { @@ -44,6 +67,7 @@ impl Session { agent, messages, session_file, + completion_cache: Arc::new(std::sync::RwLock::new(CompletionCache::new())), } } @@ -88,7 +112,12 @@ impl Session { self.agent .add_extension(config) .await - .map_err(|e| anyhow::anyhow!("Failed to start extension: {}", e)) + .map_err(|e| anyhow::anyhow!("Failed to start extension: {}", e))?; + + // Invalidate the completion cache when a new extension is added + self.invalidate_completion_cache().await; + + Ok(()) } /// Add a builtin extension to the session @@ -105,18 +134,35 @@ impl Session { .await .map_err(|e| anyhow::anyhow!("Failed to start builtin extension: {}", e))?; } + + // Invalidate the completion cache when a new extension is added + self.invalidate_completion_cache().await; + Ok(()) } - pub async fn list_prompts(&mut self) -> HashMap> { + pub async fn list_prompts( + &mut self, + extension: Option, + ) -> Result>> { let prompts = self.agent.list_extension_prompts().await; - prompts + + // Early validation if filtering by extension + if let Some(filter) = &extension { + if !prompts.contains_key(filter) { + return Err(anyhow::anyhow!("Extension '{}' not found", filter)); + } + } + + // Convert prompts into filtered map of extension names to prompt names + Ok(prompts .into_iter() + .filter(|(ext, _)| extension.as_ref().is_none_or(|f| f == ext)) .map(|(extension, prompt_list)| { let names = prompt_list.into_iter().map(|p| p.name).collect(); (extension, names) }) - .collect() + .collect()) } pub async fn get_prompt_info(&mut self, name: &str) -> Result> { @@ -157,7 +203,21 @@ impl Session { self.process_message(msg).await?; } - let mut editor = rustyline::Editor::<(), rustyline::history::DefaultHistory>::new()?; + // Initialize the completion cache + self.update_completion_cache().await?; + + // Create a new editor with our custom completer + let config = rustyline::Config::builder() + .completion_type(rustyline::CompletionType::Circular) + .build(); + let mut editor = + rustyline::Editor::::with_config( + config, + )?; + + // Set up the completer with a reference to the completion cache + let completer = GooseCompleter::new(self.completion_cache.clone()); + editor.set_helper(Some(completer)); // Load history from messages for msg in self @@ -217,8 +277,11 @@ impl Session { continue; } input::InputResult::Retry => continue, - input::InputResult::ListPrompts => { - output::render_prompts(&self.list_prompts().await) + input::InputResult::ListPrompts(extension) => { + match self.list_prompts(extension).await { + Ok(prompts) => output::render_prompts(&prompts), + Err(e) => output::render_error(&e.to_string()), + } } input::InputResult::PromptCommand(opts) => { // name is required @@ -431,4 +494,45 @@ impl Session { pub fn session_file(&self) -> PathBuf { self.session_file.clone() } + + /// Update the completion cache with fresh data + /// This should be called before the interactive session starts + pub async fn update_completion_cache(&mut self) -> Result<()> { + // Get fresh data + let prompts = self.agent.list_extension_prompts().await; + + // Update the cache with write lock + let mut cache = self.completion_cache.write().unwrap(); + cache.prompts.clear(); + cache.prompt_info.clear(); + + for (extension, prompt_list) in prompts { + let names: Vec = prompt_list.iter().map(|p| p.name.clone()).collect(); + cache.prompts.insert(extension.clone(), names); + + for prompt in prompt_list { + cache.prompt_info.insert( + prompt.name.clone(), + output::PromptInfo { + name: prompt.name.clone(), + description: prompt.description.clone(), + arguments: prompt.arguments.clone(), + extension: Some(extension.clone()), + }, + ); + } + } + + cache.last_updated = Instant::now(); + Ok(()) + } + + /// Invalidate the completion cache + /// This should be called when extensions are added or removed + async fn invalidate_completion_cache(&self) { + let mut cache = self.completion_cache.write().unwrap(); + cache.prompts.clear(); + cache.prompt_info.clear(); + cache.last_updated = Instant::now(); + } } diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index d1377eb6..66021695 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -75,7 +75,7 @@ impl ThinkingIndicator { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct PromptInfo { pub name: String, pub description: Option,