diff --git a/crates/goose-cli/src/prompt.rs b/crates/goose-cli/src/prompt.rs index 09f5ef9058..386f079d3c 100644 --- a/crates/goose-cli/src/prompt.rs +++ b/crates/goose-cli/src/prompt.rs @@ -2,6 +2,7 @@ use anyhow::Result; use goose::models::message::Message; pub mod cliclack; +pub mod renderer; pub mod rustyline; pub mod thinking; diff --git a/crates/goose-cli/src/prompt/cliclack.rs b/crates/goose-cli/src/prompt/cliclack.rs index 7dda7da764..b81f28fa62 100644 --- a/crates/goose-cli/src/prompt/cliclack.rs +++ b/crates/goose-cli/src/prompt/cliclack.rs @@ -1,14 +1,14 @@ -use std::{ - collections::HashMap, - io::{self, Write}, -}; +use std::collections::HashMap; use anyhow::Result; -use bat::WrappingMode; use cliclack::{input, set_theme, spinner, Theme as CliclackTheme, ThemeState}; -use goose::models::message::{Message, MessageContent, ToolRequest, ToolResponse}; +use goose::models::message::Message; -use super::{thinking::get_random_thinking_message, Input, InputType, Prompt, Theme}; +use super::{ + renderer::{render, BashDeveloperSystemRenderer, DefaultRenderer, ToolRenderer}, + thinking::get_random_thinking_message, + Input, InputType, Prompt, Theme, +}; pub struct CliclackPrompt { spinner: cliclack::ProgressBar, @@ -24,164 +24,38 @@ enum InputMode { impl CliclackPrompt { pub fn new() -> Self { - // // Load highlighting assets - // let assets = HighlightingAssets::from_binary(); - - // // Fetch and list all available themes - // let themes = assets.themes(); - // for theme_name in themes { - // println!("{}", theme_name); - // } - - // // List all available syntaxes (languages) - // let syntax_set = assets.get_syntaxes().unwrap(); - // for syntax in syntax_set { - // println!("{}", syntax.name); - // } - set_theme(PromptTheme); let mut renderers: HashMap> = HashMap::new(); let default_renderer = DefaultRenderer; renderers.insert(default_renderer.tool_name(), Box::new(default_renderer)); + let bash_dev_system_renderer = BashDeveloperSystemRenderer; + renderers.insert( + bash_dev_system_renderer.tool_name(), + Box::new(bash_dev_system_renderer), + ); CliclackPrompt { spinner: spinner(), - input_mode: InputMode::Multiline, - theme: Theme::Dark, + input_mode: InputMode::Singleline, + theme: std::env::var("GOOSE_CLI_THEME") + .ok() + .map(|val| { + if val.eq_ignore_ascii_case("light") { + Theme::Light + } else { + Theme::Dark + } + }) + .unwrap_or(Theme::Dark), renderers, } } } -/// Implement the ToolRenderer trait for each tool that you want to render in the prompt. -trait ToolRenderer { - fn tool_name(&self) -> String; - fn request(&self, tool_request: &ToolRequest, theme: &str); - fn response(&self, tool_response: &ToolResponse, theme: &str); -} - -struct DefaultRenderer; - -impl ToolRenderer for DefaultRenderer { - fn tool_name(&self) -> String { - "default".to_string() - } - - fn request(&self, tool_request: &ToolRequest, theme: &str) { - match &tool_request.tool_call { - Ok(call) => { - print_tool_request( - &serde_json::to_string_pretty(&call.arguments).unwrap(), - theme, - &call.name, - ); - } - Err(e) => print(&e.to_string(), theme), - } - } - - fn response(&self, tool_response: &ToolResponse, theme: &str) { - match &tool_response.tool_result { - Ok(output) => { - let output_value = serde_json::to_string_pretty(output).unwrap(); - - // For pure text responses, strip the quotes and replace escaped newlines. Eg. bash responses - let unquoted = output_value.trim_matches('"'); - let formatted = unquoted.replace("\\n", "\n"); - - let language = if formatted.starts_with("{") { - "JSON" - } else { - "Markdown" - }; - print_tool_response(&formatted, theme, language); - } - Err(e) => print(&e.to_string(), theme), - } - } -} - -fn print_tool_request(content: &str, theme: &str, tool_name: &str) { - bat::PrettyPrinter::new() - .input( - bat::Input::from_bytes(content.as_bytes()).name(format!("Tool Request: {}", tool_name)), - ) - .theme(theme) - .language("JSON") - .grid(true) - .header(true) - .wrapping_mode(WrappingMode::Character) - .print() - .unwrap(); -} - -fn print_tool_response(content: &str, theme: &str, language: &str) { - bat::PrettyPrinter::new() - .input(bat::Input::from_bytes(content.as_bytes()).name("Tool Response:")) - .theme(theme) - .language(language) - .grid(true) - .header(true) - .wrapping_mode(WrappingMode::Character) - .print() - .unwrap(); -} - -fn print(content: &str, theme: &str) { - bat::PrettyPrinter::new() - .input(bat::Input::from_bytes(content.as_bytes())) - .theme(theme) - .language("Markdown") - .wrapping_mode(WrappingMode::Character) - .print() - .unwrap(); -} - -fn print_newline() { - println!(); -} - impl Prompt for CliclackPrompt { fn render(&mut self, message: Box) { - let theme = match self.theme { - Theme::Light => "GitHub", - Theme::Dark => "zenburn", - }; - - let mut last_tool_name: &str = "default"; - for message_content in &message.content { - match message_content { - MessageContent::Text(text) => print(&text.text, theme), - MessageContent::ToolRequest(tool_request) => match &tool_request.tool_call { - Ok(call) => { - last_tool_name = &call.name; - self.renderers - .get(&call.name) - .or_else(|| self.renderers.get("default")) - .unwrap() - .request(tool_request, theme); - } - Err(_) => self - .renderers - .get("default") - .unwrap() - .request(tool_request, theme), - }, - MessageContent::ToolResponse(tool_response) => self - .renderers - .get(last_tool_name) - .or_else(|| self.renderers.get("default")) - .unwrap() - .response(tool_response, theme), - MessageContent::Image(image) => { - println!("Image: [data: {}, type: {}]", image.data, image.mime_type); - } - } - } - - print_newline(); - io::stdout().flush().expect("Failed to flush stdout"); + render(message, &self.theme, self.renderers.clone()); } fn show_busy(&mut self) { diff --git a/crates/goose-cli/src/prompt/renderer.rs b/crates/goose-cli/src/prompt/renderer.rs new file mode 100644 index 0000000000..2425ae162d --- /dev/null +++ b/crates/goose-cli/src/prompt/renderer.rs @@ -0,0 +1,268 @@ +use std::collections::HashMap; +use std::io::{self, Write}; + +use bat::WrappingMode; +use console::style; +use goose::models::message::{Message, MessageContent, ToolRequest, ToolResponse}; +use goose::models::role::Role; +use goose::models::{content::Content, tool::ToolCall}; +use serde_json::Value; + +use super::Theme; + +const MAX_STRING_LENGTH: usize = 40; +const INDENT: &str = " "; + +/// Implement the ToolRenderer trait for each tool that you want to render in the prompt. +pub trait ToolRenderer: ToolRendererClone { + fn tool_name(&self) -> String; + fn request(&self, tool_request: &ToolRequest, theme: &str); + fn response(&self, tool_response: &ToolResponse, theme: &str); +} + +// Helper trait for cloning boxed ToolRenderer objects +pub trait ToolRendererClone { + fn clone_box(&self) -> Box; +} + +// Implement the helper trait for any type that implements ToolRenderer and Clone +impl ToolRendererClone for T +where + T: 'static + ToolRenderer + Clone, +{ + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +// Make Box clonable +impl Clone for Box { + fn clone(&self) -> Box { + self.clone_box() + } +} + +#[derive(Clone)] +pub struct DefaultRenderer; + +impl ToolRenderer for DefaultRenderer { + fn tool_name(&self) -> String { + "default".to_string() + } + + fn request(&self, tool_request: &ToolRequest, theme: &str) { + match &tool_request.tool_call { + Ok(call) => { + default_print_request_header(call); + + // Format and print the parameters + print_params(&call.arguments, 0); + print_newline(); + } + Err(e) => print_markdown(&e.to_string(), theme), + } + } + + fn response(&self, tool_response: &ToolResponse, theme: &str) { + default_response_renderer(tool_response, theme); + } +} + +#[derive(Clone)] +pub struct BashDeveloperSystemRenderer; + +impl ToolRenderer for BashDeveloperSystemRenderer { + fn tool_name(&self) -> String { + "DeveloperSystem__bash".to_string() + } + + fn request(&self, tool_request: &ToolRequest, theme: &str) { + match &tool_request.tool_call { + Ok(call) => { + default_print_request_header(call); + + match call.arguments.get("command") { + Some(Value::String(s)) => { + println!("{}: {}", style("command").dim(), style(s).green()); + } + _ => print_params(&call.arguments, 0), + } + print_newline(); + } + Err(e) => print_markdown(&e.to_string(), theme), + } + } + + fn response(&self, tool_response: &ToolResponse, theme: &str) { + default_response_renderer(tool_response, theme); + } +} + +pub fn render( + message: Box, + theme: &Theme, + renderers: HashMap>, +) { + let theme = match theme { + Theme::Light => "GitHub", + Theme::Dark => "zenburn", + }; + + let mut last_tool_name: &str = "default"; + for message_content in &message.content { + match message_content { + MessageContent::Text(text) => print_markdown(&text.text, theme), + MessageContent::ToolRequest(tool_request) => match &tool_request.tool_call { + Ok(call) => { + last_tool_name = &call.name; + renderers + .get(&call.name) + .or_else(|| renderers.get("default")) + .unwrap() + .request(tool_request, theme); + } + Err(_) => renderers + .get("default") + .unwrap() + .request(tool_request, theme), + }, + MessageContent::ToolResponse(tool_response) => renderers + .get(last_tool_name) + .or_else(|| renderers.get("default")) + .unwrap() + .response(tool_response, theme), + MessageContent::Image(image) => { + println!("Image: [data: {}, type: {}]", image.data, image.mime_type); + } + } + } + + print_newline(); + io::stdout().flush().expect("Failed to flush stdout"); +} + +pub fn default_response_renderer(tool_response: &ToolResponse, theme: &str) { + match &tool_response.tool_result { + Ok(contents) => { + for content in contents { + if content + .audience() + .is_some_and(|audience| !audience.contains(&Role::User)) + { + continue; + } + + let min_priority = std::env::var("GOOSE_CLI_MIN_PRIORITY") + .ok() + .and_then(|val| val.parse::().ok()) + .unwrap_or(0.0); + + if content + .priority() + .is_some_and(|priority| priority <= min_priority) + { + continue; + } + + if let Content::Text(text) = content { + print_markdown(&text.text, theme); + } + } + } + Err(e) => print_markdown(&e.to_string(), theme), + } +} + +pub fn default_print_request_header(call: &ToolCall) { + // Print the tool name with an emoji + let parts: Vec<_> = call.name.split("__").collect(); + + let tool_header = format!( + "─── {} | {} ──────────────────────────", + style(parts.get(1).unwrap_or(&"unknown")), + style(parts.first().unwrap_or(&"unknown")).magenta().dim(), + ); + print_newline(); + println!("{}", tool_header); +} + +pub fn print_markdown(content: &str, theme: &str) { + bat::PrettyPrinter::new() + .input(bat::Input::from_bytes(content.as_bytes())) + .theme(theme) + .language("Markdown") + .wrapping_mode(WrappingMode::Character) + .print() + .unwrap(); +} + +/// Format and print parameters recursively with proper indentation and colors +pub fn print_params(value: &Value, depth: usize) { + let indent = INDENT.repeat(depth); + + match value { + Value::Object(map) => { + for (key, val) in map { + match val { + Value::Object(_) => { + println!("{}{}:", indent, style(key).dim()); + print_params(val, depth + 1); + } + Value::Array(arr) => { + println!("{}{}:", indent, style(key).dim()); + for item in arr.iter() { + println!("{}{}- ", indent, INDENT); + print_params(item, depth + 2); + } + } + Value::String(s) => { + if s.len() > MAX_STRING_LENGTH { + println!("{}{}: {}", indent, style(key).dim(), style("...").dim()); + } else { + println!("{}{}: {}", indent, style(key).dim(), style(s).green()); + } + } + Value::Number(n) => { + println!("{}{}: {}", indent, style(key).dim(), style(n).blue()); + } + Value::Bool(b) => { + println!("{}{}: {}", indent, style(key).dim(), style(b).blue()); + } + Value::Null => { + println!("{}{}: {}", indent, style(key).dim(), style("null").dim()); + } + } + } + } + Value::Array(arr) => { + for (i, item) in arr.iter().enumerate() { + println!("{}{}.", indent, i + 1); + print_params(item, depth + 1); + } + } + Value::String(s) => { + if s.len() > MAX_STRING_LENGTH { + println!( + "{}{}", + indent, + style(format!("[REDACTED: {} chars]", s.len())).yellow() + ); + } else { + println!("{}{}", indent, style(s).green()); + } + } + Value::Number(n) => { + println!("{}{}", indent, style(n).yellow()); + } + Value::Bool(b) => { + println!("{}{}", indent, style(b).yellow()); + } + Value::Null => { + println!("{}{}", indent, style("null").dim()); + } + } +} + +pub fn print_newline() { + println!(); +} diff --git a/crates/goose-cli/src/prompt/rustyline.rs b/crates/goose-cli/src/prompt/rustyline.rs index 9305b61a43..1ed6e8c1ad 100644 --- a/crates/goose-cli/src/prompt/rustyline.rs +++ b/crates/goose-cli/src/prompt/rustyline.rs @@ -1,22 +1,16 @@ -use std::{ - collections::HashMap, - io::{self, Write}, +use std::collections::HashMap; + +use super::{ + renderer::{render, BashDeveloperSystemRenderer, DefaultRenderer, ToolRenderer}, + thinking::get_random_thinking_message, + Input, InputType, Prompt, Theme, }; use anyhow::Result; -use bat::WrappingMode; use cliclack::spinner; -use console::style; -use goose::models::message::{Message, MessageContent, ToolRequest, ToolResponse}; -use goose::models::role::Role; -use goose::models::{content::Content, tool::ToolCall}; -use serde_json::Value; - -use super::{thinking::get_random_thinking_message, Input, InputType, Prompt, Theme}; +use goose::models::message::Message; const PROMPT: &str = "\x1b[1m\x1b[38;5;30m( O)> \x1b[0m"; -const MAX_STRING_LENGTH: usize = 40; -const INDENT: &str = " "; pub struct RustylinePrompt { spinner: cliclack::ProgressBar, @@ -26,21 +20,6 @@ pub struct RustylinePrompt { impl RustylinePrompt { pub fn new() -> Self { - // // Load highlighting assets - // let assets = HighlightingAssets::from_binary(); - - // // Fetch and list all available themes - // let themes = assets.themes(); - // for theme_name in themes { - // println!("{}", theme_name); - // } - - // // List all available syntaxes (languages) - // let syntax_set = assets.get_syntaxes().unwrap(); - // for syntax in syntax_set { - // println!("{}", syntax.name); - // } - let mut renderers: HashMap> = HashMap::new(); let default_renderer = DefaultRenderer; renderers.insert(default_renderer.tool_name(), Box::new(default_renderer)); @@ -52,240 +31,24 @@ impl RustylinePrompt { RustylinePrompt { spinner: spinner(), - theme: Theme::Dark, + theme: std::env::var("GOOSE_CLI_THEME") + .ok() + .map(|val| { + if val.eq_ignore_ascii_case("light") { + Theme::Light + } else { + Theme::Dark + } + }) + .unwrap_or(Theme::Dark), renderers, } } } -/// Implement the ToolRenderer trait for each tool that you want to render in the prompt. -trait ToolRenderer { - fn tool_name(&self) -> String; - fn request(&self, tool_request: &ToolRequest, theme: &str); - fn response(&self, tool_response: &ToolResponse, theme: &str); -} - -struct DefaultRenderer; - -impl ToolRenderer for DefaultRenderer { - fn tool_name(&self) -> String { - "default".to_string() - } - - fn request(&self, tool_request: &ToolRequest, theme: &str) { - match &tool_request.tool_call { - Ok(call) => { - default_print_request_header(call); - - // Format and print the parameters - print_params(&call.arguments, 0); - print_newline(); - } - Err(e) => print(&e.to_string(), theme), - } - } - - fn response(&self, tool_response: &ToolResponse, theme: &str) { - default_response_renderer(tool_response, theme); - } -} - -fn default_response_renderer(tool_response: &ToolResponse, theme: &str) { - match &tool_response.tool_result { - Ok(contents) => { - for content in contents { - if content - .audience() - .is_some_and(|audience| !audience.contains(&Role::User)) - { - continue; - } - - if content.priority().is_some_and(|priority| priority == 0.0) { - continue; - } - - if let Content::Text(text) = content { - print_markdown(&text.text, theme); - } - } - } - Err(e) => print(&e.to_string(), theme), - } -} - -fn default_print_request_header(call: &ToolCall) { - // Print the tool name with an emoji - let parts: Vec<_> = call.name.split("__").collect(); - - let tool_header = format!( - "─── {} | {} ──────────────────────────", - style(parts.get(1).unwrap_or(&"unknown")), - style(parts.first().unwrap_or(&"unknown")).magenta().dim(), - ); - print_newline(); - println!("{}", tool_header); -} -struct BashDeveloperSystemRenderer; - -impl ToolRenderer for BashDeveloperSystemRenderer { - fn tool_name(&self) -> String { - "DeveloperSystem__bash".to_string() - } - - fn request(&self, tool_request: &ToolRequest, theme: &str) { - match &tool_request.tool_call { - Ok(call) => { - default_print_request_header(call); - - match call.arguments.get("command") { - Some(Value::String(s)) => { - println!("{}: {}", style("command").dim(), style(s).green()); - } - _ => print_params(&call.arguments, 0), - } - print_newline(); - } - Err(e) => print(&e.to_string(), theme), - } - } - - fn response(&self, tool_response: &ToolResponse, theme: &str) { - default_response_renderer(tool_response, theme); - } -} - -fn print_markdown(content: &str, theme: &str) { - bat::PrettyPrinter::new() - .input(bat::Input::from_bytes(content.as_bytes())) - .theme(theme) - .language("Markdown") - .wrapping_mode(WrappingMode::Character) - .print() - .unwrap(); -} - -fn print(content: &str, theme: &str) { - bat::PrettyPrinter::new() - .input(bat::Input::from_bytes(content.as_bytes())) - .theme(theme) - .language("Markdown") - .wrapping_mode(WrappingMode::Character) - .print() - .unwrap(); -} - -/// Format and print parameters recursively with proper indentation and colors -fn print_params(value: &Value, depth: usize) { - let indent = INDENT.repeat(depth); - - match value { - Value::Object(map) => { - for (key, val) in map { - match val { - Value::Object(_) => { - println!("{}{}:", indent, style(key).dim()); - print_params(val, depth + 1); - } - Value::Array(arr) => { - println!("{}{}:", indent, style(key).dim()); - for item in arr.iter() { - println!("{}{}- ", indent, INDENT); - print_params(item, depth + 2); - } - } - Value::String(s) => { - if s.len() > MAX_STRING_LENGTH { - println!("{}{}: {}", indent, style(key).dim(), style("...").dim()); - } else { - println!("{}{}: {}", indent, style(key).dim(), style(s).green()); - } - } - Value::Number(n) => { - println!("{}{}: {}", indent, style(key).dim(), style(n).blue()); - } - Value::Bool(b) => { - println!("{}{}: {}", indent, style(key).dim(), style(b).blue()); - } - Value::Null => { - println!("{}{}: {}", indent, style(key).dim(), style("null").dim()); - } - } - } - } - Value::Array(arr) => { - for (i, item) in arr.iter().enumerate() { - println!("{}{}.", indent, i + 1); - print_params(item, depth + 1); - } - } - Value::String(s) => { - if s.len() > MAX_STRING_LENGTH { - println!( - "{}{}", - indent, - style(format!("[REDACTED: {} chars]", s.len())).yellow() - ); - } else { - println!("{}{}", indent, style(s).green()); - } - } - Value::Number(n) => { - println!("{}{}", indent, style(n).yellow()); - } - Value::Bool(b) => { - println!("{}{}", indent, style(b).yellow()); - } - Value::Null => { - println!("{}{}", indent, style("null").dim()); - } - } -} - -fn print_newline() { - println!(); -} - impl Prompt for RustylinePrompt { fn render(&mut self, message: Box) { - let theme = match self.theme { - Theme::Light => "GitHub", - Theme::Dark => "zenburn", - }; - - let mut last_tool_name: &str = "default"; - for message_content in &message.content { - match message_content { - MessageContent::Text(text) => print_markdown(&text.text, theme), - MessageContent::ToolRequest(tool_request) => match &tool_request.tool_call { - Ok(call) => { - last_tool_name = &call.name; - self.renderers - .get(&call.name) - .or_else(|| self.renderers.get("default")) - .unwrap() - .request(tool_request, theme); - } - Err(_) => self - .renderers - .get("default") - .unwrap() - .request(tool_request, theme), - }, - MessageContent::ToolResponse(tool_response) => self - .renderers - .get(last_tool_name) - .or_else(|| self.renderers.get("default")) - .unwrap() - .response(tool_response, theme), - MessageContent::Image(image) => { - println!("Image: [data: {}, type: {}]", image.data, image.mime_type); - } - } - } - - print_newline(); - io::stdout().flush().expect("Failed to flush stdout"); + render(message, &self.theme, self.renderers.clone()); } fn show_busy(&mut self) {