Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/goose-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ webbrowser = "1.0"

indicatif = "0.17.11"
urlencoding = "2"
ratatui = "0.30.0-alpha.4"
crossterm = "0.27"

[target.'cfg(target_os = "windows")'.dependencies]
winapi = { version = "0.3", features = ["wincred"] }
Expand Down
15 changes: 12 additions & 3 deletions crates/goose-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,9 +339,13 @@ enum Command {
value_name = "NAME",
help = "Add builtin extensions by name (e.g., 'developer' or multiple: 'developer,github')",
long_help = "Add one or more builtin extensions that are bundled with goose by specifying their names, comma-separated",
value_delimiter = ','
value_delimiter = ',',
)]
builtins: Vec<String>,

/// Launch the interactive session using a full-screen TUI (powered by ratatui)
#[arg(long, help = "Use experimental TUI interface instead of line-by-line mode")]
tui: bool,
},

/// Open the last project directory
Expand Down Expand Up @@ -627,6 +631,7 @@ pub async fn cli() -> Result<()> {
extensions,
remote_extensions,
builtins,
tui,
}) => {
return match command {
Some(SessionCommand::List {
Expand Down Expand Up @@ -659,7 +664,7 @@ pub async fn cli() -> Result<()> {
Ok(())
}
None => {
// Run session command by default
// Run session command by default (either standard REPL or TUI)
let mut session: crate::Session = build_session(SessionBuilderConfig {
identifier: identifier.map(extract_identifier),
resume,
Expand Down Expand Up @@ -687,7 +692,11 @@ pub async fn cli() -> Result<()> {
session.render_message_history();
}

let _ = session.interactive(None).await;
if tui {
let _ = session.interactive_tui(None).await;
} else {
let _ = session.interactive(None).await;
}
Ok(())
}
};
Expand Down
13 changes: 13 additions & 0 deletions crates/goose-cli/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod input;
mod output;
mod prompt;
mod thinking;
pub mod tui;

pub use self::export::message_to_markdown;
pub use builder::{build_session, SessionBuilderConfig, SessionSettings};
Expand Down Expand Up @@ -1300,6 +1301,18 @@ impl Session {

Ok(path)
}

/// Start an interactive TUI session using `ratatui`.
pub async fn interactive_tui(&mut self, message: Option<String>) -> Result<()> {
// Process initial message if provided so users see the conversation when the UI starts.
if let Some(msg) = message {
self.process_message(msg).await?;
}
// Build and run the TUI. The TUI owns a _mutable reference_ to `self` while running, so we
// need to construct it first and then await its future.
let tui = tui::GooseTui::new(self)?;
tui.run().await
}
}

fn get_reasoner() -> Result<Arc<dyn Provider>, anyhow::Error> {
Expand Down
165 changes: 165 additions & 0 deletions crates/goose-cli/src/session/tui.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
use anyhow::Result;
use crossterm::{
event::{self, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
Terminal,
};
use std::{
io::{self, Stdout},
time::Duration,
};
use mcp_core::role::Role;

/// Very small abstraction layer so we don't have to expose the whole `ratatui` types to the parent
/// modules. The struct just keeps the terminal alive while the TUI runs.
pub struct GooseTui<'a> {
terminal: Terminal<CrosstermBackend<Stdout>>,
/// Buffer holding the user input while they type
input: String,
/// Shared reference to an interactive [`crate::Session`]. Held as mutable reference so we can
/// push messages and request completions.
session: &'a mut crate::Session,
/// Scroll offset for the chat history panel
scroll: u16,
/// Stores the rendered text for each historical message. We keep things as simple `String`s for
/// now – every line break yields a new line on screen which is good enough for a first cut.
history: Vec<(String, bool /* is_user */)>,
}

impl<'a> GooseTui<'a> {
pub fn new(session: &'a mut crate::Session) -> Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(Self {
terminal,
input: String::new(),
session,
scroll: 0,
history: Vec::new(),
})
}

/// Consumes the TUI, restoring the terminal.
fn teardown(&mut self) -> Result<()> {
disable_raw_mode()?;
execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?;
self.terminal.show_cursor()?;
Ok(())
}

/// Run the TUI main loop. This will block until the user presses <Esc>.
pub async fn run(mut self) -> Result<()> {
loop {
// Draw UI
self.terminal.draw(|f| {
let size = f.size();

// Split screen into message area + input line (3 rows)
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(3)].as_ref())
.split(size);

// Render message history
let history_lines: Vec<Line> = self
.history
.iter()
.flat_map(|(line, is_user)| {
let clr = if *is_user { Color::Yellow } else { Color::White };
line.split('\n')
.map(move |l| {
Line::from(vec![Span::styled(
l.to_owned(),
Style::default().fg(clr),
)])
})
.collect::<Vec<_>>()
})
.collect();

let history_para = Paragraph::new(history_lines)
.block(Block::default().title("Messages").borders(Borders::ALL))
.wrap(Wrap { trim: false });
f.render_widget(history_para, chunks[0]);

// Render input area
let input_para = Paragraph::new(self.input.as_str())
.style(Style::default().fg(Color::Cyan))
.block(Block::default().title("Input (Esc to quit)").borders(Borders::ALL));
f.render_widget(input_para, chunks[1]);
// Put cursor at end of input buffer
let x = chunks[1].x + (self.input.len() as u16) + 1;
let y = chunks[1].y + 1;
#[allow(deprecated)]
{
f.set_cursor(x, y);
}
})?;

// Handle events
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char(c) => {
self.input.push(c);
}
KeyCode::Backspace => {
self.input.pop();
}
KeyCode::Enter => {
let user_msg = self.input.trim().to_string();
if !user_msg.is_empty() {
// Push to local history first so the user gets immediate feedback
self.history.push((format!("You: {}", &user_msg), true));

// Clear input buffer before awaiting async call so the UI remains responsive
self.input.clear();

// Run the agent interaction synchronously for now (will freeze UI briefly).
if let Err(e) = self.session.process_message(user_msg).await {
self.history.push((format!("Error: {}", e), false));
}

// After processing (successful or not), refresh from session's message history.
let new_msgs = self.session.message_history();
self.history = new_msgs
.iter()
.flat_map(|m| {
let mut lines = Vec::new();
let sender = match m.role {
Role::User => "You",
Role::Assistant => "Assistant",
};
let text_concat = m.as_concat_text();
for l in text_concat.split('\n') {
let is_user = matches!(m.role, Role::User);
lines.push((format!("{}: {}", sender, l), is_user));
}
lines
})
.collect();
}
}
KeyCode::Esc => {
break;
}
_ => {}
}
}
}
}

