From 7f98f1f5f9e469d08020ba4da3615afdb910bef4 Mon Sep 17 00:00:00 2001 From: Peter Willemsen Date: Wed, 28 Jan 2026 02:46:04 +0100 Subject: [PATCH] New feature: External editor such as vim, helix, etc (#5823) Signed-off-by: Peter Willemsen Signed-off-by: = --- crates/goose-cli/src/session/editor.rs | 399 +++++++++++++++++++++++++ crates/goose-cli/src/session/input.rs | 91 ++++++ crates/goose-cli/src/session/mod.rs | 16 +- crates/goose/src/config/base.rs | 1 + 4 files changed, 506 insertions(+), 1 deletion(-) create mode 100644 crates/goose-cli/src/session/editor.rs diff --git a/crates/goose-cli/src/session/editor.rs b/crates/goose-cli/src/session/editor.rs new file mode 100644 index 00000000..ee5343c2 --- /dev/null +++ b/crates/goose-cli/src/session/editor.rs @@ -0,0 +1,399 @@ +use anyhow::Result; +use std::fs; +use std::io::Read; +use std::path::PathBuf; +use std::process::Command; +use tempfile::Builder; +use tempfile::NamedTempFile; + +/// Create temporary markdown file with conversation history +fn create_temp_file(messages: &[&str]) -> Result { + let temp_file = Builder::new() + .prefix("goose_prompt_") + .suffix(".md") + .tempfile()?; + let mut content = String::from("# Goose Prompt Editor\n\n"); + + content.push_str("# Your prompt:\n\n"); + + if !messages.is_empty() { + content.push_str("# Recent conversation for context (newest first):\n\n"); + for message in messages.iter().rev() { + content.push_str(&format!("{}\n", message)); + } + content.push('\n'); + } + + fs::write(temp_file.path(), content)?; + Ok(temp_file) +} + +/// RAII guard to ensure symlink is cleaned up even on panic +struct SymlinkCleanup { + symlink_path: PathBuf, +} + +impl SymlinkCleanup { + fn new(symlink_path: PathBuf) -> Self { + Self { symlink_path } + } +} + +impl Drop for SymlinkCleanup { + fn drop(&mut self) { + // Always try to clean up the symlink, ignoring any errors + let _ = std::fs::remove_file(&self.symlink_path); + } +} + +/// Launch editor and wait for completion +fn launch_editor(editor_cmd: &str, file_path: &PathBuf) -> Result<()> { + use std::process::Stdio; + + let parts: Vec<&str> = editor_cmd.split_whitespace().collect(); + if parts.is_empty() { + return Err(anyhow::anyhow!("Empty editor command")); + } + + let mut cmd = Command::new(parts[0]); + if let Ok(cwd) = std::env::current_dir() { + cmd.current_dir(cwd); + } + if parts.len() > 1 { + cmd.args(&parts[1..]); + } + cmd.arg(file_path) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + let status = cmd.status()?; + + if !status.success() { + return Err(anyhow::anyhow!( + "Editor exited with non-zero status: {}", + status.code().unwrap_or(-1) + )); + } + + Ok(()) +} + +/// Main function to get input from editor +pub fn get_editor_input(editor_cmd: &str, messages: &[&str]) -> Result<(String, bool)> { + let temp_file = create_temp_file(messages)?; + let temp_path = temp_file.path().to_path_buf(); + + let symlink_path = PathBuf::from(".goose_prompt_temp.md"); + + if symlink_path.exists() { + std::fs::remove_file(&symlink_path)?; + } + + #[cfg(unix)] + std::os::unix::fs::symlink(&temp_path, &symlink_path)?; + + #[cfg(windows)] + std::os::windows::fs::symlink_file(&temp_path, &symlink_path)?; + + let _cleanup_guard = SymlinkCleanup::new(symlink_path.clone()); + + let _original_template = { + let mut template_content = String::from("# Goose Prompt Editor\n\n"); + template_content.push_str("# Your prompt:\n\n"); + if !messages.is_empty() { + template_content.push_str("# Recent conversation for context (newest first):\n\n"); + for message in messages.iter().rev() { + template_content.push_str(&format!("{}\n", message)); + } + template_content.push('\n'); + } + template_content + }; + + launch_editor(editor_cmd, &symlink_path)?; + + let mut content = String::new(); + let mut file = std::fs::File::open(&symlink_path)?; + file.read_to_string(&mut content)?; + + let user_input = extract_user_input(&content); + + let has_meaningful_content = !user_input.trim().is_empty(); + + Ok((user_input, has_meaningful_content)) +} + +/// Extract only the user's input from the markdown file +fn extract_user_input(content: &str) -> String { + if let Some(start) = content.find("# Your prompt:") { + let marker_len = "# Your prompt:".len(); + #[allow(clippy::string_slice)] + let user_section = &content[start + marker_len..]; + + let end_patterns = [ + "# Recent conversation for context", + "# Recent conversation for context (newest first):", + ]; + + let mut end_pos = None; + for pattern in &end_patterns { + if let Some(pos) = user_section.find(pattern) { + end_pos = Some(pos); + break; + } + } + + let user_input_section = match end_pos { + Some(pos) => + { + #[allow(clippy::string_slice)] + &user_section[..pos] + } + None => user_section, + }; + + user_input_section.trim().to_string() + } else { + content.trim().to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_extract_user_input_with_editor_output() { + let content = r#"# Goose Prompt Editor + +# Your prompt: +This is the hardcoded prompt response +# Recent conversation for context (newest first): + +## User: Hello +## Assistant: Hi there! +"#; + + let result = extract_user_input(content); + + assert_eq!(result, "This is the hardcoded prompt response"); + } + + #[test] + fn test_extract_user_input_no_marker() { + let content = "Just plain text without markers"; + let result = extract_user_input(content); + assert_eq!(result, "Just plain text without markers"); + } + + #[test] + fn test_extract_user_input_conversation_history_heading() { + let content = r#"# Goose Prompt Editor + +# Your prompt: +This is the user's input + +# Recent conversation for context (newest first): + +## User: Previous message +## Assistant: Previous response +"#; + + let result = extract_user_input(content); + assert_eq!(result, "This is the user's input"); + } + + #[test] + fn test_create_temp_file_with_messages() { + let messages = vec!["## User: Hello", "## Assistant: Hi there!"]; + + let temp_file = create_temp_file(&messages).unwrap(); + let path = temp_file.path(); + + assert!(path.exists()); + assert!(path.to_str().unwrap().contains("goose_prompt_")); + assert!(path.to_str().unwrap().ends_with(".md")); + + let content = fs::read_to_string(path).unwrap(); + assert!(content.contains("# Goose Prompt Editor")); + assert!(content.contains("## User: Hello")); + assert!(content.contains("## Assistant: Hi there!")); + assert!(content.contains("# Your prompt:")); + assert!(content.contains("# Recent conversation for context (newest first):")); + } + + #[test] + fn test_create_temp_file_with_prefix_suffix() { + let temp_file = Builder::new() + .prefix("goose_test_") + .suffix(".md") + .tempfile() + .unwrap(); + + let name = temp_file.path().file_name().unwrap().to_str().unwrap(); + assert!(name.starts_with("goose_test_")); + assert!(name.ends_with(".md")); + } + + #[test] + fn test_extract_user_input() { + let content = r#"# Goose Prompt Editor + +# Recent conversation for context: + +# Your prompt: +This is the user's actual input +with multiple lines. +"#; + + let result = extract_user_input(content); + assert_eq!( + result, + "This is the user's actual input\nwith multiple lines." + ); + } + + #[test] + fn test_tempfile_cleanup() { + let path = { + let temp_file = Builder::new() + .prefix("goose_cleanup_test_") + .tempfile() + .unwrap(); + let path = temp_file.path().to_path_buf(); + assert!(path.exists()); + path + }; + + assert!(!path.exists()); + } + + #[test] + fn test_message_ordering_newest_first() { + let messages = vec![ + "## User: First message", + "## Assistant: First response", + "## User: Second message", + "## Assistant: Second response", + "## User: Third message (newest)", + ]; + + let temp_file = create_temp_file(&messages).unwrap(); + let content = fs::read_to_string(temp_file.path()).unwrap(); + + let newest_first = [ + "## User: Third message (newest)", + "## Assistant: Second response", + "## User: Second message", + "## Assistant: First response", + "## User: First message", + ]; + + for expected_msg in &newest_first { + assert!( + content.contains(expected_msg), + "Expected to find message '{}' in content", + expected_msg + ); + } + + let newest_pos = content.find("## User: Third message (newest)").unwrap(); + let oldest_pos = content.find("## User: First message").unwrap(); + assert!( + newest_pos < oldest_pos, + "Newest message should appear before oldest message" + ); + } + + #[test] + fn test_symlink_raii_cleanup_on_panic() { + use std::os::unix::fs; + use std::panic; + + let messages = vec!["## User: Test message for panic cleanup"]; + let temp_file = create_temp_file(&messages).unwrap(); + let temp_path = temp_file.path().to_path_buf(); + + let symlink_path = PathBuf::from(format!("test_panic_cleanup_{}.md", std::process::id())); + + if symlink_path.exists() { + let _ = std::fs::remove_file(&symlink_path); + } + + assert!( + !symlink_path.exists(), + "Symlink should not exist before test" + ); + + #[cfg(unix)] + fs::symlink(&temp_path, &symlink_path).unwrap(); + + #[cfg(windows)] + std::os::windows::fs::symlink_file(&temp_path, &symlink_path).unwrap(); + + assert!(symlink_path.exists(), "Symlink should exist after creation"); + + let cleanup_guard = SymlinkCleanup::new(symlink_path.clone()); + + let result = panic::catch_unwind(|| { + let _guard = cleanup_guard; + panic!("Simulating a panic to test cleanup"); + }); + + assert!(result.is_err(), "Panic should have been caught"); + + assert!( + !symlink_path.exists(), + "Symlink should be cleaned up even after panic" + ); + } + + #[test] + fn test_symlink_creation_and_cleanup() { + use std::os::unix::fs; + + let messages = vec!["## User: Test message"]; + let temp_file = create_temp_file(&messages).unwrap(); + let temp_path = temp_file.path().to_path_buf(); + + let symlink_path = PathBuf::from(format!("test_symlink_cleanup_{}.md", std::process::id())); + + if symlink_path.exists() { + let _ = std::fs::remove_file(&symlink_path); + } + + assert!( + !symlink_path.exists(), + "Symlink should be removed before creating new one" + ); + + #[cfg(unix)] + fs::symlink(&temp_path, &symlink_path).unwrap(); + + #[cfg(windows)] + std::os::windows::fs::symlink_file(&temp_path, &symlink_path).unwrap(); + + assert!(symlink_path.exists()); + + let content = std::fs::read_to_string(&symlink_path).unwrap(); + assert!(content.contains("## User: Test message")); + + #[cfg(unix)] + { + let read_link = std::fs::read_link(&symlink_path).unwrap(); + assert_eq!(read_link, temp_path); + } + + #[cfg(windows)] + { + assert!(temp_path.exists()); + let temp_content = std::fs::read_to_string(&temp_path).unwrap(); + assert_eq!(content, temp_content); + } + + let _ = std::fs::remove_file(&symlink_path); + assert!(!symlink_path.exists()); + } +} diff --git a/crates/goose-cli/src/session/input.rs b/crates/goose-cli/src/session/input.rs index e699e819..e9319413 100644 --- a/crates/goose-cli/src/session/input.rs +++ b/crates/goose-cli/src/session/input.rs @@ -67,6 +67,75 @@ pub fn get_newline_key() -> char { pub fn get_input( editor: &mut Editor, + conversation_messages: Option<&Vec>, +) -> Result { + let config = Config::global(); + if let Ok(Some(editor_cmd)) = config.get_goose_prompt_editor() { + let messages = extract_recent_messages(conversation_messages); + let message_refs: Vec<&str> = messages.iter().map(|s| s.as_str()).collect(); + let (message, has_meaningful_content) = + crate::session::editor::get_editor_input(&editor_cmd, &message_refs)?; + + if !has_meaningful_content { + return get_regular_input(editor); + } + editor.add_history_entry(message.as_str())?; + return Ok(InputResult::Message(message)); + } + + // 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), + ); + + editor.bind_sequence( + rustyline::KeyEvent(rustyline::KeyCode::Char('c'), rustyline::Modifiers::CTRL), + rustyline::EventHandler::Conditional(Box::new(CtrlCHandler)), + ); + + let prompt = get_input_prompt_string(); + + let input = match editor.readline(&prompt) { + Ok(text) => text, + Err(e) => match e { + rustyline::error::ReadlineError::Interrupted => return Ok(InputResult::Exit), + rustyline::error::ReadlineError::Eof => 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())), + } +} + +/// Get regular CLI input when editor mode doesn't have content +fn get_regular_input( + editor: &mut Editor, ) -> Result { let newline_key = get_newline_key(); editor.bind_sequence( @@ -142,6 +211,7 @@ fn handle_slash_command(input: &str) -> Option { "/exit" | "/quit" => Some(InputResult::Exit), "/?" | "/help" => { print_help(); + print_editor_help(); Some(InputResult::Retry) } "/t" => Some(InputResult::ToggleTheme), @@ -341,6 +411,27 @@ Up/Down arrows - Navigate through command history" ); } +/// Extract recent messages for editor context +fn extract_recent_messages(conversation_messages: Option<&Vec>) -> Vec { + match conversation_messages { + Some(messages) => { + // Return the messages in reverse chronological order (newest first) + messages.clone() + } + None => Vec::new(), + } +} + +/// Print help information about editor input +fn print_editor_help() { + println!( + "Editor Input: +When goose_prompt_editor is configured, prompts will open in your editor instead of the CLI. +Previous conversation is included as markdown headings for context. +Configure with: goose configure set goose_prompt_editor \"vim\"" + ); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index db351e20..3d3e6533 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -1,5 +1,6 @@ mod builder; mod completion; +mod editor; mod elicitation; mod export; mod input; @@ -444,7 +445,20 @@ impl CliSession { loop { self.display_context_usage().await?; - let input = input::get_input(&mut editor)?; + // Convert conversation messages to strings for editor mode + let conversation_strings: Vec = self + .messages + .iter() + .map(|msg| { + let role = match msg.role { + rmcp::model::Role::User => "User", + rmcp::model::Role::Assistant => "Assistant", + }; + format!("## {}: {}", role, msg.as_concat_text()) + }) + .collect(); + + let input = input::get_input(&mut editor, Some(&conversation_strings))?; if matches!(input, InputResult::Exit) { break; } diff --git a/crates/goose/src/config/base.rs b/crates/goose/src/config/base.rs index 18e1e5b1..f053e22d 100644 --- a/crates/goose/src/config/base.rs +++ b/crates/goose/src/config/base.rs @@ -957,6 +957,7 @@ config_value!(GOOSE_SEARCH_PATHS, Vec); config_value!(GOOSE_MODE, GooseMode); config_value!(GOOSE_PROVIDER, String); config_value!(GOOSE_MODEL, String); +config_value!(GOOSE_PROMPT_EDITOR, Option); config_value!(GOOSE_MAX_ACTIVE_AGENTS, usize); /// Load init-config.yaml from workspace root if it exists.