feat: simplify CLI sessions (#1168)

This commit is contained in:
Bradley Axen
2025-02-11 21:16:58 -08:00
committed by GitHub
parent 6220ef054f
commit 7a8552ed9e
17 changed files with 1247 additions and 1281 deletions
+140
View File
@@ -0,0 +1,140 @@
use console::style;
use goose::agents::extension::ExtensionError;
use goose::agents::AgentFactory;
use goose::config::{Config, ExtensionManager};
use mcp_client::transport::Error as McpClientError;
use std::path::PathBuf;
use std::process;
use super::output;
use super::storage;
use super::Session;
pub async fn build_session(
name: Option<String>,
resume: bool,
extensions: Vec<String>,
builtins: Vec<String>,
) -> Session {
// Load config and get provider/model
let config = Config::global();
let provider_name: String = config
.get("GOOSE_PROVIDER")
.expect("No provider configured. Run 'goose configure' first");
let session_dir = storage::ensure_session_dir().expect("Failed to create session directory");
let model: String = config
.get("GOOSE_MODEL")
.expect("No model configured. Run 'goose configure' first");
let model_config = goose::model::ModelConfig::new(model.clone());
let provider =
goose::providers::create(&provider_name, model_config).expect("Failed to create provider");
// Create the agent
let agent_version: Option<String> = config.get("GOOSE_AGENT").ok();
let mut agent = match agent_version {
Some(version) => AgentFactory::create(&version, provider),
None => AgentFactory::create(AgentFactory::default_version(), provider),
}
.expect("Failed to create agent");
// Setup extensions for the agent
for extension in ExtensionManager::get_all().expect("should load extensions") {
if extension.enabled {
let config = extension.config.clone();
agent
.add_extension(config.clone())
.await
.unwrap_or_else(|e| {
let err = match e {
ExtensionError::Transport(McpClientError::StdioProcessError(inner)) => {
inner
}
_ => e.to_string(),
};
println!("Failed to start extension: {}, {:?}", config.name(), err);
println!(
"Please check extension configuration for {}.",
config.name()
);
process::exit(1);
});
}
}
// Handle session file resolution and resuming
let session_file = if resume {
if let Some(ref session_name) = name {
// Try to resume specific named session
let session_file = session_dir.join(format!("{}.jsonl", session_name));
if !session_file.exists() {
output::render_error(&format!(
"Cannot resume session {} - no such session exists",
style(session_name).cyan()
));
process::exit(1);
}
session_file
} else {
// Try to resume most recent session
match storage::get_most_recent_session() {
Ok(file) => file,
Err(_) => {
output::render_error("Cannot resume - no previous sessions found");
process::exit(1);
}
}
}
} else {
// Create new session with provided or generated name
let session_name = name.unwrap_or_else(generate_session_name);
create_new_session_file(&session_dir, &session_name)
};
// Create new session
let mut session = Session::new(agent, session_file.clone());
// Add extensions if provided
for extension_str in extensions {
if let Err(e) = session.add_extension(extension_str).await {
eprintln!("Failed to start extension: {}", e);
process::exit(1);
}
}
// Add builtin extensions
for builtin in builtins {
if let Err(e) = session.add_builtin(builtin).await {
eprintln!("Failed to start builtin extension: {}", e);
process::exit(1);
}
}
// Add CLI-specific system prompt extension
session
.agent
.extend_system_prompt(super::prompt::get_cli_prompt())
.await;
output::display_session_info(resume, &provider_name, &model, &session_file);
session
}
fn generate_session_name() -> String {
use rand::{distributions::Alphanumeric, Rng};
rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(8)
.map(char::from)
.collect()
}
fn create_new_session_file(session_dir: &std::path::Path, name: &str) -> PathBuf {
let session_file = session_dir.join(format!("{}.jsonl", name));
if session_file.exists() {
eprintln!("Session '{}' already exists", name);
process::exit(1);
}
session_file
}
+152
View File
@@ -0,0 +1,152 @@
use anyhow::Result;
use rustyline::Editor;
#[derive(Debug)]
pub enum InputResult {
Message(String),
Exit,
AddExtension(String),
AddBuiltin(String),
ToggleTheme,
Retry,
}
pub fn get_input(
editor: &mut Editor<(), 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
if !input.trim().is_empty() {
editor.add_history_entry(input.as_str())?;
}
// Handle non-slash commands first
if !input.starts_with('/') {
if input.eq_ignore_ascii_case("exit") || input.eq_ignore_ascii_case("quit") {
return Ok(InputResult::Exit);
}
return Ok(InputResult::Message(input.trim().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();
match input {
"/exit" | "/quit" => Some(InputResult::Exit),
"/?" | "/help" => {
print_help();
Some(InputResult::Retry)
}
"/t" => Some(InputResult::ToggleTheme),
s if s.starts_with("/extension ") => Some(InputResult::AddExtension(s[11..].to_string())),
s if s.starts_with("/builtin ") => Some(InputResult::AddBuiltin(s[9..].to_string())),
_ => None,
}
}
fn print_help() {
println!(
"Available commands:
/exit or /quit - Exit the session
/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)
/? or /help - Display this help message
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 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");
}
}
}
+302
View File
@@ -0,0 +1,302 @@
mod builder;
mod input;
mod output;
mod prompt;
mod storage;
mod thinking;
pub use builder::build_session;
use anyhow::Result;
use goose::agents::extension::{Envs, ExtensionConfig};
use goose::agents::Agent;
use goose::message::{Message, MessageContent};
use mcp_core::handler::ToolError;
use rand::{distributions::Alphanumeric, Rng};
use std::path::PathBuf;
use tokio;
use crate::log_usage::log_usage;
pub struct Session {
agent: Box<dyn Agent>,
messages: Vec<Message>,
session_file: PathBuf,
}
impl Session {
pub fn new(agent: Box<dyn Agent>, session_file: PathBuf) -> Self {
let messages = match storage::read_messages(&session_file) {
Ok(msgs) => msgs,
Err(e) => {
eprintln!("Warning: Failed to load message history: {}", e);
Vec::new()
}
};
Session {
agent,
messages,
session_file,
}
}
/// Add a stdio extension to the session
///
/// # Arguments
/// * `extension_command` - Full command string including environment variables
/// Format: "ENV1=val1 ENV2=val2 command args..."
pub async fn add_extension(&mut self, extension_command: String) -> Result<()> {
let mut parts: Vec<&str> = extension_command.split_whitespace().collect();
let mut envs = std::collections::HashMap::new();
// Parse environment variables (format: KEY=value)
while let Some(part) = parts.first() {
if !part.contains('=') {
break;
}
let env_part = parts.remove(0);
let (key, value) = env_part.split_once('=').unwrap();
envs.insert(key.to_string(), value.to_string());
}
if parts.is_empty() {
return Err(anyhow::anyhow!("No command provided in extension string"));
}
let cmd = parts.remove(0).to_string();
// Generate a random name for the ephemeral extension
let name: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(8)
.map(char::from)
.collect();
let config = ExtensionConfig::Stdio {
name,
cmd,
args: parts.iter().map(|s| s.to_string()).collect(),
envs: Envs::new(envs),
};
self.agent
.add_extension(config)
.await
.map_err(|e| anyhow::anyhow!("Failed to start extension: {}", e))
}
/// Add a builtin extension to the session
///
/// # Arguments
/// * `builtin_name` - Name of the builtin extension(s), comma separated
pub async fn add_builtin(&mut self, builtin_name: String) -> Result<()> {
for name in builtin_name.split(',') {
let config = ExtensionConfig::Builtin {
name: name.trim().to_string(),
};
self.agent
.add_extension(config)
.await
.map_err(|e| anyhow::anyhow!("Failed to start builtin extension: {}", e))?;
}
Ok(())
}
pub async fn start(&mut self) -> Result<()> {
let mut editor = rustyline::Editor::<(), rustyline::history::DefaultHistory>::new()?;
// Load history from messages
for msg in self
.messages
.iter()
.filter(|m| m.role == mcp_core::role::Role::User)
{
for content in msg.content.iter() {
if let Some(text) = content.as_text() {
if let Err(e) = editor.add_history_entry(text) {
eprintln!("Warning: Failed to add history entry: {}", e);
}
}
}
}
output::display_greeting();
loop {
match input::get_input(&mut editor)? {
input::InputResult::Message(content) => {
self.messages.push(Message::user().with_text(&content));
storage::persist_messages(&self.session_file, &self.messages)?;
output::show_thinking();
self.process_agent_response().await?;
output::hide_thinking();
}
input::InputResult::Exit => break,
input::InputResult::AddExtension(cmd) => {
match self.add_extension(cmd.clone()).await {
Ok(_) => output::render_extension_success(&cmd),
Err(e) => output::render_extension_error(&cmd, &e.to_string()),
}
}
input::InputResult::AddBuiltin(names) => {
match self.add_builtin(names.clone()).await {
Ok(_) => output::render_builtin_success(&names),
Err(e) => output::render_builtin_error(&names, &e.to_string()),
}
}
input::InputResult::ToggleTheme => {
let current = output::get_theme();
let new_theme = match current {
output::Theme::Light => {
println!("Switching to Dark theme");
output::Theme::Dark
}
output::Theme::Dark => {
println!("Switching to Ansi theme");
output::Theme::Ansi
}
output::Theme::Ansi => {
println!("Switching to Light theme");
output::Theme::Light
}
};
output::set_theme(new_theme);
continue;
}
input::InputResult::Retry => continue,
}
}
// Log usage and cleanup
let usage = self.agent.usage().await;
log_usage(self.session_file.to_string_lossy().to_string(), usage);
println!(
"\nClosing session. Recorded to {}",
self.session_file.display()
);
Ok(())
}
pub async fn headless_start(&mut self, initial_message: String) -> Result<()> {
self.messages
.push(Message::user().with_text(&initial_message));
storage::persist_messages(&self.session_file, &self.messages)?;
self.process_agent_response().await?;
Ok(())
}
async fn process_agent_response(&mut self) -> Result<()> {
let mut stream = self.agent.reply(&self.messages).await?;
use futures::StreamExt;
loop {
tokio::select! {
result = stream.next() => {
match result {
Some(Ok(message)) => {
self.messages.push(message.clone());
storage::persist_messages(&self.session_file, &self.messages)?;
output::hide_thinking();
output::render_message(&message);
output::show_thinking();
}
Some(Err(e)) => {
eprintln!("Error: {}", e);
drop(stream);
self.handle_interrupted_messages(false);
output::render_error(
"The error above was an exception we were not able to handle.\n\
These errors are often related to connection or authentication\n\
We've removed the conversation up to the most recent user message\n\
- depending on the error you may be able to continue",
);
break;
}
None => break,
}
}
_ = tokio::signal::ctrl_c() => {
drop(stream);
self.handle_interrupted_messages(true);
break;
}
}
}
Ok(())
}
fn handle_interrupted_messages(&mut self, interrupt: bool) {
// First, get any tool requests from the last message if it exists
let tool_requests = self
.messages
.last()
.filter(|msg| msg.role == mcp_core::role::Role::Assistant)
.map_or(Vec::new(), |msg| {
msg.content
.iter()
.filter_map(|content| {
if let MessageContent::ToolRequest(req) = content {
Some((req.id.clone(), req.tool_call.clone()))
} else {
None
}
})
.collect()
});
if !tool_requests.is_empty() {
// Interrupted during a tool request
// Create tool responses for all interrupted tool requests
let mut response_message = Message::user();
let last_tool_name = tool_requests
.last()
.and_then(|(_, tool_call)| tool_call.as_ref().ok().map(|tool| tool.name.clone()))
.unwrap_or_else(|| "tool".to_string());
let notification = if interrupt {
"Interrupted by the user to make a correction".to_string()
} else {
"An uncaught error happened during tool use".to_string()
};
for (req_id, _) in &tool_requests {
response_message.content.push(MessageContent::tool_response(
req_id.clone(),
Err(ToolError::ExecutionError(notification.clone())),
));
}
self.messages.push(response_message);
let prompt = format!(
"The existing call to {} was interrupted. How would you like to proceed?",
last_tool_name
);
self.messages.push(Message::assistant().with_text(&prompt));
output::render_message(&Message::assistant().with_text(&prompt));
} else {
// An interruption occurred outside of a tool request-response.
if let Some(last_msg) = self.messages.last() {
if last_msg.role == mcp_core::role::Role::User {
match last_msg.content.first() {
Some(MessageContent::ToolResponse(_)) => {
// Interruption occurred after a tool had completed but not assistant reply
let prompt = "The tool calling loop was interrupted. How would you like to proceed?";
self.messages.push(Message::assistant().with_text(prompt));
output::render_message(&Message::assistant().with_text(prompt));
}
Some(_) => {
// A real users message
self.messages.pop();
let prompt = "Interrupted before the model replied and removed the last message.";
output::render_message(&Message::assistant().with_text(prompt));
}
None => panic!("No content in last message"),
}
}
}
}
}
pub fn session_file(&self) -> PathBuf {
self.session_file.clone()
}
}
+457
View File
@@ -0,0 +1,457 @@
use bat::WrappingMode;
use console::style;
use goose::message::{Message, MessageContent, ToolRequest, ToolResponse};
use mcp_core::tool::ToolCall;
use serde_json::Value;
use std::cell::RefCell;
use std::path::Path;
// Re-export theme for use in main
#[derive(Clone, Copy)]
pub enum Theme {
Light,
Dark,
Ansi,
}
impl Theme {
fn as_str(&self) -> &'static str {
match self {
Theme::Light => "GitHub",
Theme::Dark => "zenburn",
Theme::Ansi => "base16",
}
}
}
thread_local! {
static CURRENT_THEME: RefCell<Theme> = RefCell::new(
std::env::var("GOOSE_CLI_THEME")
.ok()
.map(|val| {
if val.eq_ignore_ascii_case("light") {
Theme::Light
} else if val.eq_ignore_ascii_case("ansi") {
Theme::Ansi
} else {
Theme::Dark
}
})
.unwrap_or(Theme::Dark)
);
}
pub fn set_theme(theme: Theme) {
CURRENT_THEME.with(|t| *t.borrow_mut() = theme);
}
pub fn get_theme() -> Theme {
CURRENT_THEME.with(|t| *t.borrow())
}
// Simple wrapper around spinner to manage its state
#[derive(Default)]
pub struct ThinkingIndicator {
spinner: Option<cliclack::ProgressBar>,
}
impl ThinkingIndicator {
pub fn show(&mut self) {
let spinner = cliclack::spinner();
spinner.start(format!(
"{}...",
super::thinking::get_random_thinking_message()
));
self.spinner = Some(spinner);
}
pub fn hide(&mut self) {
if let Some(spinner) = self.spinner.take() {
spinner.stop("");
}
}
}
// Global thinking indicator
thread_local! {
static THINKING: RefCell<ThinkingIndicator> = RefCell::new(ThinkingIndicator::default());
}
pub fn show_thinking() {
THINKING.with(|t| t.borrow_mut().show());
}
pub fn hide_thinking() {
THINKING.with(|t| t.borrow_mut().hide());
}
pub fn render_message(message: &Message) {
let theme = get_theme();
for content in &message.content {
match content {
MessageContent::Text(text) => print_markdown(&text.text, theme),
MessageContent::ToolRequest(req) => render_tool_request(req, theme),
MessageContent::ToolResponse(resp) => render_tool_response(resp, theme),
MessageContent::Image(image) => {
println!("Image: [data: {}, type: {}]", image.data, image.mime_type);
}
}
}
println!();
}
fn render_tool_request(req: &ToolRequest, theme: Theme) {
match &req.tool_call {
Ok(call) => match call.name.as_str() {
"developer__text_editor" => render_text_editor_request(call),
"developer__shell" => render_shell_request(call),
_ => render_default_request(call),
},
Err(e) => print_markdown(&e.to_string(), theme),
}
}
fn render_tool_response(resp: &ToolResponse, theme: Theme) {
match &resp.tool_result {
Ok(contents) => {
for content in contents {
if let Some(audience) = content.audience() {
if !audience.contains(&mcp_core::role::Role::User) {
continue;
}
}
let min_priority = std::env::var("GOOSE_CLI_MIN_PRIORITY")
.ok()
.and_then(|val| val.parse::<f32>().ok())
.unwrap_or(0.0);
if content
.priority()
.is_some_and(|priority| priority <= min_priority)
|| content.priority().is_none()
{
continue;
}
if let mcp_core::content::Content::Text(text) = content {
print_markdown(&text.text, theme);
}
}
}
Err(e) => print_markdown(&e.to_string(), theme),
}
}
pub fn render_error(message: &str) {
println!("\n {} {}\n", style("error:").red().bold(), message);
}
pub fn render_extension_success(name: &str) {
println!();
println!(
" {} extension `{}`",
style("added").green(),
style(name).cyan(),
);
println!();
}
pub fn render_extension_error(name: &str, error: &str) {
println!();
println!(
" {} to add extension {}",
style("failed").red(),
style(name).red()
);
println!();
println!("{}", style(error).dim());
println!();
}
pub fn render_builtin_success(names: &str) {
println!();
println!(
" {} builtin{}: {}",
style("added").green(),
if names.contains(',') { "s" } else { "" },
style(names).cyan()
);
println!();
}
pub fn render_builtin_error(names: &str, error: &str) {
println!();
println!(
" {} to add builtin{}: {}",
style("failed").red(),
if names.contains(',') { "s" } else { "" },
style(names).red()
);
println!();
println!("{}", style(error).dim());
println!();
}
fn render_text_editor_request(call: &ToolCall) {
print_tool_header(call);
// Print path first with special formatting
if let Some(Value::String(path)) = call.arguments.get("path") {
println!(
"{}: {}",
style("path").dim(),
style(shorten_path(path)).green()
);
}
// Print other arguments normally, excluding path
if let Some(args) = call.arguments.as_object() {
let mut other_args = serde_json::Map::new();
for (k, v) in args {
if k != "path" {
other_args.insert(k.clone(), v.clone());
}
}
print_params(&Value::Object(other_args), 0);
}
println!();
}
fn render_shell_request(call: &ToolCall) {
print_tool_header(call);
match call.arguments.get("command") {
Some(Value::String(s)) => {
println!("{}: {}", style("command").dim(), style(s).green());
}
_ => print_params(&call.arguments, 0),
}
println!();
}
fn render_default_request(call: &ToolCall) {
print_tool_header(call);
print_params(&call.arguments, 0);
println!();
}
// Helper functions
fn print_tool_header(call: &ToolCall) {
let parts: Vec<_> = call.name.rsplit("__").collect();
let tool_header = format!(
"─── {} | {} ──────────────────────────",
style(parts.first().unwrap_or(&"unknown")),
style(
parts
.split_first()
.map(|(_, s)| s.iter().rev().copied().collect::<Vec<_>>().join("__"))
.unwrap_or_else(|| "unknown".to_string())
)
.magenta()
.dim(),
);
println!();
println!("{}", tool_header);
}
fn print_markdown(content: &str, theme: Theme) {
bat::PrettyPrinter::new()
.input(bat::Input::from_bytes(content.as_bytes()))
.theme(theme.as_str())
.language("Markdown")
.wrapping_mode(WrappingMode::Character)
.print()
.unwrap();
}
const MAX_STRING_LENGTH: usize = 40;
const INDENT: &str = " ";
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 shorten_path(path: &str) -> String {
let path = Path::new(path);
// First try to convert to ~ if it's in home directory
let home = etcetera::home_dir().ok();
let path_str = if let Some(home) = home {
if let Ok(stripped) = path.strip_prefix(home) {
format!("~/{}", stripped.display())
} else {
path.display().to_string()
}
} else {
path.display().to_string()
};
// If path is already short enough, return as is
if path_str.len() <= 60 {
return path_str;
}
let parts: Vec<_> = path_str.split('/').collect();
// If we have 3 or fewer parts, return as is
if parts.len() <= 3 {
return path_str;
}
// Keep the first component (empty string before root / or ~) and last two components intact
let mut shortened = vec![parts[0].to_string()];
// Shorten middle components to their first letter
for component in &parts[1..parts.len() - 2] {
if !component.is_empty() {
shortened.push(component.chars().next().unwrap_or('?').to_string());
}
}
// Add the last two components
shortened.push(parts[parts.len() - 2].to_string());
shortened.push(parts[parts.len() - 1].to_string());
shortened.join("/")
}
// Session display functions
pub fn display_session_info(resume: bool, provider: &str, model: &str, session_file: &Path) {
let start_session_msg = if resume {
"resuming session |"
} else {
"starting session |"
};
println!(
"{} {} {} {} {}",
style(start_session_msg).dim(),
style("provider:").dim(),
style(provider).cyan().dim(),
style("model:").dim(),
style(model).cyan().dim(),
);
println!(
" {} {}",
style("logging to").dim(),
style(session_file.display()).dim().cyan(),
);
}
pub fn display_greeting() {
println!("\nGoose is running! Enter your instructions, or try asking what goose can do.\n");
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_short_paths_unchanged() {
assert_eq!(shorten_path("/usr/bin"), "/usr/bin");
assert_eq!(shorten_path("/a/b/c"), "/a/b/c");
assert_eq!(shorten_path("file.txt"), "file.txt");
}
#[test]
fn test_home_directory_conversion() {
// Save the current home dir
let original_home = env::var("HOME").ok();
// Set a test home directory
env::set_var("HOME", "/Users/testuser");
assert_eq!(
shorten_path("/Users/testuser/documents/file.txt"),
"~/documents/file.txt"
);
// A path that starts similarly to home but isn't in home
assert_eq!(
shorten_path("/Users/testuser2/documents/file.txt"),
"/Users/testuser2/documents/file.txt"
);
// Restore the original home dir
if let Some(home) = original_home {
env::set_var("HOME", home);
} else {
env::remove_var("HOME");
}
}
#[test]
fn test_long_path_shortening() {
assert_eq!(
shorten_path(
"/vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv/long/path/with/many/components/file.txt"
),
"/v/l/p/w/m/components/file.txt"
);
}
}
+16
View File
@@ -0,0 +1,16 @@
/// Returns a system prompt extension that explains CLI-specific functionality
pub fn get_cli_prompt() -> String {
String::from(
"You are being accessed through a command-line interface. The following slash commands are available
- you can let the user know about them if they need help:
- /exit or /quit - Exit the session
- /t - Toggle between Light/Dark/Ansi themes
- /? or /help - Display help message
Additional keyboard shortcuts:
- Ctrl+C - Interrupt the current interaction (resets to before the interrupted request)
- Ctrl+J - Add a newline
- Up/Down arrows - Navigate command history"
)
}
+165
View File
@@ -0,0 +1,165 @@
use anyhow::Result;
use etcetera::{choose_app_strategy, AppStrategy};
use goose::message::Message;
use std::fs::{self, File};
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
/// Ensure the session directory exists and return its path
pub fn ensure_session_dir() -> Result<PathBuf> {
let data_dir = choose_app_strategy(crate::APP_STRATEGY.clone())
.expect("goose requires a home dir")
.data_dir()
.join("sessions");
if !data_dir.exists() {
fs::create_dir_all(&data_dir)?;
}
Ok(data_dir)
}
/// Get the path to the most recently modified session file
pub fn get_most_recent_session() -> Result<PathBuf> {
let session_dir = ensure_session_dir()?;
let mut entries = fs::read_dir(&session_dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "jsonl"))
.collect::<Vec<_>>();
if entries.is_empty() {
return Err(anyhow::anyhow!("No session files found"));
}
// Sort by modification time, most recent first
entries.sort_by(|a, b| {
b.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
.cmp(
&a.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH),
)
});
Ok(entries[0].path())
}
/// Read messages from a session file
///
/// Creates the file if it doesn't exist, reads and deserializes all messages if it does.
pub fn read_messages(session_file: &Path) -> Result<Vec<Message>> {
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(session_file)?;
let reader = io::BufReader::new(file);
let mut messages = Vec::new();
for line in reader.lines() {
messages.push(serde_json::from_str::<Message>(&line?)?);
}
Ok(messages)
}
/// Write messages to a session file
///
/// Overwrites the file with all messages in JSONL format.
pub fn persist_messages(session_file: &Path, messages: &[Message]) -> Result<()> {
let file = File::create(session_file)?;
let mut writer = io::BufWriter::new(file);
for message in messages {
serde_json::to_writer(&mut writer, &message)?;
writeln!(writer)?;
}
writer.flush()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use goose::message::MessageContent;
use tempfile::tempdir;
#[test]
fn test_read_write_messages() -> Result<()> {
let dir = tempdir()?;
let file_path = dir.path().join("test.jsonl");
// Create some test messages
let messages = vec![
Message::user().with_text("Hello"),
Message::assistant().with_text("Hi there"),
];
// Write messages
persist_messages(&file_path, &messages)?;
// Read them back
let read_messages = read_messages(&file_path)?;
// Compare
assert_eq!(messages.len(), read_messages.len());
for (orig, read) in messages.iter().zip(read_messages.iter()) {
assert_eq!(orig.role, read.role);
assert_eq!(orig.content.len(), read.content.len());
// Compare first text content
if let (Some(MessageContent::Text(orig_text)), Some(MessageContent::Text(read_text))) =
(orig.content.first(), read.content.first())
{
assert_eq!(orig_text.text, read_text.text);
} else {
panic!("Messages don't match expected structure");
}
}
Ok(())
}
#[test]
fn test_empty_file() -> Result<()> {
let dir = tempdir()?;
let file_path = dir.path().join("empty.jsonl");
// Reading an empty file should return empty vec
let messages = read_messages(&file_path)?;
assert!(messages.is_empty());
Ok(())
}
#[test]
fn test_get_most_recent() -> Result<()> {
let dir = tempdir()?;
let base_path = dir.path().join("sessions");
fs::create_dir_all(&base_path)?;
// Create a few session files with different timestamps
let old_file = base_path.join("old.jsonl");
let new_file = base_path.join("new.jsonl");
// Create files with some delay to ensure different timestamps
fs::write(&old_file, "dummy content")?;
std::thread::sleep(std::time::Duration::from_secs(1));
fs::write(&new_file, "dummy content")?;
// Override the home directory for testing
// This is a bit hacky but works for testing
std::env::set_var("HOME", dir.path());
if let Ok(most_recent) = get_most_recent_session() {
assert_eq!(most_recent.file_name().unwrap(), "new.jsonl");
}
Ok(())
}
}
+220
View File
@@ -0,0 +1,220 @@
use rand::seq::SliceRandom;
/// Extended list of playful thinking messages including both goose and general AI actions
const THINKING_MESSAGES: &[&str] = &[
"Spreading wings",
"Honking thoughtfully",
"Waddling to conclusions",
"Flapping wings excitedly",
"Preening code feathers",
"Gathering digital breadcrumbs",
"Paddling through data",
"Migrating thoughts",
"Nesting ideas",
"Squawking calculations",
"Ruffling algorithmic feathers",
"Pecking at problems",
"Stretching webbed feet",
"Foraging for solutions",
"Grooming syntax",
"Building digital nest",
"Patrolling the codebase",
"Gosling about",
"Strutting with purpose",
"Diving for answers",
"Herding bytes",
"Molting old code",
"Swimming through streams",
"Synchronizing flock algorithms",
"Navigating code marshes",
"Incubating brilliant ideas",
"Arranging feathers recursively",
"Gliding through branches",
"Migrating to better solutions",
"Nesting functions carefully",
"Hatching clever solutions",
"Preening parse trees",
"Flying through functions",
"Gathering syntax seeds",
"Webbing connections",
"Flocking to optimizations",
"Paddling through protocols",
"Honking success signals",
"Waddling through workflows",
"Nesting in neural networks",
"Consulting the digital oracle",
"Summoning binary spirits",
"Reticulating splines",
"Calculating meaning of life",
"Traversing neural pathways",
"Untangling spaghetti code",
"Mining thought gems",
"Defragmenting brain bits",
"Compiling wisdom",
"Debugging reality",
"Optimizing thought processes",
"Scanning parallel universes",
"Reorganizing bits and bytes",
"Calibrating neural networks",
"Charging creativity cells",
"Indexing imagination",
"Parsing possibilities",
"Buffering brilliance",
"Loading clever responses",
"Generating witty remarks",
"Synthesizing solutions",
"Applying machine learning",
"Calculating quantum states",
"Analyzing algorithms",
"Decoding human intent",
"Exploring solution space",
"Gathering computational momentum",
"Initializing clever mode",
"Juggling variables",
"Knitting neural networks",
"Learning at light speed",
"Navigating knowledge graphs",
"Orchestrating outputs",
"Pondering possibilities",
"Reading between the lines",
"Searching solution space",
"Training thought vectors",
"Unfolding understanding",
"Validating variables",
"Weaving wisdom web",
"Yielding insights",
"Zooming through zettabytes",
"Baking fresh ideas",
"Charging creativity crystals",
"Dancing with data",
"Enchanting electrons",
"Folding thought origami",
"Growing solution trees",
"Harmonizing heuristics",
"Inspiring innovations",
"Jazzing up algorithms",
"Kindling knowledge",
"Levitating logic gates",
"Manifesting solutions",
"Nurturing neural nets",
"Optimizing outcomes",
"Painting with pixels",
"Questioning bits",
"Recycling random thoughts",
"Serenading semiconductors",
"Taming tensors",
"Unlocking understanding",
"Visualizing vectors",
"Wrangling widgets",
"Yodeling yaml",
"Aligning artificial awarenesses",
"Bootstrapping brain bytes",
"Contemplating code conundrums",
"Distilling digital dreams",
"Energizing electron engines",
"Fabricating future frameworks",
"Generating genius guidelines",
"Harmonizing hardware helpers",
"Illuminating input insights",
"Kindling knowledge kernels",
"Linking logical lattices",
"Materializing memory maps",
"Navigating neural nodes",
"Orchestrating output oracles",
"Pioneering program paths",
"Quantifying quantum queries",
"Refactoring reality routines",
"Synchronizing system states",
"Transforming thought threads",
"Unifying understanding units",
"Vectorizing virtual visions",
"Weaving wisdom wavelengths",
"Yielding yaml yearnings",
"Brewing binary brilliance",
"Crafting code crystals",
"Designing data dreams",
"Encoding ethereal elements",
"Filtering function flows",
"Gathering gigabyte galaxies",
"Hashing hope hypotheses",
"Igniting innovation ions",
"Joining joy journals",
"Knitting knowledge knots",
"Launching logic loops",
"Merging memory matrices",
"Nourishing neural networks",
"Ordering output orbits",
"Processing pattern particles",
"Rendering reality rays",
"Streaming syntax stars",
"Threading thought theories",
"Updating understanding units",
"Validating virtual vectors",
"Warming wisdom waves",
"Examining electron echoes",
"Yoking yesterday yields",
"Assembling algorithm arrays",
"Balancing binary bridges",
"Calculating cosmic codes",
"Debugging dream drivers",
"Encrypting ethereal edges",
"Formatting future frames",
"Growing gradient gardens",
"Harvesting hash harmonies",
"Importing insight ions",
"Keeping kernel keys",
"Linking lambda loops",
"Mapping memory mazes",
"Normalizing neural nodes",
"Organizing output oceans",
"Parsing pattern paths",
"Sampling syntax streams",
"Testing thought threads",
"Validating virtual vectors",
"Examining electron echoes",
"Accelerating abstract algebras",
"Buffering binary bubbles",
"Caching cosmic calculations",
"Deploying digital dreams",
"Evolving ethereal entities",
"Calculating response probabilities",
"Updating knowledge graphs",
"Processing neural feedback",
"Exploring decision trees",
"Measuring semantic distance",
"Connecting synaptic pathways",
"Evaluating response options",
"Scanning memory banks",
"Simulating future outcomes",
"Adjusting confidence weights",
"Mapping context vectors",
"Balancing response parameters",
"Running inference engines",
"Optimizing memory usage",
"Merging knowledge streams",
"Calibrating response tone",
"Analyzing input patterns",
"Processing feedback loops",
"Measuring response quality",
"Scanning information matrices",
"Processing user intent",
"Measuring response coherence",
"Exploring solution paths",
"Processing context clues",
"Scanning memory circuits",
"Building response chains",
"Analyzing conversation flow",
"Processing temporal data",
"Exploring concept spaces",
"Processing memory streams",
"Evaluating logical paths",
"Building thought graphs",
"Scanning neural pathways",
];
/// Returns a random thinking message from the extended list
pub fn get_random_thinking_message() -> &'static str {
THINKING_MESSAGES
.choose(&mut rand::thread_rng())
.unwrap_or(&THINKING_MESSAGES[0])
}