From 5d040aaa6d2b90563edafdbd27048863c4e1a196 Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Wed, 3 Dec 2025 10:08:07 -0500 Subject: [PATCH] Minimal CLI using goose-server --- Cargo.lock | 1 + .../src/computercontroller/platform/linux.rs | 5 + .../computercontroller/platform/windows.rs | 1 + crates/goose-tui/Cargo.toml | 1 + crates/goose-tui/src/cli.rs | 503 ++++++++++++++++++ crates/goose-tui/src/components/info.rs | 4 +- crates/goose-tui/src/main.rs | 67 ++- crates/goose-tui/src/utils/mod.rs | 1 + crates/goose-tui/src/utils/spinner.rs | 1 + .../goose/src/providers/formats/databricks.rs | 4 +- 10 files changed, 576 insertions(+), 12 deletions(-) create mode 100644 crates/goose-tui/src/cli.rs create mode 100644 crates/goose-tui/src/utils/spinner.rs diff --git a/Cargo.lock b/Cargo.lock index b57795790e..d44f1452e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3173,6 +3173,7 @@ dependencies = [ "goose-server", "ratatui", "rmcp 0.9.1", + "rustyline", "serde", "serde_json", "termimad", diff --git a/crates/goose-mcp/src/computercontroller/platform/linux.rs b/crates/goose-mcp/src/computercontroller/platform/linux.rs index d0e78c0494..c48edbebd9 100644 --- a/crates/goose-mcp/src/computercontroller/platform/linux.rs +++ b/crates/goose-mcp/src/computercontroller/platform/linux.rs @@ -6,8 +6,10 @@ use std::path::PathBuf; use std::process::Command; use std::sync::Once; +#[allow(dead_code)] static INIT: Once = Once::new(); +#[allow(dead_code)] #[derive(Debug)] pub enum DisplayServer { X11, @@ -15,16 +17,19 @@ pub enum DisplayServer { Unknown, } +#[allow(dead_code)] pub struct LinuxAutomation { display_server: DisplayServer, } +#[allow(dead_code)] impl Default for LinuxAutomation { fn default() -> Self { Self::new() } } +#[allow(dead_code)] impl LinuxAutomation { pub fn new() -> Self { let automation = LinuxAutomation { diff --git a/crates/goose-mcp/src/computercontroller/platform/windows.rs b/crates/goose-mcp/src/computercontroller/platform/windows.rs index 8ea17f461c..75f8010350 100644 --- a/crates/goose-mcp/src/computercontroller/platform/windows.rs +++ b/crates/goose-mcp/src/computercontroller/platform/windows.rs @@ -2,6 +2,7 @@ use super::SystemAutomation; use std::path::PathBuf; use std::process::Command; +#[allow(dead_code)] pub struct WindowsAutomation; impl SystemAutomation for WindowsAutomation { diff --git a/crates/goose-tui/Cargo.toml b/crates/goose-tui/Cargo.toml index 0b3f3b0818..08a33d0203 100644 --- a/crates/goose-tui/Cargo.toml +++ b/crates/goose-tui/Cargo.toml @@ -35,6 +35,7 @@ unicode-width = "0.2.0" arboard = "3.6.1" ansi-to-tui = "7.0.0" git2 = "0.18" +rustyline = "15" [dev-dependencies] tokio = { version = "1.43", features = ["full"] } diff --git a/crates/goose-tui/src/cli.rs b/crates/goose-tui/src/cli.rs new file mode 100644 index 0000000000..588cbca6f7 --- /dev/null +++ b/crates/goose-tui/src/cli.rs @@ -0,0 +1,503 @@ +//! CLI mode for goose-tui +//! +//! Provides an interactive command-line interface as a fallback when TUI +//! is not suitable. Uses the same embedded server and goose-client as TUI mode. + +use crate::utils::spinner::SPINNER_FRAMES; +use anyhow::Result; +use goose::conversation::message::{Message, MessageContent, ToolConfirmationRequest}; +use goose_client::Client; +use goose_server::routes::reply::MessageEvent; +use rustyline::error::ReadlineError; +use rustyline::{Cmd, DefaultEditor, EventHandler, KeyCode, KeyEvent, Modifiers}; +use std::io::{stdout, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use tokio_stream::StreamExt; + +struct Spinner { + running: Arc, + handle: Option>, +} + +impl Spinner { + fn new() -> Self { + Self { + running: Arc::new(AtomicBool::new(false)), + handle: None, + } + } + + fn start(&mut self) { + self.running.store(true, Ordering::SeqCst); + let running = self.running.clone(); + + self.handle = Some(tokio::spawn(async move { + let mut frame_idx = 0; + while running.load(Ordering::SeqCst) { + print!( + "\r{}{} thinking...{}", + colors::DIM, + SPINNER_FRAMES[frame_idx % SPINNER_FRAMES.len()], + colors::RESET + ); + let _ = stdout().flush(); + frame_idx += 1; + tokio::time::sleep(tokio::time::Duration::from_millis(80)).await; + } + })); + } + + fn stop(&mut self) { + self.running.store(false, Ordering::SeqCst); + if let Some(handle) = self.handle.take() { + handle.abort(); + } + print!("\r \r"); + let _ = stdout().flush(); + } +} + +mod colors { + pub const RESET: &str = "\x1b[0m"; + pub const BOLD: &str = "\x1b[1m"; + pub const DIM: &str = "\x1b[2m"; + pub const ITALIC: &str = "\x1b[3m"; + + pub const CYAN: &str = "\x1b[36m"; + pub const GREEN: &str = "\x1b[32m"; + pub const YELLOW: &str = "\x1b[33m"; + pub const RED: &str = "\x1b[31m"; + pub const MAGENTA: &str = "\x1b[35m"; + pub const GRAY: &str = "\x1b[90m"; + + pub const BG_YELLOW: &str = "\x1b[43m"; + pub const BLACK: &str = "\x1b[30m"; +} + +fn print_session_header(provider: &str, model: Option<&str>, session_id: &str) { + use colors::*; + + println!(); + println!("{BOLD}goose{RESET} {DIM}CLI mode{RESET}"); + println!("{DIM}provider:{RESET} {GREEN}{provider}{RESET}"); + if let Some(m) = model { + println!("{DIM}model:{RESET} {GREEN}{m}{RESET}"); + } + println!("{DIM}session:{RESET} {GRAY}{session_id}{RESET}"); + println!(); + println!("{DIM}Type your message and press Enter. Use /help for commands.{RESET}"); + println!(); +} + +fn print_context_usage(total_tokens: i32, context_limit: usize) { + use colors::*; + + if context_limit == 0 { + return; + } + + let percentage = ((total_tokens as f64 / context_limit as f64) * 100.0).min(100.0) as usize; + let bar_width = 20; + let filled = (percentage * bar_width / 100).min(bar_width); + let empty = bar_width - filled; + + let bar_color = if percentage < 50 { + GREEN + } else if percentage < 80 { + YELLOW + } else { + RED + }; + + let bar = format!( + "{}{}{}{}", + bar_color, + "█".repeat(filled), + GRAY, + "░".repeat(empty) + ); + + println!( + "{DIM}context:{RESET} [{bar}{RESET}] {DIM}{percentage}% ({total_tokens}/{context_limit}){RESET}" + ); +} + +fn get_terminal_width() -> usize { + crossterm::terminal::size() + .map(|(cols, _)| cols as usize) + .unwrap_or(80) +} + +fn print_tool_call(name: &str, args: Option<&serde_json::Map>) { + use colors::*; + + let term_width = get_terminal_width(); + // Reserve space for "│ key: " prefix (roughly 20 chars for box + key + spacing) + let max_value_width = term_width.saturating_sub(20).max(30); + + println!(); + println!("{YELLOW}┌─ {BOLD}tool:{RESET} {MAGENTA}{name}{RESET}"); + + if let Some(obj) = args { + for (key, value) in obj { + let value_str = match value { + serde_json::Value::String(s) => s.clone(), + _ => value.to_string(), + }; + + // Replace newlines with spaces and truncate to fit terminal width + let single_line = value_str.replace('\n', " ").replace('\r', ""); + let truncated = if single_line.len() > max_value_width { + format!("{}...", &single_line[..max_value_width.saturating_sub(3)]) + } else { + single_line + }; + + println!("{YELLOW}│{RESET} {DIM}{key}:{RESET} {GRAY}{truncated}{RESET}"); + } + } + println!("{YELLOW}└─{RESET}"); +} + +fn print_tool_result(success: bool, tool_name: &str) { + use colors::*; + + if success { + println!("{GREEN}✓{RESET} {DIM}{tool_name} completed{RESET}"); + } else { + println!("{RED}✗{RESET} {DIM}{tool_name} failed{RESET}"); + } +} + +fn print_tool_confirmation(req: &ToolConfirmationRequest) -> bool { + use colors::*; + + println!(); + println!("{BG_YELLOW}{BLACK}{BOLD} TOOL APPROVAL REQUIRED {RESET}"); + println!(); + + if let Some(warning) = &req.prompt { + println!("{RED}{BOLD}⚠ Security:{RESET} {YELLOW}{warning}{RESET}"); + println!(); + } + + println!("{BOLD}Tool:{RESET} {MAGENTA}{}{RESET}", req.tool_name); + + if !req.arguments.is_empty() { + println!("{BOLD}Arguments:{RESET}"); + for (key, value) in &req.arguments { + let value_str = match value { + serde_json::Value::String(s) => { + if s.len() > 50 { + format!("{}...", &s[..47]) + } else { + s.clone() + } + } + _ => { + let s = value.to_string(); + if s.len() > 50 { + format!("{}...", &s[..47]) + } else { + s + } + } + }; + println!(" {DIM}{key}:{RESET} {GRAY}{value_str}{RESET}"); + } + } + + println!(); + print!("{BOLD}Allow this tool call? {RESET}[{GREEN}Y{RESET}/{RED}n{RESET}]: "); + stdout().flush().ok(); + + let mut input = String::new(); + if std::io::stdin().read_line(&mut input).is_ok() { + let trimmed = input.trim().to_lowercase(); + trimmed.is_empty() || trimmed == "y" || trimmed == "yes" + } else { + false + } +} + +fn print_help() { + use colors::*; + + println!(); + println!("{BOLD}{CYAN}Commands:{RESET}"); + println!(" {GREEN}/help{RESET}, {GREEN}/?{RESET} Show this help"); + println!(" {GREEN}/exit{RESET}, {GREEN}/quit{RESET} Exit the CLI"); + println!(" {GREEN}/clear{RESET} Clear the screen"); + println!(); + println!("{BOLD}{CYAN}Input:{RESET}"); + println!(" {DIM}Enter{RESET} Send message"); + println!(" {DIM}Ctrl+J{RESET} Insert newline (multi-line input)"); + println!(" {DIM}↑/↓{RESET} Navigate command history"); + println!(" {DIM}Ctrl+C{RESET} Cancel current input"); + println!(" {DIM}Ctrl+D{RESET} Exit"); + println!(); +} + +pub async fn run_cli( + client: Client, + session_id: String, + provider: String, + model: Option, + context_limit: usize, +) -> Result<()> { + use colors::*; + + let mut rl = DefaultEditor::new()?; + rl.bind_sequence( + KeyEvent(KeyCode::Char('J'), Modifiers::CTRL), + EventHandler::Simple(Cmd::Newline), + ); + let mut messages: Vec = Vec::new(); + let mut last_token_count: i32 = 0; + + print_session_header(&provider, model.as_deref(), &session_id); + + let mut pending_tools: std::collections::HashMap = + std::collections::HashMap::new(); + + loop { + if last_token_count > 0 { + print_context_usage(last_token_count, context_limit); + } + + // Read input with readline support + let prompt = format!("{CYAN}{BOLD}( O)>{RESET} "); + let input = match rl.readline(&prompt) { + Ok(line) => { + if !line.trim().is_empty() { + let _ = rl.add_history_entry(&line); + } + line + } + Err(ReadlineError::Eof) => break, + Err(ReadlineError::Interrupted) => { + println!("{DIM}^C{RESET}"); + continue; + } + Err(e) => return Err(e.into()), + }; + + let trimmed = input.trim(); + if trimmed.is_empty() { + continue; + } + + // Handle commands + match trimmed { + "/exit" | "/quit" | "/q" => break, + "/help" | "/?" => { + print_help(); + continue; + } + "/clear" => { + print!("\x1b[2J\x1b[H"); + stdout().flush()?; + continue; + } + cmd if cmd.starts_with('/') => { + println!( + "{YELLOW}Unknown command: {cmd}. Type /help for available commands.{RESET}" + ); + continue; + } + _ => {} + } + + // Send message and stream response + let user_message = Message::user().with_text(trimmed); + messages.push(user_message.clone()); + + // Start spinner while waiting for response + let mut spinner = Spinner::new(); + spinner.start(); + + // Stream the response + match stream_response( + &client, + &session_id, + messages.clone(), + &mut pending_tools, + &mut last_token_count, + &mut spinner, + ) + .await + { + Ok(response_messages) => { + messages.extend(response_messages); + } + Err(e) => { + spinner.stop(); + println!("{RED}Error: {e}{RESET}"); + } + } + + println!(); + } + + println!(); + println!("{DIM}Goodbye.{RESET}"); + Ok(()) +} + +#[allow(clippy::too_many_lines)] +async fn stream_response( + client: &Client, + session_id: &str, + messages: Vec, + pending_tools: &mut std::collections::HashMap, + last_token_count: &mut i32, + spinner: &mut Spinner, +) -> Result> { + use colors::*; + + let mut stream = client.reply(messages, session_id.to_string()).await?; + let mut in_text_stream = false; + let mut collected_messages: Vec = Vec::new(); + let mut pending_confirmation: Option = None; + let mut seen_tool_ids: std::collections::HashSet = std::collections::HashSet::new(); + let mut spinner_stopped = false; + + while let Some(result) = stream.next().await { + match result { + Ok(event) => match event { + MessageEvent::Message { + message, + token_state, + } => { + *last_token_count = token_state.total_tokens; + + for content in &message.content { + match content { + MessageContent::Text(t) => { + // Stop spinner when we get actual text content + if !spinner_stopped { + spinner.stop(); + spinner_stopped = true; + } + // Each message contains only the new text delta - print directly + if !in_text_stream { + println!(); + in_text_stream = true; + } + print!("{}", t.text); + stdout().flush()?; + } + MessageContent::ToolRequest(req) => { + // Stop spinner when we get a tool request + if !spinner_stopped { + spinner.stop(); + spinner_stopped = true; + } + if in_text_stream { + println!(); + in_text_stream = false; + } + if let Ok(call) = &req.tool_call { + // Only print if we haven't seen this tool request before + if seen_tool_ids.insert(req.id.clone()) { + pending_tools.insert(req.id.clone(), call.name.to_string()); + print_tool_call(&call.name, call.arguments.as_ref()); + } + } + } + MessageContent::ToolResponse(resp) => { + // Stop spinner on tool response too + if !spinner_stopped { + spinner.stop(); + spinner_stopped = true; + } + // Only print if we haven't seen this response before + if seen_tool_ids.insert(format!("resp_{}", resp.id)) { + let tool_name = pending_tools + .remove(&resp.id) + .unwrap_or_else(|| "unknown".to_string()); + print_tool_result(resp.tool_result.is_ok(), &tool_name); + } + } + MessageContent::ToolConfirmationRequest(req) => { + // Stop spinner for confirmation requests + if !spinner_stopped { + spinner.stop(); + spinner_stopped = true; + } + if in_text_stream { + println!(); + in_text_stream = false; + } + pending_confirmation = Some(req.clone()); + } + MessageContent::Thinking(t) => { + // Don't stop spinner for thinking - it's brief + if !t.thinking.is_empty() { + println!( + "{DIM}{ITALIC}💭 {}{RESET}", + &t.thinking[..t.thinking.len().min(100)] + ); + } + } + _ => {} + } + } + + collected_messages.push(message.clone()); + } + MessageEvent::Finish { token_state, .. } => { + if !spinner_stopped { + spinner.stop(); + } + *last_token_count = token_state.total_tokens; + if in_text_stream { + println!(); + } + break; + } + MessageEvent::Error { error } => { + if !spinner_stopped { + spinner.stop(); + } + if in_text_stream { + println!(); + } + println!("{RED}Error: {error}{RESET}"); + break; + } + _ => {} + }, + Err(e) => { + if !spinner_stopped { + spinner.stop(); + } + if in_text_stream { + println!(); + } + println!("{RED}Stream error: {e}{RESET}"); + break; + } + } + + // Handle pending confirmation outside the stream + if let Some(req) = pending_confirmation.take() { + let approved = print_tool_confirmation(&req); + let action = if approved { "allow_once" } else { "deny" }; + + if let Err(e) = client + .confirm_tool_permission(session_id, &req.id, action) + .await + { + println!("{RED}Failed to send confirmation: {e}{RESET}"); + } else if approved { + println!("{GREEN}✓ Tool approved{RESET}"); + } else { + println!("{YELLOW}✗ Tool denied{RESET}"); + } + } + } + + Ok(collected_messages) +} diff --git a/crates/goose-tui/src/components/info.rs b/crates/goose-tui/src/components/info.rs index 23d205e9e5..b73d88954d 100644 --- a/crates/goose-tui/src/components/info.rs +++ b/crates/goose-tui/src/components/info.rs @@ -2,6 +2,7 @@ use super::Component; use crate::services::events::Event; use crate::state::action::Action; use crate::state::AppState; +use crate::utils::spinner::SPINNER_FRAMES; use anyhow::Result; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; @@ -72,8 +73,7 @@ impl Component for InfoComponent { let theme = &state.config.theme; if state.is_working { - let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - let spinner = spinner_frames[self.frame_count % spinner_frames.len()]; + let spinner = SPINNER_FRAMES[self.frame_count % SPINNER_FRAMES.len()]; spans.push(Span::styled( format!("{spinner} "), diff --git a/crates/goose-tui/src/main.rs b/crates/goose-tui/src/main.rs index eeb8dc9f30..91a9f3a7e4 100644 --- a/crates/goose-tui/src/main.rs +++ b/crates/goose-tui/src/main.rs @@ -1,5 +1,6 @@ mod action_handler; mod app; +mod cli; mod components; mod headless; mod runner; @@ -83,6 +84,9 @@ struct Cli { )] text: Option, + #[arg(long, help = "Run in CLI mode (interactive terminal, no TUI)")] + cli: bool, + #[command(subcommand)] command: Option, } @@ -134,25 +138,26 @@ fn read_stdin_if_piped() -> Option { } fn main() -> Result<()> { - let cli = Cli::parse(); + let cli_args = Cli::parse(); - if let Some(Commands::Mcp { name }) = cli.command { + if let Some(Commands::Mcp { name }) = cli_args.command { return run_mcp_server(name); } let stdin_input = read_stdin_if_piped(); - let text_input = cli.text.or(stdin_input); + let text_input = cli_args.text.or(stdin_input); let secret_key = Uuid::new_v4().to_string(); std::env::set_var("GOOSE_SERVER__SECRET_KEY", &secret_key); std::env::set_var("GOOSE_PORT", "0"); tokio::runtime::Runtime::new()?.block_on(run_tui( - cli.session, - cli.name, - cli.resume, - cli.recipe, - cli.headless, + cli_args.session, + cli_args.name, + cli_args.resume, + cli_args.recipe, + cli_args.headless, + cli_args.cli, text_input, secret_key, )) @@ -224,12 +229,14 @@ async fn resolve_session_id( Ok(None) } +#[allow(clippy::too_many_arguments)] async fn run_tui( session: Option, name: Option, resume: bool, recipe: Option, headless: bool, + cli_mode: bool, text_input: Option, secret_key: String, ) -> Result<()> { @@ -263,6 +270,8 @@ async fn run_tui( run_text_mode(client, cwd, resolved_session, name, prompt).await } else if headless { anyhow::bail!("--headless requires either --recipe or --text (or piped stdin)") + } else if cli_mode { + run_cli_mode(client, cwd, resolved_session, name).await } else { run_interactive_mode(client, cwd, resolved_session, name).await }; @@ -422,3 +431,45 @@ async fn run_interactive_mode( tui::restore()?; result } + +async fn run_cli_mode( + client: Client, + cwd: std::path::PathBuf, + session: Option, + name: Option, +) -> Result<()> { + info!("Starting CLI mode"); + + let initial_session = if let Some(id) = session { + info!("Resuming session: {}", id); + client.resume_agent(&id).await? + } else { + let new_session = client + .start_agent(cwd.to_string_lossy().to_string()) + .await?; + + if let Some(ref session_name) = name { + if let Err(e) = client + .update_session_name(&new_session.id, session_name) + .await + { + tracing::warn!("Failed to set session name: {}", e); + } + } + + new_session + }; + + let (provider, model) = configure_session_from_global(&client, &initial_session.id).await; + + let context_limit = model + .as_ref() + .and_then(|m| { + goose::model::ModelConfig::new(m) + .ok() + .map(|c| c.context_limit()) + }) + .unwrap_or(goose_tui::DEFAULT_CONTEXT_LIMIT); + + cli::run_cli(client, initial_session.id, provider, model, context_limit).await +} diff --git a/crates/goose-tui/src/utils/mod.rs b/crates/goose-tui/src/utils/mod.rs index 68f95c0b1b..ae81ee2d0b 100644 --- a/crates/goose-tui/src/utils/mod.rs +++ b/crates/goose-tui/src/utils/mod.rs @@ -3,6 +3,7 @@ pub mod json; pub mod layout; pub mod message_format; pub mod sanitize; +pub mod spinner; pub mod styles; pub mod termimad_renderer; diff --git a/crates/goose-tui/src/utils/spinner.rs b/crates/goose-tui/src/utils/spinner.rs new file mode 100644 index 0000000000..6f7d073589 --- /dev/null +++ b/crates/goose-tui/src/utils/spinner.rs @@ -0,0 +1 @@ +pub const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; diff --git a/crates/goose/src/providers/formats/databricks.rs b/crates/goose/src/providers/formats/databricks.rs index cbc047e6ee..5c78dcaadb 100644 --- a/crates/goose/src/providers/formats/databricks.rs +++ b/crates/goose/src/providers/formats/databricks.rs @@ -691,13 +691,13 @@ mod tests { }), ); - let spec = format_tools(&[tool.clone()], "gpt-4o")?; + let spec = format_tools(std::slice::from_ref(&tool), "gpt-4o")?; assert_eq!( spec[0]["function"]["parameters"]["$schema"], "http://json-schema.org/draft-07/schema#" ); - let spec = format_tools(&[tool.clone()], "gemini-2-5-flash")?; + let spec = format_tools(std::slice::from_ref(&tool), "gemini-2-5-flash")?; assert!(spec[0]["function"]["parameters"].get("$schema").is_none()); assert_eq!(spec[0]["function"]["parameters"]["type"], "object");