Minimal CLI using goose-server

This commit is contained in:
Tyler Longwell
2025-12-03 10:08:07 -05:00
parent 10c53c136f
commit 5d040aaa6d
10 changed files with 576 additions and 12 deletions
Generated
+1
View File
@@ -3173,6 +3173,7 @@ dependencies = [
"goose-server",
"ratatui",
"rmcp 0.9.1",
"rustyline",
"serde",
"serde_json",
"termimad",
@@ -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 {
@@ -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 {
+1
View File
@@ -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"] }
+503
View File
@@ -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<AtomicBool>,
handle: Option<tokio::task::JoinHandle<()>>,
}
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<String, serde_json::Value>>) {
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<String>,
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<Message> = 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<String, String> =
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<Message>,
pending_tools: &mut std::collections::HashMap<String, String>,
last_token_count: &mut i32,
spinner: &mut Spinner,
) -> Result<Vec<Message>> {
use colors::*;
let mut stream = client.reply(messages, session_id.to_string()).await?;
let mut in_text_stream = false;
let mut collected_messages: Vec<Message> = Vec::new();
let mut pending_confirmation: Option<ToolConfirmationRequest> = None;
let mut seen_tool_ids: std::collections::HashSet<String> = 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)
}
+2 -2
View File
@@ -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} "),
+59 -8
View File
@@ -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<String>,
#[arg(long, help = "Run in CLI mode (interactive terminal, no TUI)")]
cli: bool,
#[command(subcommand)]
command: Option<Commands>,
}
@@ -134,25 +138,26 @@ fn read_stdin_if_piped() -> Option<String> {
}
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<String>,
name: Option<String>,
resume: bool,
recipe: Option<PathBuf>,
headless: bool,
cli_mode: bool,
text_input: Option<String>,
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<String>,
name: Option<String>,
) -> 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
}
+1
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
pub const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -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");