From 087fb047c45e6180363bbb2b48c2b2d3bb8a83aa Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Sun, 7 Dec 2025 10:28:18 -0500 Subject: [PATCH] run tool manually via goose-server, automatic cwd analysis injected into tui context --- crates/goose-client/src/lib.rs | 25 ++- crates/goose-client/src/types.rs | 4 +- crates/goose-server/src/routes/agent.rs | 85 ++++++++++ crates/goose-tui/src/action_handler.rs | 205 +++++++++++++++++++++++- crates/goose-tui/src/app.rs | 3 + crates/goose-tui/src/cli.rs | 32 +++- crates/goose-tui/src/components/chat.rs | 25 ++- crates/goose-tui/src/headless.rs | 15 +- crates/goose-tui/src/main.rs | 83 ++++++++-- crates/goose-tui/src/services/config.rs | 6 + crates/goose-tui/src/services/events.rs | 1 + crates/goose-tui/src/state/action.rs | 1 + crates/goose-tui/src/state/mod.rs | 27 ++++ crates/goose-tui/src/state/reducer.rs | 26 ++- 14 files changed, 501 insertions(+), 37 deletions(-) diff --git a/crates/goose-client/src/lib.rs b/crates/goose-client/src/lib.rs index f06368c572..e5091794e3 100644 --- a/crates/goose-client/src/lib.rs +++ b/crates/goose-client/src/lib.rs @@ -14,11 +14,11 @@ use serde::Serialize; use tokio_stream::StreamExt; use types::{ - AddExtensionRequest, ChatRequest, ConfigResponse, ExtensionQuery, ExtensionResponse, - RemoveExtensionRequest, ResumeAgentRequest, SessionListResponse, StartAgentRequest, - UpdateProviderRequest, UpsertConfigQuery, + AddExtensionRequest, CallToolRequest, ChatRequest, ConfigResponse, ExtensionQuery, + ExtensionResponse, RemoveExtensionRequest, ResumeAgentRequest, SessionListResponse, + StartAgentRequest, UpdateProviderRequest, UpsertConfigQuery, }; -pub use types::{ProviderDetails, ToolInfo}; +pub use types::{CallToolResponse, ProviderDetails, ToolInfo}; pub struct ClientBuilder { host: String, @@ -391,4 +391,21 @@ impl Client { Err(e) => Err(anyhow::anyhow!("SSE stream error: {}", e)), })) } + + pub async fn call_tool( + &self, + session_id: &str, + tool_name: &str, + arguments: Option, + ) -> Result { + self.post("/agent/call_tool") + .json(&CallToolRequest { + session_id: session_id.to_string(), + tool_name: tool_name.to_string(), + arguments, + }) + .send() + .await + .context("Failed to call tool") + } } diff --git a/crates/goose-client/src/types.rs b/crates/goose-client/src/types.rs index 4076b6189a..405adf72df 100644 --- a/crates/goose-client/src/types.rs +++ b/crates/goose-client/src/types.rs @@ -1,7 +1,7 @@ pub use goose::agents::extension::ToolInfo; pub use goose_server::routes::agent::{ - AddExtensionRequest, RemoveExtensionRequest, ResumeAgentRequest, StartAgentRequest, - UpdateProviderRequest, + AddExtensionRequest, CallToolRequest, CallToolResponse, RemoveExtensionRequest, + ResumeAgentRequest, StartAgentRequest, UpdateProviderRequest, }; pub use goose_server::routes::config_management::{ ConfigResponse, ExtensionQuery, ExtensionResponse, ProviderDetails, UpsertConfigQuery, diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 27e5fb94fa..a3a7669ec6 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -11,6 +11,7 @@ use axum::{ Json, Router, }; use goose::config::PermissionManager; +use uuid::Uuid; use goose::agents::ExtensionConfig; use goose::config::{Config, GooseMode}; @@ -632,6 +633,89 @@ async fn stop_agent( Ok(StatusCode::OK) } +#[derive(Deserialize, Serialize, utoipa::ToSchema)] +pub struct CallToolRequest { + pub session_id: String, + pub tool_name: String, + #[schema(value_type = Option)] + pub arguments: Option, +} + +#[derive(Deserialize, Serialize, utoipa::ToSchema)] +pub struct CallToolResponse { + pub output: String, + pub is_error: bool, +} + +#[utoipa::path( + post, + path = "/agent/call_tool", + request_body = CallToolRequest, + responses( + (status = 200, description = "Tool executed successfully", body = CallToolResponse), + (status = 404, description = "Session not found"), + (status = 424, description = "Agent not initialized for session"), + (status = 500, description = "Tool execution failed") + ) +)] +async fn call_tool( + State(state): State>, + Json(request): Json, +) -> Result, ErrorResponse> { + let agent = state + .get_agent_for_route(request.session_id.clone()) + .await + .map_err(|status| ErrorResponse { + message: "Agent not initialized for this session".into(), + status, + })?; + + let session = SessionManager::get_session(&request.session_id, false) + .await + .map_err(|e| ErrorResponse { + message: format!("Session not found: {}", e), + status: StatusCode::NOT_FOUND, + })?; + + let tool_call = rmcp::model::CallToolRequestParam { + name: request.tool_name.clone().into(), + arguments: request.arguments.and_then(|v| v.as_object().cloned()), + }; + + let request_id = Uuid::new_v4().to_string(); + + let (_req_id, result) = agent + .dispatch_tool_call(tool_call, request_id, None, &session) + .await; + + match result { + Ok(tool_result) => { + let content_result = tool_result.result.await; + match content_result { + Ok(content) => { + let output = content + .iter() + .filter_map(|c| c.as_text().map(|t| t.text.to_string())) + .collect::>() + .join("\n"); + Ok(Json(CallToolResponse { + output, + is_error: false, + })) + } + Err(error_data) => Ok(Json(CallToolResponse { + output: error_data.message.to_string(), + is_error: true, + })), + } + } + Err(error_data) => Ok(Json(CallToolResponse { + output: error_data.message.to_string(), + is_error: true, + })), + } +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/agent/start", post(start_agent)) @@ -646,5 +730,6 @@ pub fn routes(state: Arc) -> Router { .route("/agent/add_extension", post(agent_add_extension)) .route("/agent/remove_extension", post(agent_remove_extension)) .route("/agent/stop", post(stop_agent)) + .route("/agent/call_tool", post(call_tool)) .with_state(state) } diff --git a/crates/goose-tui/src/action_handler.rs b/crates/goose-tui/src/action_handler.rs index 17606df1cb..027281afee 100644 --- a/crates/goose-tui/src/action_handler.rs +++ b/crates/goose-tui/src/action_handler.rs @@ -1,13 +1,58 @@ use crate::services::events::Event; use crate::state::action::Action; -use crate::state::AppState; +use crate::state::{AppState, CwdAnalysisState}; +use goose::conversation::message::Message; use goose_client::Client; +use std::time::Duration; use tokio::sync::mpsc; use tokio_stream::StreamExt; +const CWD_ANALYSIS_TAG: &str = "cwd_analysis"; +const CWD_ANALYSIS_TIMEOUT: Duration = Duration::from_secs(30); +const CWD_ANALYSIS_GRACE_PERIOD: Duration = Duration::from_secs(2); +const CWD_ANALYSIS_DEPTH: u32 = 3; + +fn detect_analysis_target(cwd: &std::path::Path) -> (std::path::PathBuf, u32) { + let monorepo_markers = [ + "pnpm-workspace.yaml", + "lerna.json", + "nx.json", + "turbo.json", + "rush.json", + "go.work", + ]; + + let is_rust_workspace = std::fs::read_to_string(cwd.join("Cargo.toml")) + .map(|content| content.contains("[workspace]")) + .unwrap_or(false); + + let is_monorepo = is_rust_workspace || monorepo_markers.iter().any(|m| cwd.join(m).exists()); + + if is_monorepo { + let monorepo_dirs = ["crates", "packages", "apps", "libs", "cmd", "internal"]; + for dir in monorepo_dirs { + let path = cwd.join(dir); + if path.is_dir() { + return (path, CWD_ANALYSIS_DEPTH); + } + } + return (cwd.to_path_buf(), 1); + } + + let source_dirs = ["src", "lib", "app", "cmd", "Sources", "internal"]; + for dir in source_dirs { + let path = cwd.join(dir); + if path.is_dir() { + return (path, CWD_ANALYSIS_DEPTH); + } + } + + (cwd.to_path_buf(), 1) +} + pub fn handle_action( action: &Action, - state: &AppState, + state: &mut AppState, client: &Client, tx: &mpsc::UnboundedSender, reply_task: &mut Option>, @@ -20,7 +65,7 @@ pub fn handle_action( handle_resume_session(id, client, tx); } Action::CreateNewSession => { - handle_create_new_session(client, tx); + handle_create_new_session(state, client, tx); } Action::OpenSessionPicker => { handle_open_session_picker(client, tx); @@ -59,9 +104,120 @@ pub fn handle_action( false } +pub fn spawn_cwd_analysis(session_id: &str, client: &Client, tx: &mpsc::UnboundedSender) { + let client = client.clone(); + let tx = tx.clone(); + let session_id = session_id.to_string(); + tokio::spawn(async move { + let result = fetch_cwd_analysis(&client, &session_id).await; + let _ = tx.send(Event::CwdAnalysisComplete(result)); + }); +} + +pub async fn fetch_cwd_analysis_sync(client: &Client, session_id: &str) -> Option { + fetch_cwd_analysis(client, session_id).await +} + +async fn fetch_cwd_analysis(client: &Client, session_id: &str) -> Option { + let cwd = std::env::current_dir().ok()?; + let (target_path, depth) = detect_analysis_target(&cwd); + let start = std::time::Instant::now(); + tracing::info!( + "Smart context: analyzing {} (depth={})", + target_path.display(), + depth + ); + + let analyze_future = client.call_tool( + session_id, + "developer__analyze", + Some(serde_json::json!({ + "path": target_path.to_string_lossy(), + "max_depth": depth + })), + ); + + let result = tokio::time::timeout(CWD_ANALYSIS_TIMEOUT, analyze_future).await; + let elapsed = start.elapsed(); + + match result { + Ok(Ok(response)) if !response.is_error && !response.output.is_empty() => { + tracing::info!( + "Smart context: completed in {:.2}s ({} chars)", + elapsed.as_secs_f64(), + response.output.len() + ); + Some(response.output) + } + Ok(Ok(response)) if response.is_error => { + tracing::warn!( + "Smart context: failed in {:.2}s: {}", + elapsed.as_secs_f64(), + response.output + ); + None + } + Ok(Err(e)) => { + tracing::warn!( + "Smart context: error in {:.2}s: {}", + elapsed.as_secs_f64(), + e + ); + None + } + Err(_) => { + tracing::warn!( + "Smart context: timed out after {:.2}s", + elapsed.as_secs_f64() + ); + None + } + _ => { + tracing::debug!( + "Smart context: empty result in {:.2}s", + elapsed.as_secs_f64() + ); + None + } + } +} + +fn prepend_cwd_analysis(message: &Message, analysis: &str) -> Message { + let original_text = message.as_concat_text(); + let augmented = + format!("<{CWD_ANALYSIS_TAG}>\n{analysis}\n\n\n{original_text}"); + Message::user().with_text(augmented) +} + +async fn wait_for_pending_analysis( + client: &Client, + session_id: &str, + tx: &mpsc::UnboundedSender, +) -> Option { + let (result_tx, mut result_rx) = mpsc::unbounded_channel(); + let client = client.clone(); + let session_id = session_id.to_string(); + + tokio::spawn(async move { + let result = fetch_cwd_analysis(&client, &session_id).await; + let _ = result_tx.send(result); + }); + + match tokio::time::timeout(CWD_ANALYSIS_GRACE_PERIOD, result_rx.recv()).await { + Ok(Some(result)) => { + let _ = tx.send(Event::CwdAnalysisComplete(result.clone())); + result + } + _ => { + tracing::info!("Smart context: grace period expired, proceeding without analysis"); + None + } + } +} + fn handle_send_message( - message_to_send: &goose::conversation::message::Message, - state: &AppState, + message_to_send: &Message, + state: &mut AppState, client: &Client, tx: &mpsc::UnboundedSender, reply_task: &mut Option>, @@ -69,10 +225,34 @@ fn handle_send_message( let client = client.clone(); let tx = tx.clone(); let mut messages_snapshot = state.messages.clone(); - messages_snapshot.push(message_to_send.clone()); let session_id = state.session_id.clone(); + let is_first_message = messages_snapshot.is_empty(); + let analysis_result = if is_first_message { + state.cwd_analysis.take_result() + } else { + None + }; + let analysis_pending = is_first_message && state.cwd_analysis.is_pending(); + + let user_message = message_to_send.clone(); + let task = tokio::spawn(async move { + let analysis = if analysis_result.is_some() { + analysis_result + } else if analysis_pending { + wait_for_pending_analysis(&client, &session_id, &tx).await + } else { + None + }; + + let final_user_message = match analysis { + Some(ref a) => prepend_cwd_analysis(&user_message, a), + None => user_message, + }; + + messages_snapshot.push(final_user_message); + match client.reply(messages_snapshot, session_id).await { Ok(mut stream) => { while let Some(result) = stream.next().await { @@ -110,7 +290,15 @@ fn handle_resume_session(id: &str, client: &Client, tx: &mpsc::UnboundedSender) { +fn handle_create_new_session( + state: &mut AppState, + client: &Client, + tx: &mpsc::UnboundedSender, +) { + let smart_context = state.config.smart_context; + if smart_context { + state.cwd_analysis = CwdAnalysisState::Pending; + } let client = client.clone(); let tx = tx.clone(); tokio::spawn(async move { @@ -118,6 +306,9 @@ fn handle_create_new_session(client: &Client, tx: &mpsc::UnboundedSender) match client.start_agent(cwd.to_string_lossy().to_string()).await { Ok(s) => { crate::configure_session_from_global(&client, &s.id).await; + if smart_context { + spawn_cwd_analysis(&s.id, &client, &tx); + } let _ = tx.send(Event::SessionResumed(Box::new(s))); } Err(e) => { diff --git a/crates/goose-tui/src/app.rs b/crates/goose-tui/src/app.rs index 29c7a7433f..0df8ba1ed5 100644 --- a/crates/goose-tui/src/app.rs +++ b/crates/goose-tui/src/app.rs @@ -146,6 +146,9 @@ impl<'a> Component for App<'a> { Event::Error(e) => return Ok(Some(Action::Error(e.clone()))), Event::Flash(msg) => return Ok(Some(Action::ShowFlash(msg.clone()))), Event::Resize => return Ok(Some(Action::Resize)), + Event::CwdAnalysisComplete(result) => { + return Ok(Some(Action::CwdAnalysisComplete(result.clone()))) + } _ => {} } diff --git a/crates/goose-tui/src/cli.rs b/crates/goose-tui/src/cli.rs index 588cbca6f7..dfa3f6506e 100644 --- a/crates/goose-tui/src/cli.rs +++ b/crates/goose-tui/src/cli.rs @@ -239,12 +239,24 @@ fn print_help() { println!(); } +const CWD_ANALYSIS_TAG: &str = "cwd_analysis"; + +fn print_context_status(success: bool) { + use colors::*; + if success { + println!("{GREEN}✓{RESET} {DIM}Project context loaded{RESET}"); + } else { + println!("{YELLOW}⚠{RESET} {DIM}Project context unavailable{RESET}"); + } +} + pub async fn run_cli( client: Client, session_id: String, provider: String, model: Option, context_limit: usize, + cwd_analysis: Option, ) -> Result<()> { use colors::*; @@ -255,8 +267,11 @@ pub async fn run_cli( ); let mut messages: Vec = Vec::new(); let mut last_token_count: i32 = 0; + let mut cwd_analysis_used = false; print_session_header(&provider, model.as_deref(), &session_id); + print_context_status(cwd_analysis.is_some()); + println!(); let mut pending_tools: std::collections::HashMap = std::collections::HashMap::new(); @@ -266,7 +281,6 @@ pub async fn run_cli( 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) => { @@ -288,7 +302,6 @@ pub async fn run_cli( continue; } - // Handle commands match trimmed { "/exit" | "/quit" | "/q" => break, "/help" | "/?" => { @@ -309,8 +322,19 @@ pub async fn run_cli( _ => {} } - // Send message and stream response - let user_message = Message::user().with_text(trimmed); + let message_text = if !cwd_analysis_used { + if let Some(ref analysis) = cwd_analysis { + cwd_analysis_used = true; + format!("<{CWD_ANALYSIS_TAG}>\n{analysis}\n\n\n{trimmed}") + } else { + cwd_analysis_used = true; + trimmed.to_string() + } + } else { + trimmed.to_string() + }; + + let user_message = Message::user().with_text(&message_text); messages.push(user_message.clone()); // Start spinner while waiting for response diff --git a/crates/goose-tui/src/components/chat.rs b/crates/goose-tui/src/components/chat.rs index 94e058b7b5..b68f653367 100644 --- a/crates/goose-tui/src/components/chat.rs +++ b/crates/goose-tui/src/components/chat.rs @@ -109,6 +109,20 @@ impl ChatComponent { (items, map) } + fn strip_cwd_analysis(text: &str) -> String { + const START_TAG: &str = ""; + const END_TAG: &str = ""; + + if let Some(start) = text.find(START_TAG) { + if let Some(end) = text[start..].find(END_TAG) { + let before = &text[..start]; + let after = &text[start + end + END_TAG.len()..]; + return format!("{}{}", before, after.trim_start()); + } + } + text.to_string() + } + fn render_user_text( t: &rmcp::model::TextContent, msg_idx: usize, @@ -117,12 +131,17 @@ impl ChatComponent { items: &mut Vec>, map: &mut Vec, ) { + let display_text = if msg_idx == 0 { + Self::strip_cwd_analysis(&t.text) + } else { + t.text.clone() + }; + let user_text_style = Style::default().fg(theme.base.user_message_foreground); let renderer = MarkdownRenderer::new(theme, Some(user_text_style)); - let mut rendered_lines = renderer.render_lines(&t.text, width.saturating_sub(4)); + let mut rendered_lines = renderer.render_lines(&display_text, width.saturating_sub(4)); - // Remove trailing empty line if text doesn't end with double newline - if !t.text.ends_with("\n\n") + if !display_text.ends_with("\n\n") && rendered_lines .last() .is_some_and(|line| line.spans.is_empty() || line.width() == 0) diff --git a/crates/goose-tui/src/headless.rs b/crates/goose-tui/src/headless.rs index 3814373b45..ed8e59b41a 100644 --- a/crates/goose-tui/src/headless.rs +++ b/crates/goose-tui/src/headless.rs @@ -6,10 +6,22 @@ use std::io::Write; use tokio_stream::StreamExt; use tokio_util::sync::CancellationToken; +const CWD_ANALYSIS_TAG: &str = "cwd_analysis"; + +fn prepend_context(prompt: &str, context: Option<&str>) -> String { + match context { + Some(analysis) => { + format!("<{CWD_ANALYSIS_TAG}>\n{analysis}\n\n\n{prompt}") + } + None => prompt.to_string(), + } +} + pub async fn run_headless( client: Client, session_id: String, initial_prompt: String, + cwd_analysis: Option, ) -> Result<()> { let cancel_token = CancellationToken::new(); @@ -20,7 +32,8 @@ pub async fn run_headless( cancel_clone.cancel(); }); - let user_message = Message::user().with_text(&initial_prompt); + let full_prompt = prepend_context(&initial_prompt, cwd_analysis.as_deref()); + let user_message = Message::user().with_text(&full_prompt); println!("[user] {initial_prompt}"); let messages = vec![user_message]; diff --git a/crates/goose-tui/src/main.rs b/crates/goose-tui/src/main.rs index 91a9f3a7e4..dc5f2f7249 100644 --- a/crates/goose-tui/src/main.rs +++ b/crates/goose-tui/src/main.rs @@ -288,6 +288,7 @@ async fn run_recipe_mode( recipe_path: PathBuf, headless: bool, ) -> Result<()> { + let config = TuiConfig::load()?; let recipe = load_recipe(&recipe_path)?; let prompt = recipe .prompt @@ -302,12 +303,18 @@ async fn run_recipe_mode( configure_session_from_global(&client, &initial_session.id).await; + let cwd_analysis = if config.smart_context { + action_handler::fetch_cwd_analysis_sync(&client, &initial_session.id).await + } else { + None + }; + let use_headless = headless || !std::io::stdout().is_terminal(); if use_headless { - headless::run_headless(client, initial_session.id, prompt).await + headless::run_headless(client, initial_session.id, prompt, cwd_analysis).await } else { - run_recipe_tui_mode(client, initial_session, prompt).await + run_recipe_tui_mode(client, initial_session, prompt, cwd_analysis).await } } @@ -320,9 +327,11 @@ async fn run_text_mode( ) -> Result<()> { info!("Running with text input"); - let initial_session = if let Some(id) = session { + let config = TuiConfig::load()?; + + let (initial_session, is_new_session) = if let Some(id) = session { info!("Resuming session: {}", id); - client.resume_agent(&id).await? + (client.resume_agent(&id).await?, false) } else { let new_session = client .start_agent(cwd.to_string_lossy().to_string()) @@ -337,18 +346,30 @@ async fn run_text_mode( } } - new_session + (new_session, true) }; configure_session_from_global(&client, &initial_session.id).await; - headless::run_headless(client, initial_session.id, prompt).await + let has_messages = initial_session + .conversation + .as_ref() + .is_some_and(|c| !c.messages().is_empty()); + + let cwd_analysis = if config.smart_context && is_new_session && !has_messages { + action_handler::fetch_cwd_analysis_sync(&client, &initial_session.id).await + } else { + None + }; + + headless::run_headless(client, initial_session.id, prompt, cwd_analysis).await } async fn run_recipe_tui_mode( client: Client, initial_session: goose::session::Session, prompt: String, + cwd_analysis: Option, ) -> Result<()> { let config = TuiConfig::load()?; let mut state = AppState::new(initial_session.id.clone(), config, None, None); @@ -358,6 +379,10 @@ async fn run_recipe_tui_mode( .map(|c| c.messages().clone()) .unwrap_or_default(); + if let Some(analysis) = cwd_analysis { + state.cwd_analysis = state::CwdAnalysisState::Complete(analysis); + } + let terminal = tui::init()?; let app = App::new(); let event_handler = EventHandler::new(); @@ -374,9 +399,13 @@ async fn run_interactive_mode( session: Option, name: Option, ) -> Result<()> { - let initial_session = if let Some(id) = session { + let config = TuiConfig::load()?; + let event_handler = EventHandler::new(); + let tx = event_handler.sender(); + + let (initial_session, is_new_session) = if let Some(id) = session { info!("Resuming agent session: {}", id); - client.resume_agent(&id).await? + (client.resume_agent(&id).await?, false) } else { let new_session = client .start_agent(cwd.to_string_lossy().to_string()) @@ -391,12 +420,11 @@ async fn run_interactive_mode( } } - new_session + (new_session, true) }; let (provider, model) = configure_session_from_global(&client, &initial_session.id).await; - let config = TuiConfig::load()?; let mut state = AppState::new( initial_session.id.clone(), config, @@ -422,9 +450,13 @@ async fn run_interactive_mode( state.token_state.input_tokens = initial_session.input_tokens.unwrap_or(0); state.token_state.output_tokens = initial_session.output_tokens.unwrap_or(0); + if state.config.smart_context && is_new_session && state.messages.is_empty() { + state.cwd_analysis = state::CwdAnalysisState::Pending; + action_handler::spawn_cwd_analysis(&initial_session.id, &client, &tx); + } + let terminal = tui::init()?; let app = App::new(); - let event_handler = EventHandler::new(); let result = run_event_loop(terminal, app, event_handler, state, client).await; @@ -440,9 +472,11 @@ async fn run_cli_mode( ) -> Result<()> { info!("Starting CLI mode"); - let initial_session = if let Some(id) = session { + let config = TuiConfig::load()?; + + let (initial_session, is_new_session) = if let Some(id) = session { info!("Resuming session: {}", id); - client.resume_agent(&id).await? + (client.resume_agent(&id).await?, false) } else { let new_session = client .start_agent(cwd.to_string_lossy().to_string()) @@ -457,7 +491,7 @@ async fn run_cli_mode( } } - new_session + (new_session, true) }; let (provider, model) = configure_session_from_global(&client, &initial_session.id).await; @@ -471,5 +505,24 @@ async fn run_cli_mode( }) .unwrap_or(goose_tui::DEFAULT_CONTEXT_LIMIT); - cli::run_cli(client, initial_session.id, provider, model, context_limit).await + let has_messages = initial_session + .conversation + .as_ref() + .is_some_and(|c| !c.messages().is_empty()); + + let cwd_analysis = if config.smart_context && is_new_session && !has_messages { + action_handler::fetch_cwd_analysis_sync(&client, &initial_session.id).await + } else { + None + }; + + cli::run_cli( + client, + initial_session.id, + provider, + model, + context_limit, + cwd_analysis, + ) + .await } diff --git a/crates/goose-tui/src/services/config.rs b/crates/goose-tui/src/services/config.rs index c421f8387f..495b0dc963 100644 --- a/crates/goose-tui/src/services/config.rs +++ b/crates/goose-tui/src/services/config.rs @@ -15,6 +15,7 @@ pub struct CustomCommand { pub struct TuiConfig { pub theme: Theme, pub custom_commands: Vec, + pub smart_context: bool, } impl TuiConfig { @@ -29,9 +30,14 @@ impl TuiConfig { .get_param::>("tui_custom_commands") .unwrap_or_else(|_| Self::default_commands()); + let smart_context = global + .get_param::("tui_smart_context") + .unwrap_or(true); + Ok(Self { theme: Theme::from_name(&theme_name), custom_commands, + smart_context, }) } diff --git a/crates/goose-tui/src/services/events.rs b/crates/goose-tui/src/services/events.rs index 2a0b197225..b7bb42a4e9 100644 --- a/crates/goose-tui/src/services/events.rs +++ b/crates/goose-tui/src/services/events.rs @@ -28,6 +28,7 @@ pub enum Event { ConfigLoaded(serde_json::Value), Error(String), Flash(String), + CwdAnalysisComplete(Option), } pub struct EventHandler { rx: mpsc::UnboundedReceiver, diff --git a/crates/goose-tui/src/state/action.rs b/crates/goose-tui/src/state/action.rs index 0066d51006..92816366fe 100644 --- a/crates/goose-tui/src/state/action.rs +++ b/crates/goose-tui/src/state/action.rs @@ -64,4 +64,5 @@ pub enum Action { id: String, approved: bool, }, + CwdAnalysisComplete(Option), } diff --git a/crates/goose-tui/src/state/mod.rs b/crates/goose-tui/src/state/mod.rs index bab3845d27..e5d2a3dc8b 100644 --- a/crates/goose-tui/src/state/mod.rs +++ b/crates/goose-tui/src/state/mod.rs @@ -43,6 +43,31 @@ pub struct TodoItem { pub done: bool, } +#[derive(Debug, Clone, Default)] +pub enum CwdAnalysisState { + #[default] + NotStarted, + Pending, + Complete(String), + Failed, +} + +impl CwdAnalysisState { + pub fn take_result(&mut self) -> Option { + match std::mem::take(self) { + CwdAnalysisState::Complete(s) => Some(s), + other => { + *self = other; + None + } + } + } + + pub fn is_pending(&self) -> bool { + matches!(self, CwdAnalysisState::Pending) + } +} + pub struct AppState { pub session_id: String, pub messages: Vec, @@ -65,6 +90,7 @@ pub struct AppState { pub needs_refresh: bool, pub copy_mode: bool, pub pending_confirmation: Option, + pub cwd_analysis: CwdAnalysisState, } impl AppState { @@ -96,6 +122,7 @@ impl AppState { needs_refresh: false, copy_mode: false, pending_confirmation: None, + cwd_analysis: CwdAnalysisState::default(), } } } diff --git a/crates/goose-tui/src/state/reducer.rs b/crates/goose-tui/src/state/reducer.rs index 09614ef747..2853e808c6 100644 --- a/crates/goose-tui/src/state/reducer.rs +++ b/crates/goose-tui/src/state/reducer.rs @@ -1,5 +1,7 @@ use super::action::Action; -use crate::state::{ActivePopup, AppState, InputMode, PendingToolConfirmation, TodoItem}; +use crate::state::{ + ActivePopup, AppState, CwdAnalysisState, InputMode, PendingToolConfirmation, TodoItem, +}; use goose::conversation::message::{Message, MessageContent, ToolConfirmationRequest}; use goose::model::ModelConfig; use goose::providers::base::ModelInfo; @@ -28,6 +30,24 @@ pub fn update(state: &mut AppState, action: Action) { Action::ServerMessage(msg) => handle_server_message(state, msg), Action::ModelsLoaded { provider, models } => handle_models_loaded(state, provider, models), Action::ConfigLoaded(config) => handle_config_loaded(state, config), + Action::CwdAnalysisComplete(result) => { + state.cwd_analysis = match result { + Some(s) => { + state.flash_message = Some(( + "✓ Project context loaded".to_string(), + Instant::now() + FLASH_DURATION_SHORT, + )); + CwdAnalysisState::Complete(s) + } + None => { + state.flash_message = Some(( + "⚠ Project context unavailable".to_string(), + Instant::now() + FLASH_DURATION_SHORT, + )); + CwdAnalysisState::Failed + } + }; + } _ => { if !handle_data_loaded(state, &action) && !handle_chat(state, &action) @@ -41,6 +61,7 @@ pub fn update(state: &mut AppState, action: Action) { fn handle_data_loaded(state: &mut AppState, action: &Action) -> bool { match action { Action::SessionResumed(session) => { + let is_new_session = state.session_id != session.id; state.session_id = session.id.clone(); state.messages = session .conversation @@ -60,6 +81,9 @@ fn handle_data_loaded(state: &mut AppState, action: &Action) -> bool { state.active_popup = ActivePopup::None; state.is_working = false; state.pending_confirmation = None; + if is_new_session && !state.messages.is_empty() { + state.cwd_analysis = CwdAnalysisState::Failed; + } let pending = state.messages.last().and_then(|last_msg| { extract_tool_confirmation(last_msg).map(|req| PendingToolConfirmation {