mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-18 22:54:24 +01:00
515 lines
17 KiB
Rust
515 lines
17 KiB
Rust
use super::completion::GooseCompleter;
|
|
use anyhow::Result;
|
|
use rustyline::Editor;
|
|
use shlex;
|
|
use std::collections::HashMap;
|
|
|
|
#[derive(Debug)]
|
|
pub enum InputResult {
|
|
Message(String),
|
|
Exit,
|
|
AddExtension(String),
|
|
AddBuiltin(String),
|
|
ToggleTheme,
|
|
SelectTheme(String),
|
|
Retry,
|
|
ListPrompts(Option<String>),
|
|
PromptCommand(PromptCommandOptions),
|
|
GooseMode(String),
|
|
Plan(PlanCommandOptions),
|
|
EndPlan,
|
|
Clear,
|
|
Recipe(Option<String>),
|
|
Summarize,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct PromptCommandOptions {
|
|
pub name: String,
|
|
pub info: bool,
|
|
pub arguments: HashMap<String, String>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct PlanCommandOptions {
|
|
pub message_text: String,
|
|
}
|
|
|
|
pub fn get_input(
|
|
editor: &mut Editor<GooseCompleter, rustyline::history::DefaultHistory>,
|
|
) -> Result<InputResult> {
|
|
// Ensure Ctrl-J binding is set for newlines
|
|
editor.bind_sequence(
|
|
rustyline::KeyEvent(rustyline::KeyCode::Char('j'), rustyline::Modifiers::CTRL),
|
|
rustyline::EventHandler::Simple(rustyline::Cmd::Newline),
|
|
);
|
|
|
|
let prompt = format!("{} ", console::style("( O)>").cyan().bold());
|
|
let input = match editor.readline(&prompt) {
|
|
Ok(text) => text,
|
|
Err(e) => match e {
|
|
rustyline::error::ReadlineError::Interrupted => return Ok(InputResult::Exit),
|
|
_ => return Err(e.into()),
|
|
},
|
|
};
|
|
|
|
// Add valid input to history (history saving to file is handled in the Session::interactive method)
|
|
if !input.trim().is_empty() {
|
|
editor.add_history_entry(input.as_str())?;
|
|
}
|
|
|
|
// Handle non-slash commands first
|
|
if !input.starts_with('/') {
|
|
let trimmed = input.trim();
|
|
if trimmed.is_empty()
|
|
|| trimmed.eq_ignore_ascii_case("exit")
|
|
|| trimmed.eq_ignore_ascii_case("quit")
|
|
{
|
|
return Ok(if trimmed.is_empty() {
|
|
InputResult::Retry
|
|
} else {
|
|
InputResult::Exit
|
|
});
|
|
}
|
|
return Ok(InputResult::Message(trimmed.to_string()));
|
|
}
|
|
|
|
// Handle slash commands
|
|
match handle_slash_command(&input) {
|
|
Some(result) => Ok(result),
|
|
None => Ok(InputResult::Message(input.trim().to_string())),
|
|
}
|
|
}
|
|
|
|
fn handle_slash_command(input: &str) -> Option<InputResult> {
|
|
let input = input.trim();
|
|
|
|
// Command prefix constants
|
|
const CMD_PROMPTS: &str = "/prompts ";
|
|
const CMD_PROMPT: &str = "/prompt";
|
|
const CMD_PROMPT_WITH_SPACE: &str = "/prompt ";
|
|
const CMD_EXTENSION: &str = "/extension ";
|
|
const CMD_BUILTIN: &str = "/builtin ";
|
|
const CMD_MODE: &str = "/mode ";
|
|
const CMD_PLAN: &str = "/plan";
|
|
const CMD_ENDPLAN: &str = "/endplan";
|
|
const CMD_CLEAR: &str = "/clear";
|
|
const CMD_RECIPE: &str = "/recipe";
|
|
const CMD_SUMMARIZE: &str = "/summarize";
|
|
|
|
match input {
|
|
"/exit" | "/quit" => Some(InputResult::Exit),
|
|
"/?" | "/help" => {
|
|
print_help();
|
|
Some(InputResult::Retry)
|
|
}
|
|
"/t" => Some(InputResult::ToggleTheme),
|
|
s if s.starts_with("/t ") => {
|
|
let t = s
|
|
.strip_prefix("/t ")
|
|
.unwrap_or_default()
|
|
.trim()
|
|
.to_lowercase();
|
|
if ["light", "dark", "ansi"].contains(&t.as_str()) {
|
|
Some(InputResult::SelectTheme(t))
|
|
} else {
|
|
println!(
|
|
"Theme Unavailable: {} Available themes are: light, dark, ansi",
|
|
t
|
|
);
|
|
Some(InputResult::Retry)
|
|
}
|
|
}
|
|
"/prompts" => Some(InputResult::ListPrompts(None)),
|
|
s if s.starts_with(CMD_PROMPTS) => {
|
|
// Parse arguments for /prompts command
|
|
let args = s.strip_prefix(CMD_PROMPTS).unwrap_or_default();
|
|
parse_prompts_command(args)
|
|
}
|
|
s if s.starts_with(CMD_PROMPT) => {
|
|
if s == CMD_PROMPT {
|
|
// No arguments case
|
|
Some(InputResult::PromptCommand(PromptCommandOptions {
|
|
name: String::new(), // Empty name will trigger the error message in the rendering
|
|
info: false,
|
|
arguments: HashMap::new(),
|
|
}))
|
|
} else if let Some(stripped) = s.strip_prefix(CMD_PROMPT_WITH_SPACE) {
|
|
// Has arguments case
|
|
parse_prompt_command(stripped)
|
|
} else {
|
|
// Handle invalid cases like "/promptxyz"
|
|
None
|
|
}
|
|
}
|
|
s if s.starts_with(CMD_EXTENSION) => Some(InputResult::AddExtension(
|
|
s[CMD_EXTENSION.len()..].to_string(),
|
|
)),
|
|
s if s.starts_with(CMD_BUILTIN) => {
|
|
Some(InputResult::AddBuiltin(s[CMD_BUILTIN.len()..].to_string()))
|
|
}
|
|
s if s.starts_with(CMD_MODE) => {
|
|
Some(InputResult::GooseMode(s[CMD_MODE.len()..].to_string()))
|
|
}
|
|
s if s.starts_with(CMD_PLAN) => parse_plan_command(s[CMD_PLAN.len()..].trim().to_string()),
|
|
s if s == CMD_ENDPLAN => Some(InputResult::EndPlan),
|
|
s if s == CMD_CLEAR => Some(InputResult::Clear),
|
|
s if s.starts_with(CMD_RECIPE) => parse_recipe_command(s),
|
|
s if s == CMD_SUMMARIZE => Some(InputResult::Summarize),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn parse_recipe_command(s: &str) -> Option<InputResult> {
|
|
const CMD_RECIPE: &str = "/recipe";
|
|
|
|
if s == CMD_RECIPE {
|
|
// No filepath provided, use default
|
|
return Some(InputResult::Recipe(None));
|
|
}
|
|
|
|
// Extract the filepath from the command
|
|
let filepath = s[CMD_RECIPE.len()..].trim();
|
|
|
|
if filepath.is_empty() {
|
|
return Some(InputResult::Recipe(None));
|
|
}
|
|
|
|
// Validate that the filepath ends with .yaml
|
|
if !filepath.to_lowercase().ends_with(".yaml") {
|
|
println!("{}", console::style("Filepath must end with .yaml").red());
|
|
return Some(InputResult::Retry);
|
|
}
|
|
|
|
// Return the filepath for validation in the handler
|
|
Some(InputResult::Recipe(Some(filepath.to_string())))
|
|
}
|
|
|
|
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();
|
|
|
|
// set name to empty and error out in the rendering
|
|
let mut options = PromptCommandOptions {
|
|
name: parts.first().cloned().unwrap_or_default(),
|
|
info: false,
|
|
arguments: HashMap::new(),
|
|
};
|
|
|
|
// handle info at any point in the command
|
|
if parts.iter().any(|part| part == "--info") {
|
|
options.info = true;
|
|
}
|
|
|
|
// Parse remaining arguments
|
|
let mut i = 1;
|
|
|
|
while i < parts.len() {
|
|
let part = &parts[i];
|
|
|
|
// Skip flag arguments
|
|
if part == "--info" {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
// Process key=value pairs - removed redundant contains check
|
|
if let Some((key, value)) = part.split_once('=') {
|
|
options.arguments.insert(key.to_string(), value.to_string());
|
|
}
|
|
|
|
i += 1;
|
|
}
|
|
|
|
Some(InputResult::PromptCommand(options))
|
|
}
|
|
|
|
fn parse_plan_command(input: String) -> Option<InputResult> {
|
|
let options = PlanCommandOptions {
|
|
message_text: input.trim().to_string(),
|
|
};
|
|
|
|
Some(InputResult::Plan(options))
|
|
}
|
|
|
|
fn print_help() {
|
|
println!(
|
|
"Available commands:
|
|
/exit or /quit - Exit the session
|
|
/t - Toggle Light/Dark/Ansi theme
|
|
/t <name> - Set theme directly (light, dark, ansi)
|
|
/extension <command> - Add a stdio extension (format: ENV1=val1 command args...)
|
|
/builtin <names> - Add builtin extensions by name (comma-separated)
|
|
/prompts [--extension <name>] - List all available prompts, optionally filtered by extension
|
|
/prompt <n> [--info] [key=value...] - Get prompt info or execute a prompt
|
|
/mode <name> - Set the goose mode to use ('auto', 'approve', 'chat')
|
|
/plan <message_text> - Enters 'plan' mode with optional message. Create a plan based on the current messages and asks user if they want to act on it.
|
|
If user acts on the plan, goose mode is set to 'auto' and returns to 'normal' goose mode.
|
|
To warm up goose before using '/plan', we recommend setting '/mode approve' & putting appropriate context into goose.
|
|
The model is used based on $GOOSE_PLANNER_PROVIDER and $GOOSE_PLANNER_MODEL environment variables.
|
|
If no model is set, the default model is used.
|
|
/endplan - Exit plan mode and return to 'normal' goose mode.
|
|
/recipe [filepath] - Generate a recipe from the current conversation and save it to the specified filepath (must end with .yaml).
|
|
If no filepath is provided, it will be saved to ./recipe.yaml.
|
|
/summarize - Summarize the current conversation to reduce context length while preserving key information.
|
|
/? or /help - Display this help message
|
|
/clear - Clears the current chat history
|
|
|
|
Navigation:
|
|
Ctrl+C - Interrupt goose (resets the interaction to before the interrupted user request)
|
|
Ctrl+J - Add a newline
|
|
Up/Down arrows - Navigate through command history"
|
|
);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_handle_slash_command() {
|
|
// Test exit commands
|
|
assert!(matches!(
|
|
handle_slash_command("/exit"),
|
|
Some(InputResult::Exit)
|
|
));
|
|
assert!(matches!(
|
|
handle_slash_command("/quit"),
|
|
Some(InputResult::Exit)
|
|
));
|
|
|
|
// Test help commands
|
|
assert!(matches!(
|
|
handle_slash_command("/help"),
|
|
Some(InputResult::Retry)
|
|
));
|
|
assert!(matches!(
|
|
handle_slash_command("/?"),
|
|
Some(InputResult::Retry)
|
|
));
|
|
|
|
// Test theme toggle
|
|
assert!(matches!(
|
|
handle_slash_command("/t"),
|
|
Some(InputResult::ToggleTheme)
|
|
));
|
|
|
|
// Test extension command
|
|
if let Some(InputResult::AddExtension(cmd)) = handle_slash_command("/extension foo bar") {
|
|
assert_eq!(cmd, "foo bar");
|
|
} else {
|
|
panic!("Expected AddExtension");
|
|
}
|
|
|
|
// Test builtin command
|
|
if let Some(InputResult::AddBuiltin(names)) = handle_slash_command("/builtin dev,git") {
|
|
assert_eq!(names, "dev,git");
|
|
} else {
|
|
panic!("Expected AddBuiltin");
|
|
}
|
|
|
|
// Test unknown commands
|
|
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
|
|
if let Some(InputResult::PromptCommand(opts)) =
|
|
handle_slash_command("/prompt test-prompt --info")
|
|
{
|
|
assert_eq!(opts.name, "test-prompt");
|
|
assert!(opts.info);
|
|
assert!(opts.arguments.is_empty());
|
|
} else {
|
|
panic!("Expected PromptCommand");
|
|
}
|
|
|
|
// Test prompt with arguments
|
|
if let Some(InputResult::PromptCommand(opts)) =
|
|
handle_slash_command("/prompt test-prompt arg1=val1 arg2=val2")
|
|
{
|
|
assert_eq!(opts.name, "test-prompt");
|
|
assert!(!opts.info);
|
|
assert_eq!(opts.arguments.len(), 2);
|
|
assert_eq!(opts.arguments.get("arg1"), Some(&"val1".to_string()));
|
|
assert_eq!(opts.arguments.get("arg2"), Some(&"val2".to_string()));
|
|
} else {
|
|
panic!("Expected PromptCommand");
|
|
}
|
|
}
|
|
|
|
// Test whitespace handling
|
|
#[test]
|
|
fn test_whitespace_handling() {
|
|
// Leading/trailing whitespace in extension command
|
|
if let Some(InputResult::AddExtension(cmd)) = handle_slash_command(" /extension foo bar ")
|
|
{
|
|
assert_eq!(cmd, "foo bar");
|
|
} else {
|
|
panic!("Expected AddExtension");
|
|
}
|
|
|
|
// Leading/trailing whitespace in builtin command
|
|
if let Some(InputResult::AddBuiltin(names)) = handle_slash_command(" /builtin dev,git ") {
|
|
assert_eq!(names, "dev,git");
|
|
} else {
|
|
panic!("Expected AddBuiltin");
|
|
}
|
|
}
|
|
|
|
// Test prompt with no arguments
|
|
#[test]
|
|
fn test_prompt_no_args() {
|
|
// Test just "/prompt" with no arguments
|
|
if let Some(InputResult::PromptCommand(opts)) = handle_slash_command("/prompt") {
|
|
assert_eq!(opts.name, "");
|
|
assert!(!opts.info);
|
|
assert!(opts.arguments.is_empty());
|
|
} else {
|
|
panic!("Expected PromptCommand");
|
|
}
|
|
|
|
// Test invalid prompt command
|
|
assert!(handle_slash_command("/promptxyz").is_none());
|
|
}
|
|
|
|
// Test quoted arguments
|
|
#[test]
|
|
fn test_quoted_arguments() {
|
|
// Test prompt with quoted arguments
|
|
if let Some(InputResult::PromptCommand(opts)) = handle_slash_command(
|
|
r#"/prompt test-prompt arg1="value with spaces" arg2="another value""#,
|
|
) {
|
|
assert_eq!(opts.name, "test-prompt");
|
|
assert_eq!(opts.arguments.len(), 2);
|
|
assert_eq!(
|
|
opts.arguments.get("arg1"),
|
|
Some(&"value with spaces".to_string())
|
|
);
|
|
assert_eq!(
|
|
opts.arguments.get("arg2"),
|
|
Some(&"another value".to_string())
|
|
);
|
|
} else {
|
|
panic!("Expected PromptCommand");
|
|
}
|
|
|
|
// Test prompt with mixed quoted and unquoted arguments
|
|
if let Some(InputResult::PromptCommand(opts)) = handle_slash_command(
|
|
r#"/prompt test-prompt simple=value quoted="value with \"nested\" quotes""#,
|
|
) {
|
|
assert_eq!(opts.name, "test-prompt");
|
|
assert_eq!(opts.arguments.len(), 2);
|
|
assert_eq!(opts.arguments.get("simple"), Some(&"value".to_string()));
|
|
assert_eq!(
|
|
opts.arguments.get("quoted"),
|
|
Some(&r#"value with "nested" quotes"#.to_string())
|
|
);
|
|
} else {
|
|
panic!("Expected PromptCommand");
|
|
}
|
|
}
|
|
|
|
// Test invalid arguments
|
|
#[test]
|
|
fn test_invalid_arguments() {
|
|
// Test prompt with invalid arguments
|
|
if let Some(InputResult::PromptCommand(opts)) =
|
|
handle_slash_command(r#"/prompt test-prompt valid=value invalid_arg another_invalid"#)
|
|
{
|
|
assert_eq!(opts.name, "test-prompt");
|
|
assert_eq!(opts.arguments.len(), 1);
|
|
assert_eq!(opts.arguments.get("valid"), Some(&"value".to_string()));
|
|
// Invalid arguments are ignored but logged
|
|
} else {
|
|
panic!("Expected PromptCommand");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_plan_mode() {
|
|
// Test plan mode with no text
|
|
let result = handle_slash_command("/plan");
|
|
assert!(result.is_some());
|
|
|
|
// Test plan mode with text
|
|
let result = handle_slash_command("/plan hello world");
|
|
assert!(result.is_some());
|
|
let options = result.unwrap();
|
|
match options {
|
|
InputResult::Plan(options) => {
|
|
assert_eq!(options.message_text, "hello world");
|
|
}
|
|
_ => panic!("Expected Plan"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_recipe_command() {
|
|
// Test recipe with no filepath
|
|
if let Some(InputResult::Recipe(filepath)) = handle_slash_command("/recipe") {
|
|
assert!(filepath.is_none());
|
|
} else {
|
|
panic!("Expected Recipe");
|
|
}
|
|
|
|
// Test recipe with filepath
|
|
if let Some(InputResult::Recipe(filepath)) =
|
|
handle_slash_command("/recipe /path/to/file.yaml")
|
|
{
|
|
assert_eq!(filepath, Some("/path/to/file.yaml".to_string()));
|
|
} else {
|
|
panic!("Expected recipe with filepath");
|
|
}
|
|
|
|
// Test recipe with invalid extension
|
|
let result = handle_slash_command("/recipe /path/to/file.txt");
|
|
assert!(matches!(result, Some(InputResult::Retry)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_summarize_command() {
|
|
// Test the summarize command
|
|
let result = handle_slash_command("/summarize");
|
|
assert!(matches!(result, Some(InputResult::Summarize)));
|
|
|
|
// Test with whitespace
|
|
let result = handle_slash_command(" /summarize ");
|
|
assert!(matches!(result, Some(InputResult::Summarize)));
|
|
}
|
|
}
|