From 7992804f32d694d80cdfe0a65dd040b3962c7560 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 4 Sep 2025 15:42:20 +1000 Subject: [PATCH 1/5] basics --- Cargo.lock | 66 +++++++++++++ crates/goose-cli/Cargo.toml | 3 +- crates/goose-cli/src/cli.rs | 10 ++ crates/goose-cli/src/commands/acp.rs | 136 +++++++++++++++++++++++++++ crates/goose-cli/src/commands/mod.rs | 1 + 5 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 crates/goose-cli/src/commands/acp.rs diff --git a/Cargo.lock b/Cargo.lock index 84d7681a165e..73eda921ec8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,22 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "agent-client-protocol" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b91e5ec3ce05e8effb2a7a3b7b1a587daa6699b9f98bbde6a35e44b8c6c773a" +dependencies = [ + "anyhow", + "async-broadcast", + "futures", + "log", + "parking_lot", + "schemars", + "serde", + "serde_json", +] + [[package]] name = "ahash" version = "0.8.11" @@ -427,6 +443,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-compression" version = "0.4.20" @@ -1525,6 +1553,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "config" version = "0.14.1" @@ -2146,6 +2183,27 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "eventsource-client" version = "0.12.2" @@ -2644,6 +2702,7 @@ dependencies = [ name = "goose-cli" version = "1.7.0" dependencies = [ + "agent-client-protocol", "anstream", "anyhow", "async-trait", @@ -4573,6 +4632,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.3" @@ -6598,6 +6663,7 @@ checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 0986ec3219e0..d45d8135b0f9 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -22,6 +22,7 @@ mcp-client = { path = "../mcp-client" } mcp-server = { path = "../mcp-server" } mcp-core = { path = "../mcp-core" } rmcp = { workspace = true } +agent-client-protocol = "0.1.1" clap = { version = "4.4", features = ["derive"] } cliclack = "0.3.5" console = "0.15.8" @@ -56,7 +57,7 @@ tower-http = { version = "0.5", features = ["cors", "fs"] } http = "1.0" webbrowser = "1.0" indicatif = "0.17.11" -tokio-util = "0.7.15" +tokio-util = { version = "0.7.15", features = ["compat"] } is-terminal = "0.4.16" anstream = "0.6.18" diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index f3ed00c657b7..1c59cc346226 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -3,6 +3,7 @@ use clap::{Args, Parser, Subcommand}; use goose::config::{Config, ExtensionConfig}; +use crate::commands::acp::run_acp_agent; use crate::commands::bench::agent_generator; use crate::commands::configure::handle_configure; use crate::commands::info::handle_info; @@ -291,6 +292,10 @@ enum Command { #[command(about = "Run one of the mcp servers bundled with goose")] Mcp { name: String }, + /// Run Goose as an ACP (Agent Client Protocol) agent + #[command(about = "Run Goose as an ACP agent server on stdio")] + Acp {}, + /// Start or resume interactive chat sessions #[command( about = "Start or resume interactive chat sessions", @@ -709,6 +714,7 @@ pub async fn cli() -> Result<()> { Some(Command::Configure {}) => "configure", Some(Command::Info { .. }) => "info", Some(Command::Mcp { .. }) => "mcp", + Some(Command::Acp {}) => "acp", Some(Command::Session { .. }) => "session", Some(Command::Project {}) => "project", Some(Command::Projects) => "projects", @@ -739,6 +745,10 @@ pub async fn cli() -> Result<()> { Some(Command::Mcp { name }) => { let _ = run_server(&name).await; } + Some(Command::Acp {}) => { + let _ = run_acp_agent().await; + return Ok(()); + } Some(Command::Session { command, identifier, diff --git a/crates/goose-cli/src/commands/acp.rs b/crates/goose-cli/src/commands/acp.rs new file mode 100644 index 000000000000..bc5e0cdeaf39 --- /dev/null +++ b/crates/goose-cli/src/commands/acp.rs @@ -0,0 +1,136 @@ +use agent_client_protocol::{self as acp, Client, SessionNotification}; +use anyhow::Result; +use std::cell::Cell; +use tokio::sync::{mpsc, oneshot}; +use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _}; +use tracing::{error, info}; + +/// Simple Goose ACP Agent implementation +struct GooseAcpAgent { + session_update_tx: mpsc::UnboundedSender<(acp::SessionNotification, oneshot::Sender<()>)>, + next_session_id: Cell, +} + +impl GooseAcpAgent { + fn new( + session_update_tx: mpsc::UnboundedSender<(acp::SessionNotification, oneshot::Sender<()>)>, + ) -> Self { + Self { + session_update_tx, + next_session_id: Cell::new(0), + } + } +} + +impl acp::Agent for GooseAcpAgent { + async fn initialize( + &self, + arguments: acp::InitializeRequest, + ) -> Result { + info!("ACP: Received initialize request {:?}", arguments); + Ok(acp::InitializeResponse { + protocol_version: acp::V1, + agent_capabilities: acp::AgentCapabilities::default(), + auth_methods: Vec::new(), + }) + } + + async fn authenticate(&self, arguments: acp::AuthenticateRequest) -> Result<(), acp::Error> { + info!("ACP: Received authenticate request {:?}", arguments); + Ok(()) + } + + async fn new_session( + &self, + arguments: acp::NewSessionRequest, + ) -> Result { + info!("ACP: Received new session request {:?}", arguments); + let session_id = self.next_session_id.get(); + self.next_session_id.set(session_id + 1); + Ok(acp::NewSessionResponse { + session_id: acp::SessionId(session_id.to_string().into()), + }) + } + + async fn load_session(&self, arguments: acp::LoadSessionRequest) -> Result<(), acp::Error> { + info!("ACP: Received load session request {:?}", arguments); + // For now, we don't support loading previous sessions + Err(acp::Error::method_not_found()) + } + + async fn prompt( + &self, + arguments: acp::PromptRequest, + ) -> Result { + info!("ACP: Received prompt request {:?}", arguments); + + // Echo back the prompt with a prefix (simple example behavior) + for content in ["Goose ACP Agent received: ".into()] + .into_iter() + .chain(arguments.prompt) + { + let (tx, rx) = oneshot::channel(); + self.session_update_tx + .send(( + SessionNotification { + session_id: arguments.session_id.clone(), + update: acp::SessionUpdate::AgentMessageChunk { content }, + }, + tx, + )) + .map_err(|_| acp::Error::internal_error())?; + rx.await.map_err(|_| acp::Error::internal_error())?; + } + + Ok(acp::PromptResponse { + stop_reason: acp::StopReason::EndTurn, + }) + } + + async fn cancel(&self, args: acp::CancelNotification) -> Result<(), acp::Error> { + info!("ACP: Received cancel request {:?}", args); + Ok(()) + } +} + +/// Run the ACP agent server +pub async fn run_acp_agent() -> Result<()> { + info!("Starting Goose ACP agent server on stdio"); + println!("Goose ACP agent started. Listening on stdio..."); + + let outgoing = tokio::io::stdout().compat_write(); + let incoming = tokio::io::stdin().compat(); + + // The AgentSideConnection will spawn futures onto our Tokio runtime. + // LocalSet and spawn_local are used because the futures from the + // agent-client-protocol crate are not Send. + let local_set = tokio::task::LocalSet::new(); + local_set + .run_until(async move { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + + // Start up the GooseAcpAgent connected to stdio. + let (conn, handle_io) = + acp::AgentSideConnection::new(GooseAcpAgent::new(tx), outgoing, incoming, |fut| { + tokio::task::spawn_local(fut); + }); + + // Kick off a background task to send the agent's session notifications to the client. + tokio::task::spawn_local(async move { + while let Some((session_notification, tx)) = rx.recv().await { + let result = conn.session_notification(session_notification).await; + if let Err(e) = result { + error!("ACP session notification error: {}", e); + break; + } + tx.send(()).ok(); + } + }); + + // Run until stdin/stdout are closed. + handle_io.await + }) + .await?; + + Ok(()) +} diff --git a/crates/goose-cli/src/commands/mod.rs b/crates/goose-cli/src/commands/mod.rs index 72ce9be243ed..43b666de7d2d 100644 --- a/crates/goose-cli/src/commands/mod.rs +++ b/crates/goose-cli/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod acp; pub mod bench; pub mod configure; pub mod info; From 0201f492056199d0174a55f062c8f2902c510f39 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 4 Sep 2025 16:06:13 +1000 Subject: [PATCH 2/5] basics of session --- crates/goose-cli/Cargo.toml | 2 +- crates/goose-cli/src/commands/acp.rs | 168 ++++++++++++++++++++++----- test_acp_client.py | 117 +++++++++++++++++++ 3 files changed, 257 insertions(+), 30 deletions(-) create mode 100755 test_acp_client.py diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index d45d8135b0f9..185f59c0cfbe 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -26,6 +26,7 @@ agent-client-protocol = "0.1.1" clap = { version = "4.4", features = ["derive"] } cliclack = "0.3.5" console = "0.15.8" +uuid = { version = "1.11", features = ["v4"] } dotenvy = "0.15.7" bat = "0.24.0" anyhow = "1.0" @@ -48,7 +49,6 @@ shlex = "1.3.0" async-trait = "0.1.86" base64 = "0.22.1" regex = "1.11.1" -uuid = { version = "1.11", features = ["v4"] } nix = { version = "0.30.1", features = ["process", "signal"] } tar = "0.4" # Web server dependencies diff --git a/crates/goose-cli/src/commands/acp.rs b/crates/goose-cli/src/commands/acp.rs index bc5e0cdeaf39..ee9db45ec744 100644 --- a/crates/goose-cli/src/commands/acp.rs +++ b/crates/goose-cli/src/commands/acp.rs @@ -1,24 +1,61 @@ use agent_client_protocol::{self as acp, Client, SessionNotification}; use anyhow::Result; -use std::cell::Cell; -use tokio::sync::{mpsc, oneshot}; +use goose::agents::Agent; +use goose::conversation::Conversation; +use goose::conversation::message::{Message, MessageContent}; +use goose::providers::create; +use goose::config::Config; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{mpsc, oneshot, Mutex}; use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _}; +use tokio_util::sync::CancellationToken; use tracing::{error, info}; -/// Simple Goose ACP Agent implementation +/// Represents a single Goose session for ACP +struct GooseSession { + agent: Agent, + messages: Conversation, +} + +/// Goose ACP Agent implementation that connects to real Goose agents struct GooseAcpAgent { session_update_tx: mpsc::UnboundedSender<(acp::SessionNotification, oneshot::Sender<()>)>, - next_session_id: Cell, + sessions: Arc>>, + provider: Arc, } impl GooseAcpAgent { - fn new( + async fn new( session_update_tx: mpsc::UnboundedSender<(acp::SessionNotification, oneshot::Sender<()>)>, - ) -> Self { - Self { + ) -> Result { + // Load config and create provider + let config = Config::global(); + + let provider_name: String = config + .get_param("GOOSE_PROVIDER") + .map_err(|e| anyhow::anyhow!("No provider configured: {}", e))?; + + let model_name: String = config + .get_param("GOOSE_MODEL") + .map_err(|e| anyhow::anyhow!("No model configured: {}", e))?; + + let model_config = goose::model::ModelConfig { + model_name: model_name.clone(), + context_limit: None, + temperature: None, + max_tokens: None, + toolshim: false, + toolshim_model: None, + fast_model: None, + }; + let provider = create(&provider_name, model_config)?; + + Ok(Self { session_update_tx, - next_session_id: Cell::new(0), - } + sessions: Arc::new(Mutex::new(HashMap::new())), + provider, + }) } } @@ -45,10 +82,26 @@ impl acp::Agent for GooseAcpAgent { arguments: acp::NewSessionRequest, ) -> Result { info!("ACP: Received new session request {:?}", arguments); - let session_id = self.next_session_id.get(); - self.next_session_id.set(session_id + 1); + + // Generate a unique session ID + let session_id = uuid::Uuid::new_v4().to_string(); + + // Create a new Agent and session for this ACP session + let mut agent = Agent::new(); + agent.update_provider(self.provider.clone()).await + .map_err(|_| acp::Error::internal_error())?; + + let session = GooseSession { + agent, + messages: Conversation::new_unvalidated(Vec::new()), + }; + + // Store the session + let mut sessions = self.sessions.lock().await; + sessions.insert(session_id.clone(), session); + Ok(acp::NewSessionResponse { - session_id: acp::SessionId(session_id.to_string().into()), + session_id: acp::SessionId(session_id.into()), }) } @@ -64,22 +117,77 @@ impl acp::Agent for GooseAcpAgent { ) -> Result { info!("ACP: Received prompt request {:?}", arguments); - // Echo back the prompt with a prefix (simple example behavior) - for content in ["Goose ACP Agent received: ".into()] + // Get the session + let session_id = arguments.session_id.0.to_string(); + let mut sessions = self.sessions.lock().await; + let session = sessions.get_mut(&session_id) + .ok_or_else(|| acp::Error::invalid_params())?; + + // Convert ACP prompt to Goose message + // Extract text from ContentBlocks + let prompt_text = arguments.prompt .into_iter() - .chain(arguments.prompt) - { - let (tx, rx) = oneshot::channel(); - self.session_update_tx - .send(( - SessionNotification { - session_id: arguments.session_id.clone(), - update: acp::SessionUpdate::AgentMessageChunk { content }, - }, - tx, - )) - .map_err(|_| acp::Error::internal_error())?; - rx.await.map_err(|_| acp::Error::internal_error())?; + .filter_map(|block| { + if let acp::ContentBlock::Text(text) = block { + Some(text.text.clone()) + } else { + None + } + }) + .collect::>() + .join(" "); + + let user_message = Message::user().with_text(&prompt_text); + + // Add message to conversation + session.messages.push(user_message); + + // Get agent's reply through the Goose agent + let cancel_token = CancellationToken::new(); + let mut stream = session.agent + .reply(session.messages.clone(), None, Some(cancel_token.clone())) + .await + .map_err(|e| { + error!("Error getting agent reply: {}", e); + acp::Error::internal_error() + })?; + + use futures::StreamExt; + + // Process the agent's response stream + while let Some(event) = stream.next().await { + match event { + Ok(goose::agents::AgentEvent::Message(message)) => { + // Add to conversation + session.messages.push(message.clone()); + + // Stream the response text to the client + for content_item in &message.content { + if let MessageContent::Text(text) = content_item { + let (tx, rx) = oneshot::channel(); + self.session_update_tx + .send(( + SessionNotification { + session_id: arguments.session_id.clone(), + update: acp::SessionUpdate::AgentMessageChunk { + content: text.text.clone().into() + }, + }, + tx, + )) + .map_err(|_| acp::Error::internal_error())?; + rx.await.map_err(|_| acp::Error::internal_error())?; + } + } + } + Ok(_) => { + // Ignore other events for now + } + Err(e) => { + error!("Error in agent response stream: {}", e); + return Err(acp::Error::internal_error()); + } + } } Ok(acp::PromptResponse { @@ -96,7 +204,7 @@ impl acp::Agent for GooseAcpAgent { /// Run the ACP agent server pub async fn run_acp_agent() -> Result<()> { info!("Starting Goose ACP agent server on stdio"); - println!("Goose ACP agent started. Listening on stdio..."); + eprintln!("Goose ACP agent started. Listening on stdio..."); let outgoing = tokio::io::stdout().compat_write(); let incoming = tokio::io::stdin().compat(); @@ -110,8 +218,10 @@ pub async fn run_acp_agent() -> Result<()> { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); // Start up the GooseAcpAgent connected to stdio. + let agent = GooseAcpAgent::new(tx).await + .map_err(|e| anyhow::anyhow!("Failed to create ACP agent: {}", e))?; let (conn, handle_io) = - acp::AgentSideConnection::new(GooseAcpAgent::new(tx), outgoing, incoming, |fut| { + acp::AgentSideConnection::new(agent, outgoing, incoming, |fut| { tokio::task::spawn_local(fut); }); diff --git a/test_acp_client.py b/test_acp_client.py new file mode 100755 index 000000000000..0213df2b2c52 --- /dev/null +++ b/test_acp_client.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +""" +Simple ACP client to test the Goose ACP agent. +Connects to goose acp running on stdio. +""" + +import subprocess +import json +import sys +import uuid + +class AcpClient: + def __init__(self): + # Start the goose acp process + self.process = subprocess.Popen( + ['cargo', 'run', '-p', 'goose-cli', '--', 'acp'], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=0 + ) + self.request_id = 0 + + def send_request(self, method, params=None): + self.request_id += 1 + request = { + "jsonrpc": "2.0", + "method": method, + "id": self.request_id, + } + if params: + request["params"] = params + + # Send the request + request_str = json.dumps(request) + print(f">>> Sending: {request_str}") + self.process.stdin.write(request_str + '\n') + self.process.stdin.flush() + + # Read response + response_line = self.process.stdout.readline() + if not response_line: + return None + + print(f"<<< Response: {response_line}") + return json.loads(response_line) + + def initialize(self): + return self.send_request("initialize", { + "protocolVersion": "v1", + "clientCapabilities": {}, + "clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + }) + + def new_session(self): + return self.send_request("newSession", { + "context": {} + }) + + def prompt(self, session_id, text): + return self.send_request("prompt", { + "sessionId": session_id, + "prompt": [ + { + "type": "text", + "text": text + } + ] + }) + + def close(self): + if self.process: + self.process.terminate() + self.process.wait() + +def main(): + print("Starting ACP client test...") + client = AcpClient() + + try: + # Initialize the agent + print("\n1. Initializing agent...") + init_response = client.initialize() + if init_response and 'result' in init_response: + print(f" Initialized successfully: {init_response['result']}") + else: + print(f" Failed to initialize: {init_response}") + return + + # Create a new session + print("\n2. Creating new session...") + session_response = client.new_session() + if session_response and 'result' in session_response: + session_id = session_response['result']['sessionId'] + print(f" Created session: {session_id}") + else: + print(f" Failed to create session: {session_response}") + return + + # Send a prompt + print("\n3. Sending prompt...") + prompt_response = client.prompt(session_id, "Hello! What is 2 + 2?") + if prompt_response: + print(f" Got response: {prompt_response}") + else: + print(" Failed to get prompt response") + + finally: + client.close() + print("\nTest complete.") + +if __name__ == "__main__": + main() From b6e8b658b5db9688e4e6bda6f7df3d25051743a6 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 4 Sep 2025 16:13:12 +1000 Subject: [PATCH 3/5] basics now working --- crates/goose-cli/src/commands/acp.rs | 63 ++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/crates/goose-cli/src/commands/acp.rs b/crates/goose-cli/src/commands/acp.rs index ee9db45ec744..7a5e5c41770a 100644 --- a/crates/goose-cli/src/commands/acp.rs +++ b/crates/goose-cli/src/commands/acp.rs @@ -1,16 +1,17 @@ use agent_client_protocol::{self as acp, Client, SessionNotification}; use anyhow::Result; use goose::agents::Agent; +use goose::config::{Config, ExtensionConfigManager}; use goose::conversation::Conversation; use goose::conversation::message::{Message, MessageContent}; use goose::providers::create; -use goose::config::Config; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio::task::JoinSet; use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _}; use tokio_util::sync::CancellationToken; -use tracing::{error, info}; +use tracing::{error, info, warn}; /// Represents a single Goose session for ACP struct GooseSession { @@ -87,10 +88,62 @@ impl acp::Agent for GooseAcpAgent { let session_id = uuid::Uuid::new_v4().to_string(); // Create a new Agent and session for this ACP session - let mut agent = Agent::new(); + let agent = Agent::new(); agent.update_provider(self.provider.clone()).await .map_err(|_| acp::Error::internal_error())?; + // Load and add extensions just like the normal CLI + // Get all enabled extensions from configuration + let extensions_to_run: Vec<_> = ExtensionConfigManager::get_all() + .map_err(|e| { + error!("Failed to load extensions: {}", e); + acp::Error::internal_error() + })? + .into_iter() + .filter(|ext| ext.enabled) + .map(|ext| ext.config) + .collect(); + + // Add extensions to the agent in parallel + let agent_ptr = Arc::new(agent); + let mut set = JoinSet::new(); + let mut waiting_on = HashSet::new(); + + for extension in extensions_to_run { + waiting_on.insert(extension.name()); + let agent_ptr_clone = agent_ptr.clone(); + set.spawn(async move { + ( + extension.name(), + agent_ptr_clone.add_extension(extension.clone()).await, + ) + }); + } + + // Wait for all extensions to load + while let Some(result) = set.join_next().await { + match result { + Ok((name, Ok(_))) => { + waiting_on.remove(&name); + info!("Loaded extension: {}", name); + } + Ok((name, Err(e))) => { + warn!("Failed to load extension '{}': {}", name, e); + waiting_on.remove(&name); + } + Err(e) => { + error!("Task error while loading extension: {}", e); + } + } + } + + // Unwrap the Arc to get the agent back + let agent = Arc::try_unwrap(agent_ptr) + .map_err(|_| { + error!("Failed to unwrap agent Arc"); + acp::Error::internal_error() + })?; + let session = GooseSession { agent, messages: Conversation::new_unvalidated(Vec::new()), @@ -100,6 +153,8 @@ impl acp::Agent for GooseAcpAgent { let mut sessions = self.sessions.lock().await; sessions.insert(session_id.clone(), session); + info!("Created new session with ID: {}", session_id); + Ok(acp::NewSessionResponse { session_id: acp::SessionId(session_id.into()), }) From 7e32b7c8afb1cbb2b9505814e3f69c995f40c6e5 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 4 Sep 2025 16:55:13 +1000 Subject: [PATCH 4/5] tidy --- crates/goose-cli/src/commands/acp.rs | 73 +++++++++++++++------------- 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/crates/goose-cli/src/commands/acp.rs b/crates/goose-cli/src/commands/acp.rs index 7a5e5c41770a..42f2f7de1f8c 100644 --- a/crates/goose-cli/src/commands/acp.rs +++ b/crates/goose-cli/src/commands/acp.rs @@ -2,8 +2,8 @@ use agent_client_protocol::{self as acp, Client, SessionNotification}; use anyhow::Result; use goose::agents::Agent; use goose::config::{Config, ExtensionConfigManager}; -use goose::conversation::Conversation; use goose::conversation::message::{Message, MessageContent}; +use goose::conversation::Conversation; use goose::providers::create; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -32,11 +32,11 @@ impl GooseAcpAgent { ) -> Result { // Load config and create provider let config = Config::global(); - + let provider_name: String = config .get_param("GOOSE_PROVIDER") .map_err(|e| anyhow::anyhow!("No provider configured: {}", e))?; - + let model_name: String = config .get_param("GOOSE_MODEL") .map_err(|e| anyhow::anyhow!("No model configured: {}", e))?; @@ -83,15 +83,17 @@ impl acp::Agent for GooseAcpAgent { arguments: acp::NewSessionRequest, ) -> Result { info!("ACP: Received new session request {:?}", arguments); - + // Generate a unique session ID let session_id = uuid::Uuid::new_v4().to_string(); - + // Create a new Agent and session for this ACP session let agent = Agent::new(); - agent.update_provider(self.provider.clone()).await + agent + .update_provider(self.provider.clone()) + .await .map_err(|_| acp::Error::internal_error())?; - + // Load and add extensions just like the normal CLI // Get all enabled extensions from configuration let extensions_to_run: Vec<_> = ExtensionConfigManager::get_all() @@ -103,12 +105,12 @@ impl acp::Agent for GooseAcpAgent { .filter(|ext| ext.enabled) .map(|ext| ext.config) .collect(); - + // Add extensions to the agent in parallel let agent_ptr = Arc::new(agent); let mut set = JoinSet::new(); let mut waiting_on = HashSet::new(); - + for extension in extensions_to_run { waiting_on.insert(extension.name()); let agent_ptr_clone = agent_ptr.clone(); @@ -119,7 +121,7 @@ impl acp::Agent for GooseAcpAgent { ) }); } - + // Wait for all extensions to load while let Some(result) = set.join_next().await { match result { @@ -136,25 +138,24 @@ impl acp::Agent for GooseAcpAgent { } } } - + // Unwrap the Arc to get the agent back - let agent = Arc::try_unwrap(agent_ptr) - .map_err(|_| { - error!("Failed to unwrap agent Arc"); - acp::Error::internal_error() - })?; - + let agent = Arc::try_unwrap(agent_ptr).map_err(|_| { + error!("Failed to unwrap agent Arc"); + acp::Error::internal_error() + })?; + let session = GooseSession { agent, messages: Conversation::new_unvalidated(Vec::new()), }; - + // Store the session let mut sessions = self.sessions.lock().await; sessions.insert(session_id.clone(), session); - + info!("Created new session with ID: {}", session_id); - + Ok(acp::NewSessionResponse { session_id: acp::SessionId(session_id.into()), }) @@ -175,12 +176,14 @@ impl acp::Agent for GooseAcpAgent { // Get the session let session_id = arguments.session_id.0.to_string(); let mut sessions = self.sessions.lock().await; - let session = sessions.get_mut(&session_id) - .ok_or_else(|| acp::Error::invalid_params())?; - + let session = sessions + .get_mut(&session_id) + .ok_or_else(acp::Error::invalid_params)?; + // Convert ACP prompt to Goose message // Extract text from ContentBlocks - let prompt_text = arguments.prompt + let prompt_text = arguments + .prompt .into_iter() .filter_map(|block| { if let acp::ContentBlock::Text(text) = block { @@ -191,31 +194,32 @@ impl acp::Agent for GooseAcpAgent { }) .collect::>() .join(" "); - + let user_message = Message::user().with_text(&prompt_text); - + // Add message to conversation session.messages.push(user_message); - + // Get agent's reply through the Goose agent let cancel_token = CancellationToken::new(); - let mut stream = session.agent + let mut stream = session + .agent .reply(session.messages.clone(), None, Some(cancel_token.clone())) .await .map_err(|e| { error!("Error getting agent reply: {}", e); acp::Error::internal_error() })?; - + use futures::StreamExt; - + // Process the agent's response stream while let Some(event) = stream.next().await { match event { Ok(goose::agents::AgentEvent::Message(message)) => { // Add to conversation session.messages.push(message.clone()); - + // Stream the response text to the client for content_item in &message.content { if let MessageContent::Text(text) = content_item { @@ -224,8 +228,8 @@ impl acp::Agent for GooseAcpAgent { .send(( SessionNotification { session_id: arguments.session_id.clone(), - update: acp::SessionUpdate::AgentMessageChunk { - content: text.text.clone().into() + update: acp::SessionUpdate::AgentMessageChunk { + content: text.text.clone().into(), }, }, tx, @@ -273,7 +277,8 @@ pub async fn run_acp_agent() -> Result<()> { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); // Start up the GooseAcpAgent connected to stdio. - let agent = GooseAcpAgent::new(tx).await + let agent = GooseAcpAgent::new(tx) + .await .map_err(|e| anyhow::anyhow!("Failed to create ACP agent: {}", e))?; let (conn, handle_io) = acp::AgentSideConnection::new(agent, outgoing, incoming, |fut| { From 32297cf163d8e4a87f8b6a6c3b6afe1d8ffe8840 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 4 Sep 2025 17:17:33 +1000 Subject: [PATCH 5/5] TUI experiment on top of ratui --- Cargo.lock | 166 ++++++++ crates/goose-cli/Cargo.toml | 3 + crates/goose-cli/src/cli.rs | 10 + crates/goose-cli/src/commands/acp_tui.rs | 502 +++++++++++++++++++++++ crates/goose-cli/src/commands/mod.rs | 1 + 5 files changed, 682 insertions(+) create mode 100644 crates/goose-cli/src/commands/acp_tui.rs diff --git a/Cargo.lock b/Cargo.lock index 73eda921ec8a..745e604e3206 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1329,12 +1329,27 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d2c12f985c78475a6b8d629afd0c360260ef34cfef52efccdcfd31972f81c2e" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cast" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -1553,6 +1568,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1814,6 +1843,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.9.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.3" @@ -2332,6 +2386,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -2713,6 +2773,7 @@ dependencies = [ "clap", "cliclack", "console", + "crossterm", "dotenvy", "etcetera", "futures", @@ -2729,6 +2790,7 @@ dependencies = [ "nix 0.30.1", "once_cell", "rand 0.8.5", + "ratatui", "regex", "rmcp", "rustyline", @@ -2740,6 +2802,7 @@ dependencies = [ "temp-env", "tempfile", "test-case", + "textwrap", "tokio", "tokio-util", "tower-http", @@ -2932,6 +2995,11 @@ name = "hashbrown" version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "hashlink" @@ -3508,6 +3576,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" +dependencies = [ + "darling 0.20.10", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.99", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -3921,6 +4002,15 @@ dependencies = [ "weezl", ] +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.2", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -4096,6 +4186,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", + "log", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.52.0", ] @@ -5166,6 +5257,27 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f60fcc7d6849342eff22c4350c8b9a989ee8ceabc4b481253e8946b9fe83d684" +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.9.0", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "rav1e" version = "0.7.1" @@ -6037,6 +6149,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -6147,6 +6280,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.99", +] + [[package]] name = "subtle" version = "2.6.1" @@ -7009,6 +7164,17 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" version = "0.1.14" diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 185f59c0cfbe..8799c718bf7d 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -60,6 +60,9 @@ indicatif = "0.17.11" tokio-util = { version = "0.7.15", features = ["compat"] } is-terminal = "0.4.16" anstream = "0.6.18" +ratatui = { version = "0.29", features = ["crossterm"] } +crossterm = "0.28" +textwrap = "0.16" [target.'cfg(target_os = "windows")'.dependencies] winapi = { version = "0.3", features = ["wincred"] } diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 1c59cc346226..e8f44af70ccd 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -4,6 +4,7 @@ use clap::{Args, Parser, Subcommand}; use goose::config::{Config, ExtensionConfig}; use crate::commands::acp::run_acp_agent; +use crate::commands::acp_tui::run_acp_tui; use crate::commands::bench::agent_generator; use crate::commands::configure::handle_configure; use crate::commands::info::handle_info; @@ -296,6 +297,10 @@ enum Command { #[command(about = "Run Goose as an ACP agent server on stdio")] Acp {}, + /// Run Goose ACP TUI client + #[command(about = "Run Goose with a Terminal User Interface (TUI) using ACP")] + AcpTui {}, + /// Start or resume interactive chat sessions #[command( about = "Start or resume interactive chat sessions", @@ -715,6 +720,7 @@ pub async fn cli() -> Result<()> { Some(Command::Info { .. }) => "info", Some(Command::Mcp { .. }) => "mcp", Some(Command::Acp {}) => "acp", + Some(Command::AcpTui {}) => "acp_tui", Some(Command::Session { .. }) => "session", Some(Command::Project {}) => "project", Some(Command::Projects) => "projects", @@ -749,6 +755,10 @@ pub async fn cli() -> Result<()> { let _ = run_acp_agent().await; return Ok(()); } + Some(Command::AcpTui {}) => { + let _ = run_acp_tui().await; + return Ok(()); + } Some(Command::Session { command, identifier, diff --git a/crates/goose-cli/src/commands/acp_tui.rs b/crates/goose-cli/src/commands/acp_tui.rs new file mode 100644 index 000000000000..9938e7669bf4 --- /dev/null +++ b/crates/goose-cli/src/commands/acp_tui.rs @@ -0,0 +1,502 @@ +use agent_client_protocol::{self as acp, Agent}; +use anyhow::Result; +use crossterm::{ + event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}, + Frame, Terminal, +}; +use std::io::{self, Stdout}; +use std::process::Stdio; +use std::sync::Arc; +use textwrap; +use tokio::sync::mpsc; +use tokio::sync::Mutex; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; +use tracing::{error, info}; + +/// Represents a message in the conversation +#[derive(Clone)] +struct ChatMessage { + role: String, + content: String, + timestamp: chrono::DateTime, +} + +/// Application state +struct App { + input: String, + messages: Vec, + scroll_offset: u16, + is_waiting: bool, + current_response: String, + session_id: Option, + status_message: String, +} + +impl App { + fn new() -> Self { + Self { + input: String::new(), + messages: Vec::new(), + scroll_offset: 0, + is_waiting: false, + current_response: String::new(), + session_id: None, + status_message: "Initializing...".to_string(), + } + } + + fn add_message(&mut self, role: String, content: String) { + self.messages.push(ChatMessage { + role, + content, + timestamp: chrono::Local::now(), + }); + } + + fn append_to_current_response(&mut self, content: &str) { + self.current_response.push_str(content); + } + + fn finalize_current_response(&mut self) { + if !self.current_response.is_empty() { + self.add_message("Agent".to_string(), self.current_response.clone()); + self.current_response.clear(); + } + self.is_waiting = false; + } + + fn scroll_up(&mut self, amount: u16) { + self.scroll_offset = self.scroll_offset.saturating_sub(amount); + } + + fn scroll_down(&mut self, amount: u16) { + self.scroll_offset = self.scroll_offset.saturating_add(amount); + } + + fn reset_scroll(&mut self) { + self.scroll_offset = 0; + } +} + +/// TUI Client implementation for ACP +struct TuiClient { + message_tx: mpsc::UnboundedSender, +} + +impl TuiClient { + fn new(message_tx: mpsc::UnboundedSender) -> Self { + Self { message_tx } + } +} + +impl acp::Client for TuiClient { + async fn request_permission( + &self, + _args: acp::RequestPermissionRequest, + ) -> anyhow::Result { + Err(acp::Error::method_not_found()) + } + + async fn write_text_file( + &self, + _args: acp::WriteTextFileRequest, + ) -> anyhow::Result<(), acp::Error> { + Err(acp::Error::method_not_found()) + } + + async fn read_text_file( + &self, + _args: acp::ReadTextFileRequest, + ) -> anyhow::Result { + Err(acp::Error::method_not_found()) + } + + async fn session_notification( + &self, + args: acp::SessionNotification, + ) -> anyhow::Result<(), acp::Error> { + match args.update { + acp::SessionUpdate::AgentMessageChunk { content } => { + let text = match content { + acp::ContentBlock::Text(text_content) => text_content.text, + acp::ContentBlock::Image(_) => "[Image]".into(), + acp::ContentBlock::Audio(_) => "[Audio]".into(), + acp::ContentBlock::ResourceLink(resource_link) => { + format!("[Resource: {}]", resource_link.uri) + } + acp::ContentBlock::Resource(_) => "[Resource]".into(), + }; + self.message_tx.send(text).ok(); + } + _ => {} + } + Ok(()) + } +} + +/// Initialize the terminal +fn init_terminal() -> Result>> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend)?; + Ok(terminal) +} + +/// Restore the terminal +fn restore_terminal(terminal: &mut Terminal>) -> Result<()> { + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + Ok(()) +} + +/// Draw the UI +fn draw_ui(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Status bar + Constraint::Min(10), // Messages + Constraint::Length(3), // Input + ]) + .split(f.area()); + + // Status bar + let status_style = if app.is_waiting { + Style::default().bg(Color::Yellow).fg(Color::Black) + } else { + Style::default().bg(Color::Blue).fg(Color::White) + }; + + let status_text = if app.is_waiting { + format!(" {} | Agent is thinking...", app.status_message) + } else { + format!(" {} | Ready", app.status_message) + }; + + let status = Paragraph::new(status_text) + .style(status_style) + .block(Block::default()); + f.render_widget(status, chunks[0]); + + // Messages area + let messages_block = Block::default() + .title(" Conversation ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + // Format messages with word wrapping + let mut formatted_lines = Vec::new(); + let width = (chunks[1].width - 4) as usize; // Account for borders and padding + + for msg in &app.messages { + // Add role header + let role_style = match msg.role.as_str() { + "User" => Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + "Agent" => Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + _ => Style::default(), + }; + + formatted_lines.push(Line::from(vec![ + Span::styled(format!("[{}]", msg.role), role_style), + Span::raw(format!(" {}", msg.timestamp.format("%H:%M:%S"))), + ])); + + // Wrap and add message content + let wrapped = textwrap::wrap(&msg.content, width); + for line in wrapped { + formatted_lines.push(Line::from(Span::raw(format!(" {}", line)))); + } + + formatted_lines.push(Line::from("")); // Empty line between messages + } + + // Add current response if in progress + if app.is_waiting && !app.current_response.is_empty() { + formatted_lines.push(Line::from(vec![ + Span::styled( + "[Agent]", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(format!(" {}", chrono::Local::now().format("%H:%M:%S"))), + ])); + + let wrapped = textwrap::wrap(&app.current_response, width); + for line in wrapped { + formatted_lines.push(Line::from(Span::raw(format!(" {}", line)))); + } + + // Add typing indicator + formatted_lines.push(Line::from(Span::styled( + " ▋", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::SLOW_BLINK), + ))); + } + + // Calculate scrolling + let total_lines = formatted_lines.len() as u16; + let visible_lines = chunks[1].height.saturating_sub(2); // Account for borders + let max_scroll = total_lines.saturating_sub(visible_lines); + let scroll_offset = app.scroll_offset.min(max_scroll); + + // Create paragraph with scrolling + let messages = Paragraph::new(formatted_lines) + .block(messages_block) + .scroll((scroll_offset, 0)) + .wrap(Wrap { trim: false }); + + f.render_widget(messages, chunks[1]); + + // Scrollbar + if total_lines > visible_lines { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("▲")) + .end_symbol(Some("▼")); + let mut scrollbar_state = + ScrollbarState::new(total_lines as usize).position(scroll_offset as usize); + + let scrollbar_area = Rect { + x: chunks[1].x + chunks[1].width - 1, + y: chunks[1].y + 1, + width: 1, + height: chunks[1].height - 2, + }; + + f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state); + } + + // Input area + let input_block = Block::default() + .title(" Input (Enter to send, Ctrl-C to exit) ") + .borders(Borders::ALL) + .border_style(if app.is_waiting { + Style::default().fg(Color::DarkGray) + } else { + Style::default().fg(Color::White) + }); + + let input_text = if app.is_waiting { + Paragraph::new(app.input.as_str()) + .style(Style::default().fg(Color::DarkGray)) + .block(input_block) + } else { + Paragraph::new(app.input.as_str()) + .style(Style::default().fg(Color::White)) + .block(input_block) + }; + + f.render_widget(input_text, chunks[2]); + + // Show cursor in input area when not waiting + if !app.is_waiting { + f.set_cursor_position((chunks[2].x + 1 + app.input.len() as u16, chunks[2].y + 1)); + } +} + +/// Handle keyboard events +fn handle_key_event(key: KeyEvent, app: &mut App) -> (Option<(acp::SessionId, String)>, bool) { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + return (None, true); // Exit + } + KeyCode::Enter if !app.is_waiting => { + if !app.input.trim().is_empty() { + let input = app.input.clone(); + app.add_message("User".to_string(), input.clone()); + app.input.clear(); + app.is_waiting = true; + app.reset_scroll(); + + // Return the prompt to send + if let Some(session_id) = &app.session_id { + return (Some((session_id.clone(), input)), false); + } + } + } + KeyCode::Char(c) if !app.is_waiting => { + app.input.push(c); + } + KeyCode::Backspace if !app.is_waiting => { + app.input.pop(); + } + KeyCode::Up => { + app.scroll_up(1); + } + KeyCode::Down => { + app.scroll_down(1); + } + KeyCode::PageUp => { + app.scroll_up(10); + } + KeyCode::PageDown => { + app.scroll_down(10); + } + KeyCode::Home => { + app.scroll_offset = 0; + } + KeyCode::End => { + app.scroll_offset = u16::MAX; // Will be clamped to max in draw_ui + } + _ => {} + } + (None, false) +} + +/// Run the TUI client +pub async fn run_acp_tui() -> Result<()> { + info!("Starting Goose ACP TUI client"); + + // Start the goose acp agent as a subprocess + let mut child = tokio::process::Command::new(std::env::current_exe()?) + .arg("acp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn()?; + + let outgoing = child.stdin.take().unwrap().compat_write(); + let incoming = child.stdout.take().unwrap().compat(); + + // Create message channel for agent responses + let (message_tx, mut message_rx) = mpsc::unbounded_channel(); + + // Create the TUI client + let client = TuiClient::new(message_tx); + + // Initialize app state + let app = Arc::new(Mutex::new(App::new())); + + // Initialize terminal + let terminal = init_terminal()?; + + // Wrap terminal in Arc for sharing + let terminal = std::sync::Arc::new(tokio::sync::Mutex::new(terminal)); + let terminal_clone = terminal.clone(); + + // The ClientSideConnection will spawn futures onto our Tokio runtime + let local_set = tokio::task::LocalSet::new(); + let result = local_set + .run_until(async move { + // Set up the client connection + let (conn, handle_io) = + acp::ClientSideConnection::new(client, outgoing, incoming, |fut| { + tokio::task::spawn_local(fut); + }); + + // Wrap the connection in an Arc for sharing + let conn = std::sync::Arc::new(conn); + + // Handle I/O in the background + tokio::task::spawn_local(handle_io); + + // Initialize the agent connection + conn.initialize(acp::InitializeRequest { + protocol_version: acp::V1, + client_capabilities: acp::ClientCapabilities::default(), + }) + .await?; + + // Create a new session + let response = conn + .new_session(acp::NewSessionRequest { + mcp_servers: Vec::new(), + cwd: std::env::current_dir()?, + }) + .await?; + + // Update app with session ID + { + let mut app = app.lock().await; + app.session_id = Some(response.session_id.clone()); + app.status_message = "Connected to Goose".to_string(); + } + + // Handle incoming messages + tokio::task::spawn_local({ + let app = app.clone(); + async move { + while let Some(content) = message_rx.recv().await { + let mut app = app.lock().await; + app.append_to_current_response(&content); + } + } + }); + + // Main UI loop + let mut should_exit = false; + while !should_exit { + // Draw UI + { + let app = app.lock().await; + let mut terminal = terminal_clone.lock().await; + terminal.draw(|f| draw_ui(f, &app))?; + } + + // Handle events with timeout + if event::poll(std::time::Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + let mut app = app.lock().await; + let (prompt_to_send, exit) = handle_key_event(key, &mut app); + should_exit = exit; + + // Send prompt if needed + if let Some((session_id, input)) = prompt_to_send { + let conn = conn.clone(); + tokio::task::spawn_local(async move { + let result = conn + .prompt(acp::PromptRequest { + session_id, + prompt: vec![input.into()], + }) + .await; + if let Err(e) = result { + error!("Failed to send prompt: {}", e); + } + }); + } + } + } + + // Check if response is complete (simple heuristic - no new content for a bit) + { + let mut app = app.lock().await; + if app.is_waiting && !app.current_response.is_empty() { + // This is a simplified check - in production you'd want proper message completion detection + app.finalize_current_response(); + } + } + } + + Ok::<(), anyhow::Error>(()) + }) + .await; + + // Restore terminal + let mut terminal = terminal.lock().await; + restore_terminal(&mut terminal)?; + + // Kill the child process + drop(child); + + result +} diff --git a/crates/goose-cli/src/commands/mod.rs b/crates/goose-cli/src/commands/mod.rs index 43b666de7d2d..169dbc1aef9f 100644 --- a/crates/goose-cli/src/commands/mod.rs +++ b/crates/goose-cli/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod acp; +pub mod acp_tui; pub mod bench; pub mod configure; pub mod info;