self.teardown()
}
}
96 changes: 95 additions & 1 deletion crates/goose/src/agents/tool_vectordb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;

use crate::config::Config;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolRecord {
pub tool_name: String,
Expand Down Expand Up @@ -53,7 +55,25 @@ impl ToolVectorDB {
Ok(tool_db)
}

fn get_db_path() -> Result<PathBuf> {
pub fn get_db_path() -> Result<PathBuf> {
let config = Config::global();

// Check for custom database path override
if let Ok(custom_path) = config.get_param::<String>("GOOSE_VECTOR_DB_PATH") {
let path = PathBuf::from(custom_path);

// Validate the path is absolute
if !path.is_absolute() {
return Err(anyhow::anyhow!(
"GOOSE_VECTOR_DB_PATH must be an absolute path, got: {}",
path.display()
));
}

return Ok(path);
}

// Fall back to default XDG-based path
let data_dir = Xdg::new()
.context("Failed to determine base strategy")?
.data_dir();
Expand Down Expand Up @@ -363,6 +383,7 @@ mod tests {
use super::*;

#[tokio::test]
#[serial_test::serial]
async fn test_tool_vectordb_creation() {
let db = ToolVectorDB::new(Some("test_tools_vectordb_creation".to_string()))
.await
Expand All @@ -372,6 +393,7 @@ mod tests {
}

#[tokio::test]
#[serial_test::serial]
async fn test_tool_vectordb_operations() -> Result<()> {
// Create a new database instance with a unique table name
let db = ToolVectorDB::new(Some("test_tool_vectordb_operations".to_string())).await?;
Expand Down Expand Up @@ -440,6 +462,7 @@ mod tests {
}

#[tokio::test]
#[serial_test::serial]
async fn test_empty_db() -> Result<()> {
// Create a new database instance with a unique table name
let db = ToolVectorDB::new(Some("test_empty_db".to_string())).await?;
Expand All @@ -458,6 +481,7 @@ mod tests {
}

#[tokio::test]
#[serial_test::serial]
async fn test_tool_deletion() -> Result<()> {
// Create a new database instance with a unique table name
let db = ToolVectorDB::new(Some("test_tool_deletion".to_string())).await?;
Expand Down Expand Up @@ -490,4 +514,74 @@ mod tests {

Ok(())
}

#[test]
#[serial_test::serial]
fn test_custom_db_path_override() -> Result<()> {
use std::env;
use tempfile::TempDir;

// Create a temporary directory for testing
let temp_dir = TempDir::new().unwrap();
let custom_path = temp_dir.path().join("custom_vector_db");

// Set the environment variable
env::set_var("GOOSE_VECTOR_DB_PATH", custom_path.to_str().unwrap());

// Test that get_db_path returns the custom path
let db_path = ToolVectorDB::get_db_path()?;
assert_eq!(db_path, custom_path);

// Clean up
env::remove_var("GOOSE_VECTOR_DB_PATH");

Ok(())
}

#[test]
#[serial_test::serial]
fn test_custom_db_path_validation() {
use std::env;

// Test that relative paths are rejected
env::set_var("GOOSE_VECTOR_DB_PATH", "relative/path");

let result = ToolVectorDB::get_db_path();
assert!(
result.is_err(),
"Expected error for relative path, got: {:?}",
result
);
assert!(result
.unwrap_err()
.to_string()
.contains("must be an absolute path"));

// Clean up
env::remove_var("GOOSE_VECTOR_DB_PATH");
}

#[test]
#[serial_test::serial]
fn test_fallback_to_default_path() -> Result<()> {
use std::env;

// Ensure no custom path is set
env::remove_var("GOOSE_VECTOR_DB_PATH");

// Test that it falls back to default XDG path
let db_path = ToolVectorDB::get_db_path()?;
assert!(
db_path.to_string_lossy().contains("goose"),
"Path should contain 'goose', got: {}",
db_path.display()
);
assert!(
db_path.to_string_lossy().contains("tool_db"),
"Path should contain 'tool_db', got: {}",
db_path.display()
);

Ok(())
}
}
Loading
Loading