feat(cli): add tab based slash command completion and prompt info completion (#1436)
This commit is contained in:
@@ -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<std::sync::RwLock<CompletionCache>>,
|
||||
}
|
||||
|
||||
impl GooseCompleter {
|
||||
/// Create a new GooseCompleter with a reference to the Session's completion cache
|
||||
pub fn new(completion_cache: Arc<std::sync::RwLock<CompletionCache>>) -> Self {
|
||||
Self { completion_cache }
|
||||
}
|
||||
|
||||
/// Complete prompt names for the /prompt command
|
||||
fn complete_prompt_names(&self, line: &str) -> Result<(usize, Vec<Pair>)> {
|
||||
// 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<Pair> = 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<Pair>)> {
|
||||
// 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<Pair> = 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<Pair>)> {
|
||||
// Define available slash commands
|
||||
let commands = [
|
||||
"/exit",
|
||||
"/quit",
|
||||
"/help",
|
||||
"/?",
|
||||
"/t",
|
||||
"/extension",
|
||||
"/builtin",
|
||||
"/prompts",
|
||||
"/prompt",
|
||||
];
|
||||
|
||||
// Find commands that match the prefix
|
||||
let matching_commands: Vec<Pair> = 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<Pair>)> {
|
||||
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<Pair> = 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<Self::Candidate>)> {
|
||||
// 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<Self::Hint> {
|
||||
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<rustyline::validate::ValidationResult> {
|
||||
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<RwLock<CompletionCache>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<String>),
|
||||
PromptCommand(PromptCommandOptions),
|
||||
}
|
||||
|
||||
@@ -23,7 +25,7 @@ pub struct PromptCommandOptions {
|
||||
}
|
||||
|
||||
pub fn get_input(
|
||||
editor: &mut Editor<(), rustyline::history::DefaultHistory>,
|
||||
editor: &mut Editor<GooseCompleter, rustyline::history::DefaultHistory>,
|
||||
) -> Result<InputResult> {
|
||||
// Ensure Ctrl-J binding is set for newlines
|
||||
editor.bind_sequence(
|
||||
@@ -70,7 +72,12 @@ fn handle_slash_command(input: &str) -> Option<InputResult> {
|
||||
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<InputResult> {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_prompts_command(args: &str) -> Option<InputResult> {
|
||||
let parts: Vec<String> = 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<InputResult> {
|
||||
let parts: Vec<String> = shlex::split(args).unwrap_or_default();
|
||||
|
||||
@@ -138,8 +160,8 @@ fn print_help() {
|
||||
/t - Toggle Light/Dark/Ansi theme
|
||||
/extension <command> - Add a stdio extension (format: ENV1=val1 command args...)
|
||||
/builtin <names> - Add builtin extensions by name (comma-separated)
|
||||
/prompts - List all available prompts by name
|
||||
/prompt <name> [--info] [key=value...] - Get prompt info or execute a prompt
|
||||
/prompts [--extension <name>] - List all available prompts, optionally filtered by extension
|
||||
/prompt <n> [--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
|
||||
|
||||
@@ -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<dyn Agent>,
|
||||
messages: Vec<Message>,
|
||||
session_file: PathBuf,
|
||||
// Cache for completion data - using std::sync for thread safety without async
|
||||
completion_cache: Arc<std::sync::RwLock<CompletionCache>>,
|
||||
}
|
||||
|
||||
// Cache structure for completion data
|
||||
struct CompletionCache {
|
||||
prompts: HashMap<String, Vec<String>>,
|
||||
prompt_info: HashMap<String, output::PromptInfo>,
|
||||
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<String, Vec<String>> {
|
||||
pub async fn list_prompts(
|
||||
&mut self,
|
||||
extension: Option<String>,
|
||||
) -> Result<HashMap<String, Vec<String>>> {
|
||||
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<Option<output::PromptInfo>> {
|
||||
@@ -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::<GooseCompleter, rustyline::history::DefaultHistory>::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<String> = 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ impl ThinkingIndicator {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PromptInfo {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
|
||||
Reference in New Issue
Block a user