run tool manually via goose-server, automatic cwd analysis injected into tui context

This commit is contained in:
Tyler Longwell
2025-12-07 10:28:18 -05:00
parent 26f4834b5f
commit 087fb047c4
14 changed files with 501 additions and 37 deletions
+21 -4
View File
@@ -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<serde_json::Value>,
) -> Result<CallToolResponse> {
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")
}
}
+2 -2
View File
@@ -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,
+85
View File
@@ -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<Object>)]
pub arguments: Option<Value>,
}
#[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<Arc<AppState>>,
Json(request): Json<CallToolRequest>,
) -> Result<Json<CallToolResponse>, 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::<Vec<_>>()
.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<AppState>) -> Router {
Router::new()
.route("/agent/start", post(start_agent))
@@ -646,5 +730,6 @@ pub fn routes(state: Arc<AppState>) -> 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)
}
+198 -7
View File
@@ -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<Event>,
reply_task: &mut Option<tokio::task::JoinHandle<()>>,
@@ -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<Event>) {
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<String> {
fetch_cwd_analysis(client, session_id).await
}
async fn fetch_cwd_analysis(client: &Client, session_id: &str) -> Option<String> {
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</{CWD_ANALYSIS_TAG}>\n\n{original_text}");
Message::user().with_text(augmented)
}
async fn wait_for_pending_analysis(
client: &Client,
session_id: &str,
tx: &mpsc::UnboundedSender<Event>,
) -> Option<String> {
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<Event>,
reply_task: &mut Option<tokio::task::JoinHandle<()>>,
@@ -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<E
});
}
fn handle_create_new_session(client: &Client, tx: &mpsc::UnboundedSender<Event>) {
fn handle_create_new_session(
state: &mut AppState,
client: &Client,
tx: &mpsc::UnboundedSender<Event>,
) {
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<Event>)
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) => {
+3
View File
@@ -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())))
}
_ => {}
}
+28 -4
View File
@@ -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<String>,
context_limit: usize,
cwd_analysis: Option<String>,
) -> Result<()> {
use colors::*;
@@ -255,8 +267,11 @@ pub async fn run_cli(
);
let mut messages: Vec<Message> = 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<String, String> =
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</{CWD_ANALYSIS_TAG}>\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
+22 -3
View File
@@ -109,6 +109,20 @@ impl ChatComponent {
(items, map)
}
fn strip_cwd_analysis(text: &str) -> String {
const START_TAG: &str = "<cwd_analysis>";
const END_TAG: &str = "</cwd_analysis>";
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<ListItem<'static>>,
map: &mut Vec<usize>,
) {
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)
+14 -1
View File
@@ -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</{CWD_ANALYSIS_TAG}>\n\n{prompt}")
}
None => prompt.to_string(),
}
}
pub async fn run_headless(
client: Client,
session_id: String,
initial_prompt: String,
cwd_analysis: Option<String>,
) -> 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];
+68 -15
View File
@@ -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<String>,
) -> 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<String>,
name: Option<String>,
) -> 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
}
+6
View File
@@ -15,6 +15,7 @@ pub struct CustomCommand {
pub struct TuiConfig {
pub theme: Theme,
pub custom_commands: Vec<CustomCommand>,
pub smart_context: bool,
}
impl TuiConfig {
@@ -29,9 +30,14 @@ impl TuiConfig {
.get_param::<Vec<CustomCommand>>("tui_custom_commands")
.unwrap_or_else(|_| Self::default_commands());
let smart_context = global
.get_param::<bool>("tui_smart_context")
.unwrap_or(true);
Ok(Self {
theme: Theme::from_name(&theme_name),
custom_commands,
smart_context,
})
}
+1
View File
@@ -28,6 +28,7 @@ pub enum Event {
ConfigLoaded(serde_json::Value),
Error(String),
Flash(String),
CwdAnalysisComplete(Option<String>),
}
pub struct EventHandler {
rx: mpsc::UnboundedReceiver<Event>,
+1
View File
@@ -64,4 +64,5 @@ pub enum Action {
id: String,
approved: bool,
},
CwdAnalysisComplete(Option<String>),
}
+27
View File
@@ -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<String> {
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<Message>,
@@ -65,6 +90,7 @@ pub struct AppState {
pub needs_refresh: bool,
pub copy_mode: bool,
pub pending_confirmation: Option<PendingToolConfirmation>,
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(),
}
}
}
+25 -1
View File
@@ -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 {