From add44cfe21865cf45c792710b50f646efe32110f Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Sat, 29 Nov 2025 14:59:14 -0500 Subject: [PATCH] refactor 2 initial --- crates/goose-tui/src/action_handler.rs | 401 ++++++++++++ crates/goose-tui/src/commands.rs | 79 --- crates/goose-tui/src/components/chat.rs | 24 +- crates/goose-tui/src/components/input.rs | 40 +- .../src/components/popups/builder.rs | 13 +- crates/goose-tui/src/main.rs | 595 +----------------- crates/goose-tui/src/runner.rs | 203 ++++++ crates/goose-tui/src/utils/json.rs | 8 + crates/goose-tui/src/utils/mod.rs | 1 + crates/goose-tui/src/utils/sanitize.rs | 2 +- crates/goose-tui/src/utils/styles.rs | 22 + .../goose-tui/src/utils/termimad_renderer.rs | 21 +- 12 files changed, 675 insertions(+), 734 deletions(-) create mode 100644 crates/goose-tui/src/action_handler.rs delete mode 100644 crates/goose-tui/src/commands.rs create mode 100644 crates/goose-tui/src/runner.rs create mode 100644 crates/goose-tui/src/utils/json.rs diff --git a/crates/goose-tui/src/action_handler.rs b/crates/goose-tui/src/action_handler.rs new file mode 100644 index 0000000000..9b4fedbddd --- /dev/null +++ b/crates/goose-tui/src/action_handler.rs @@ -0,0 +1,401 @@ +use crate::services::events::Event; +use crate::state::action::Action; +use crate::state::AppState; +use goose_client::Client; +use tokio::sync::mpsc; +use tokio_stream::StreamExt; + +pub fn handle_action( + action: &Action, + state: &AppState, + client: &Client, + tx: &mpsc::UnboundedSender, + reply_task: &mut Option>, +) -> bool { + match action { + Action::SendMessage(message_to_send) => { + handle_send_message(message_to_send, state, client, tx, reply_task); + } + Action::ResumeSession(id) => { + handle_resume_session(id, client, tx); + } + Action::CreateNewSession => { + handle_create_new_session(client, tx); + } + Action::OpenSessionPicker => { + handle_open_session_picker(client, tx); + } + Action::OpenConfig => { + handle_open_config(client, tx); + } + Action::FetchModels(provider) => { + handle_fetch_models(provider, client, tx); + } + Action::UpdateProvider { provider, model } => { + handle_update_provider(provider, model, state, client, tx); + } + Action::ToggleExtension { name, enabled } => { + handle_toggle_extension(name, *enabled, state, client, tx); + } + Action::ForkFromMessage(msg_idx) => { + handle_fork_from_message(*msg_idx, state, client, tx); + } + Action::Quit => { + return true; + } + Action::Interrupt => { + if let Some(task) = reply_task.take() { + task.abort(); + } + } + _ => {} + } + false +} + +fn handle_send_message( + message_to_send: &goose::conversation::message::Message, + state: &AppState, + client: &Client, + tx: &mpsc::UnboundedSender, + reply_task: &mut Option>, +) { + 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 task = tokio::spawn(async move { + match client.reply(messages_snapshot, session_id).await { + Ok(mut stream) => { + while let Some(result) = stream.next().await { + match result { + Ok(msg) => { + let _ = tx.send(Event::Server(std::sync::Arc::new(msg))); + } + Err(e) => { + let _ = tx.send(Event::Error(e.to_string())); + } + } + } + } + Err(e) => { + let _ = tx.send(Event::Error(e.to_string())); + } + } + }); + *reply_task = Some(task); +} + +fn handle_resume_session(id: &str, client: &Client, tx: &mpsc::UnboundedSender) { + let client = client.clone(); + let tx = tx.clone(); + let id = id.to_string(); + tokio::spawn(async move { + match client.resume_agent(&id).await { + Ok(s) => { + let _ = tx.send(Event::SessionResumed(Box::new(s))); + } + Err(e) => { + let _ = tx.send(Event::Error(e.to_string())); + } + } + }); +} + +fn handle_create_new_session(client: &Client, tx: &mpsc::UnboundedSender) { + let client = client.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + let cwd = std::env::current_dir().unwrap_or_default(); + match client.start_agent(cwd.to_string_lossy().to_string()).await { + Ok(s) => { + let global_config = goose::config::Config::global(); + let provider = global_config + .get_goose_provider() + .unwrap_or_else(|_| "openai".to_string()); + let model = global_config.get_goose_model().ok(); + + if let Err(e) = client.update_provider(&s.id, provider, model).await { + let _ = tx.send(Event::Error(format!("Failed to update provider: {e}"))); + } + + for ext in goose::config::get_enabled_extensions() { + if let Err(e) = client.add_extension(&s.id, ext.clone()).await { + let _ = tx.send(Event::Error(format!( + "Failed to add extension {}: {e}", + ext.name() + ))); + } + } + + let _ = tx.send(Event::SessionResumed(Box::new(s))); + } + Err(e) => { + let _ = tx.send(Event::Error(e.to_string())); + } + } + }); +} + +fn handle_open_session_picker(client: &Client, tx: &mpsc::UnboundedSender) { + let client = client.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + match client.list_sessions().await { + Ok(sessions) => { + let _ = tx.send(Event::SessionsList(sessions)); + } + Err(e) => { + let _ = tx.send(Event::Error(e.to_string())); + } + } + }); +} + +fn handle_open_config(client: &Client, tx: &mpsc::UnboundedSender) { + let client = client.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + match client.get_providers().await { + Ok(providers) => { + let _ = tx.send(Event::ProvidersLoaded(providers)); + } + Err(e) => { + let _ = tx.send(Event::Error(e.to_string())); + } + } + match client.get_extensions().await { + Ok(extensions) => { + let _ = tx.send(Event::ExtensionsLoaded(extensions)); + } + Err(e) => { + let _ = tx.send(Event::Error(e.to_string())); + } + } + match client.read_config().await { + Ok(config) => { + let _ = tx.send(Event::ConfigLoaded(config)); + } + Err(e) => { + let _ = tx.send(Event::Error(e.to_string())); + } + } + }); +} + +fn handle_fetch_models(provider: &str, client: &Client, tx: &mpsc::UnboundedSender) { + let client = client.clone(); + let tx = tx.clone(); + let p = provider.to_string(); + tokio::spawn(async move { + match client.get_provider_models(&p).await { + Ok(models) => { + let _ = tx.send(Event::ModelsLoaded { + provider: p, + models, + }); + } + Err(e) => { + tracing::warn!("Failed to fetch models for {}: {}", p, e); + } + } + }); +} + +fn handle_update_provider( + provider: &str, + model: &str, + state: &AppState, + client: &Client, + tx: &mpsc::UnboundedSender, +) { + let client = client.clone(); + let tx = tx.clone(); + let session_id = state.session_id.clone(); + let p = provider.to_string(); + let m = model.to_string(); + tokio::spawn(async move { + if let Err(e) = client + .update_provider(&session_id, p.clone(), Some(m.clone())) + .await + { + let _ = tx.send(Event::Error(format!( + "Failed to update session provider: {e}" + ))); + return; + } + if let Err(e) = client + .upsert_config("GOOSE_PROVIDER", serde_json::json!(p), false) + .await + { + let _ = tx.send(Event::Error(format!( + "Failed to update config provider: {e}" + ))); + } + if let Err(e) = client + .upsert_config("GOOSE_MODEL", serde_json::json!(m), false) + .await + { + let _ = tx.send(Event::Error(format!("Failed to update config model: {e}"))); + } + }); +} + +fn handle_toggle_extension( + name: &str, + enabled: bool, + state: &AppState, + client: &Client, + tx: &mpsc::UnboundedSender, +) { + let client = client.clone(); + let tx = tx.clone(); + let session_id = state.session_id.clone(); + let ext_name = name.to_string(); + + let ext_config = state + .extensions + .iter() + .find(|e| e.config.name() == ext_name) + .map(|e| e.config.clone()); + + if let Some(config) = ext_config { + tokio::spawn(async move { + if enabled { + if let Err(e) = client.add_extension(&session_id, config.clone()).await { + let _ = tx.send(Event::Error(format!( + "Failed to enable extension in session: {e}" + ))); + } + if let Err(e) = client.add_config_extension(ext_name, config, true).await { + let _ = tx.send(Event::Error(format!( + "Failed to enable extension in config: {e}" + ))); + } + } else { + if let Err(e) = client.remove_extension(&session_id, &ext_name).await { + let _ = tx.send(Event::Error(format!( + "Failed to disable extension in session: {e}" + ))); + } + if let Err(e) = client.add_config_extension(ext_name, config, false).await { + let _ = tx.send(Event::Error(format!( + "Failed to disable extension in config: {e}" + ))); + } + } + + match client.get_extensions().await { + Ok(extensions) => { + let _ = tx.send(Event::ExtensionsLoaded(extensions)); + } + Err(e) => { + let _ = tx.send(Event::Error(e.to_string())); + } + } + }); + } +} + +fn handle_fork_from_message( + msg_idx: usize, + state: &AppState, + client: &Client, + tx: &mpsc::UnboundedSender, +) { + let client = client.clone(); + let tx = tx.clone(); + let session_id = state.session_id.clone(); + + tokio::spawn(async move { + let exported = match client.export_session(&session_id).await { + Ok(json) => { + tracing::debug!( + "Fork: exported session (first 500 chars): {}", + &json[..json.len().min(500)] + ); + json + } + Err(e) => { + let _ = tx.send(Event::Error(format!("Export failed: {e}"))); + return; + } + }; + + let mut session: serde_json::Value = match serde_json::from_str(&exported) { + Ok(v) => v, + Err(e) => { + tracing::error!("Fork: failed to parse exported JSON: {e}"); + let _ = tx.send(Event::Error(format!("Parse failed: {e}"))); + return; + } + }; + + let original_count = session + .get("conversation") + .and_then(|c| c.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + + if let Some(conv) = session.get_mut("conversation") { + if let Some(messages) = conv.as_array_mut() { + messages.truncate(msg_idx + 1); + } + } + + let new_count = session + .get("conversation") + .and_then(|c| c.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + + tracing::info!( + "Fork: truncating from {} to {} messages (up to index {})", + original_count, + new_count, + msg_idx + ); + + if let Some(name) = session.get("name").and_then(|n| n.as_str()) { + session["name"] = serde_json::json!(format!("Fork: {}", name)); + } + + let modified_json = serde_json::to_string(&session).unwrap_or_default(); + tracing::info!( + "Fork: sending import request with {} bytes", + modified_json.len() + ); + let forked = match client.import_session(&modified_json).await { + Ok(s) => { + tracing::info!("Fork: import succeeded, new session id: {}", s.id); + s + } + Err(e) => { + tracing::error!("Fork: import failed: {e}"); + let _ = tx.send(Event::Error(format!("Import failed: {e}"))); + return; + } + }; + + tracing::info!( + "Fork: imported session {} with {} messages", + forked.id, + forked + .conversation + .as_ref() + .map(|c| c.messages().len()) + .unwrap_or(0) + ); + + match client.resume_agent(&forked.id).await { + Ok(s) => { + let _ = tx.send(Event::SessionResumed(Box::new(s))); + } + Err(e) => { + let _ = tx.send(Event::Error(format!("Resume failed: {e}"))); + } + } + }); +} diff --git a/crates/goose-tui/src/commands.rs b/crates/goose-tui/src/commands.rs deleted file mode 100644 index b4f8cec779..0000000000 --- a/crates/goose-tui/src/commands.rs +++ /dev/null @@ -1,79 +0,0 @@ -use crate::app::App; -use crate::utils::styles::Theme; - -pub enum CommandResult { - Continue, - Quit, - Reply(String), - OpenBuilder, - OpenSessions, - ToggleBorder, -} - -pub fn dispatch(app: &mut App, input: &str) -> CommandResult { - let parts: Vec<&str> = input.trim_start_matches('/').split_whitespace().collect(); - if parts.is_empty() { - return CommandResult::Continue; - } - - let command = parts[0]; - let args = &parts[1..]; - - match command { - "exit" | "quit" => CommandResult::Quit, - "clear" => { - app.messages.clear(); - CommandResult::Continue - } - "help" => { - app.showing_help_popup = !app.showing_help_popup; - CommandResult::Continue - } - "about" => { - // Assuming showing_about_popup exists, otherwise ignore or remove - // app.showing_about_popup = !app.showing_about_popup; - // I didn't see showing_about_popup in App struct earlier. - // Let's verify App struct. - CommandResult::Continue - } - "theme" => { - if let Some(name) = args.first() { - app.config.theme = Theme::from_name(name); - } - CommandResult::Continue - } - "border" => CommandResult::ToggleBorder, - "alias" | "create-command" => CommandResult::OpenBuilder, - "session" | "sessions" => CommandResult::OpenSessions, - _ => { - // Check custom commands - if let Some(cmd) = app - .config - .custom_commands - .iter() - .find(|c| c.name == command) - { - // Construct a prompt that asks Goose to run this tool - let args_str = cmd - .args - .as_object() - .map(|obj| { - obj.iter() - .map(|(k, v)| format!("{}: {}", k, v)) - .collect::>() - .join(", ") - }) - .unwrap_or_default(); - - let prompt = format!( - "Please run the tool '{}' with these arguments: {{{}}}", - cmd.tool, args_str - ); - - CommandResult::Reply(prompt) - } else { - CommandResult::Reply(input.to_string()) - } - } - } -} diff --git a/crates/goose-tui/src/components/chat.rs b/crates/goose-tui/src/components/chat.rs index 85bf54f45e..cc384b8e74 100644 --- a/crates/goose-tui/src/components/chat.rs +++ b/crates/goose-tui/src/components/chat.rs @@ -4,7 +4,7 @@ use crate::state::action::Action; use crate::state::{AppState, InputMode}; use crate::utils::ascii_art::GOOSE_LOGO; use crate::utils::sanitize::sanitize_line; -use crate::utils::styles::Theme; +use crate::utils::styles::{breathing_color, Theme}; use crate::utils::termimad_renderer::TermimadRenderer2; use anyhow::Result; use crossterm::event::{KeyCode, MouseEventKind}; @@ -52,13 +52,6 @@ impl ChatComponent { } } - fn to_rgb(color: Color) -> (u8, u8, u8) { - match color { - Color::Rgb(r, g, b) => (r, g, b), - _ => (128, 128, 128), - } - } - fn render_message( msg_idx: usize, message: &goose::conversation::message::Message, @@ -328,20 +321,7 @@ impl ChatComponent { InputMode::Normal => theme.base.border, } }; - let (r, g, b) = Self::to_rgb(base_color); - - let (dr, dg, db) = if state.is_working { - let t = self.frame_count as f32 * 0.1; - let factor = 0.85 + 0.15 * t.sin(); - ( - (r as f32 * factor) as u8, - (g as f32 * factor) as u8, - (b as f32 * factor) as u8, - ) - } else { - (r, g, b) - }; - let logo_color = Color::Rgb(dr, dg, db); + let logo_color = breathing_color(base_color, self.frame_count, state.is_working); let logo_lines: Vec = GOOSE_LOGO .lines() diff --git a/crates/goose-tui/src/components/input.rs b/crates/goose-tui/src/components/input.rs index 73f3daeaee..cc39c5b5a9 100644 --- a/crates/goose-tui/src/components/input.rs +++ b/crates/goose-tui/src/components/input.rs @@ -2,10 +2,12 @@ use super::Component; use crate::services::events::Event; use crate::state::action::Action; use crate::state::{AppState, InputMode}; +use crate::utils::json::has_input_placeholder; +use crate::utils::styles::breathing_color; use anyhow::Result; use crossterm::event::{KeyCode, KeyModifiers}; use ratatui::layout::Rect; -use ratatui::style::{Color, Style}; +use ratatui::style::Style; use ratatui::text::Span; use ratatui::widgets::{Block, BorderType, Borders, Clear, List, ListItem}; use ratatui::Frame; @@ -51,17 +53,9 @@ impl<'a> InputComponent<'a> { } pub fn height(&self, max_height: u16) -> u16 { - // +2 for top/bottom borders of the textarea block (self.lines_count() + 2).clamp(3, max_height) } - fn to_rgb(color: Color) -> (u8, u8, u8) { - match color { - Color::Rgb(r, g, b) => (r, g, b), - _ => (128, 128, 128), - } - } - fn handle_slash_command(&self, cmd_line: &str, state: &AppState) -> Option { // Split into command and trailing arguments let trimmed = cmd_line.trim(); @@ -254,26 +248,13 @@ impl<'a> Component for InputComponent<'a> { } }; - let (r, g, b) = Self::to_rgb(base_color); - - // Breathing effect - let (dr, dg, db) = if state.is_working { - let t = self.frame_count as f32 * 0.1; - let factor = 0.85 + 0.15 * t.sin(); - ( - (r as f32 * factor) as u8, - (g as f32 * factor) as u8, - (b as f32 * factor) as u8, - ) - } else { - (r, g, b) - }; + let border_color = breathing_color(base_color, self.frame_count, state.is_working); let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .title("Message") - .border_style(Style::default().fg(Color::Rgb(dr, dg, db))); + .border_style(Style::default().fg(border_color)); self.textarea.set_block(block); self.textarea.set_style( @@ -311,7 +292,7 @@ impl<'a> Component for InputComponent<'a> { .custom_commands .iter() .map(|c| { - let has_input = args_has_input_placeholder(&c.args); + let has_input = has_input_placeholder(&c.args); (format!("/{}", c.name), has_input) }) .collect(); @@ -377,12 +358,3 @@ impl<'a> Component for InputComponent<'a> { } } } - -fn args_has_input_placeholder(args: &serde_json::Value) -> bool { - match args { - serde_json::Value::String(s) => s.contains("{input}"), - serde_json::Value::Object(obj) => obj.values().any(args_has_input_placeholder), - serde_json::Value::Array(arr) => arr.iter().any(args_has_input_placeholder), - _ => false, - } -} diff --git a/crates/goose-tui/src/components/popups/builder.rs b/crates/goose-tui/src/components/popups/builder.rs index cf341990f0..00d188f037 100644 --- a/crates/goose-tui/src/components/popups/builder.rs +++ b/crates/goose-tui/src/components/popups/builder.rs @@ -4,6 +4,7 @@ use crate::services::config::CustomCommand; use crate::services::events::Event; use crate::state::action::Action; use crate::state::AppState; +use crate::utils::json::has_input_placeholder; use crate::utils::layout::centered_rect; use crate::utils::styles::Theme; use anyhow::Result; @@ -655,7 +656,6 @@ fn tool_list_item_with_prefix(tool: &ToolInfo, theme: &Theme) -> ListItem<'stati fn alias_list_item(cmd: &CustomCommand, theme: &Theme) -> ListItem<'static> { let short_tool = cmd.tool.split("__").last().unwrap_or(&cmd.tool); let args_preview = preview_args(&cmd.args); - let has_input = args_has_input_placeholder(&cmd.args); let mut first_line = vec![ Span::styled( @@ -670,7 +670,7 @@ fn alias_list_item(cmd: &CustomCommand, theme: &Theme) -> ListItem<'static> { ), ]; - if has_input { + if has_input_placeholder(&cmd.args) { first_line.push(Span::styled( " ".to_string(), Style::default().fg(theme.status.warning), @@ -686,15 +686,6 @@ fn alias_list_item(cmd: &CustomCommand, theme: &Theme) -> ListItem<'static> { ]) } -fn args_has_input_placeholder(args: &serde_json::Value) -> bool { - match args { - serde_json::Value::String(s) => s.contains("{input}"), - serde_json::Value::Object(obj) => obj.values().any(args_has_input_placeholder), - serde_json::Value::Array(arr) => arr.iter().any(args_has_input_placeholder), - _ => false, - } -} - fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() diff --git a/crates/goose-tui/src/main.rs b/crates/goose-tui/src/main.rs index 43004de275..20c1774dd1 100644 --- a/crates/goose-tui/src/main.rs +++ b/crates/goose-tui/src/main.rs @@ -1,6 +1,8 @@ +mod action_handler; mod app; mod components; mod headless; +mod runner; mod services; mod state; mod tui; @@ -9,18 +11,15 @@ mod utils; use anyhow::Result; use app::App; use clap::{Parser, Subcommand}; -use components::Component; use goose_client::Client; +use runner::{run_event_loop, run_recipe_event_loop}; use services::config::TuiConfig; -use services::events::{Event, EventHandler}; -use state::action::Action; +use services::events::EventHandler; use state::AppState; use std::fs; use std::io::IsTerminal; use std::path::PathBuf; use std::time::Duration; -use tokio::sync::mpsc; -use tokio_stream::StreamExt; use tokio_util::sync::CancellationToken; use tracing::info; use tracing_appender::non_blocking::WorkerGuard; @@ -73,26 +72,7 @@ fn main() -> Result<()> { let cli = Cli::parse(); if let Some(Commands::Mcp { name }) = cli.command { - use goose_mcp::mcp_server_runner::{serve, McpCommand}; - use goose_mcp::{ - AutoVisualiserRouter, ComputerControllerServer, DeveloperServer, MemoryServer, - TutorialServer, - }; - use std::str::FromStr; - - return tokio::runtime::Runtime::new()?.block_on(async { - goose_server::logging::setup_logging(Some(&format!("mcp-{name}")))?; - let server = McpCommand::from_str(&name) - .map_err(|e| anyhow::anyhow!("Invalid MCP server: {}", e))?; - match server { - McpCommand::AutoVisualiser => serve(AutoVisualiserRouter::new()).await?, - McpCommand::ComputerController => serve(ComputerControllerServer::new()).await?, - McpCommand::Memory => serve(MemoryServer::new()).await?, - McpCommand::Tutorial => serve(TutorialServer::new()).await?, - McpCommand::Developer => serve(DeveloperServer::new()).await?, - } - Ok(()) - }); + return run_mcp_server(name); } let secret_key = Uuid::new_v4().to_string(); @@ -107,6 +87,29 @@ fn main() -> Result<()> { )) } +fn run_mcp_server(name: String) -> Result<()> { + use goose_mcp::mcp_server_runner::{serve, McpCommand}; + use goose_mcp::{ + AutoVisualiserRouter, ComputerControllerServer, DeveloperServer, MemoryServer, + TutorialServer, + }; + use std::str::FromStr; + + tokio::runtime::Runtime::new()?.block_on(async { + goose_server::logging::setup_logging(Some(&format!("mcp-{name}")))?; + let server = McpCommand::from_str(&name) + .map_err(|e| anyhow::anyhow!("Invalid MCP server: {}", e))?; + match server { + McpCommand::AutoVisualiser => serve(AutoVisualiserRouter::new()).await?, + McpCommand::ComputerController => serve(ComputerControllerServer::new()).await?, + McpCommand::Memory => serve(MemoryServer::new()).await?, + McpCommand::Tutorial => serve(TutorialServer::new()).await?, + McpCommand::Developer => serve(DeveloperServer::new()).await?, + } + Ok(()) + }) +} + fn load_recipe(path: &PathBuf) -> Result { let content = fs::read_to_string(path)?; let recipe_dir = path.parent().map(|p| p.to_string_lossy().to_string()); @@ -289,545 +292,3 @@ async fn run_interactive_mode( tui::restore()?; result } - -fn needs_redraw(event: &Event, state: &AppState) -> bool { - match event { - Event::Tick => state.is_working, - _ => true, - } -} - -async fn run_event_loop( - mut terminal: tui::Tui, - mut app: App<'_>, - mut event_handler: EventHandler, - mut state: AppState, - client: Client, -) -> Result<()> { - let tx = event_handler.sender(); - let c_tools = client.clone(); - let s_id = state.session_id.clone(); - let tx_tools = tx.clone(); - - tokio::spawn(async move { - if let Ok(tools) = c_tools.get_tools(&s_id).await { - let _ = tx_tools.send(Event::ToolsLoaded(tools)); - } - }); - - let mut reply_task: Option> = None; - let mut should_redraw = true; - - loop { - if state.needs_refresh { - terminal.clear()?; - state.needs_refresh = false; - should_redraw = true; - } - - if should_redraw { - terminal.draw(|f| { - app.render(f, f.area(), &state); - })?; - should_redraw = false; - } - - let Some(event) = event_handler.next().await else { - break; - }; - - if needs_redraw(&event, &state) { - should_redraw = true; - } - - if process_event(event, &mut app, &mut state, &client, &tx, &mut reply_task) { - break; - } - - let mut quit = false; - while let Some(event) = event_handler.try_next() { - if needs_redraw(&event, &state) { - should_redraw = true; - } - if process_event(event, &mut app, &mut state, &client, &tx, &mut reply_task) { - quit = true; - break; - } - } - if quit { - break; - } - } - - Ok(()) -} - -async fn run_recipe_event_loop( - mut terminal: tui::Tui, - mut app: App<'_>, - mut event_handler: EventHandler, - mut state: AppState, - client: Client, - prompt: String, -) -> Result<()> { - let tx = event_handler.sender(); - - let user_message = goose::conversation::message::Message::user().with_text(&prompt); - state.messages.push(user_message.clone()); - state.is_working = true; - - let client_clone = client.clone(); - let tx_clone = tx.clone(); - let session_id = state.session_id.clone(); - let messages_snapshot = state.messages.clone(); - - tokio::spawn(async move { - match client_clone.reply(messages_snapshot, session_id).await { - Ok(mut stream) => { - while let Some(result) = stream.next().await { - match result { - Ok(msg) => { - let _ = tx_clone.send(Event::Server(std::sync::Arc::new(msg))); - } - Err(e) => { - let _ = tx_clone.send(Event::Error(e.to_string())); - } - } - } - } - Err(e) => { - let _ = tx_clone.send(Event::Error(e.to_string())); - } - } - }); - - let mut reply_task: Option> = None; - - loop { - if state.needs_refresh { - terminal.clear()?; - state.needs_refresh = false; - } - - terminal.draw(|f| { - app.render(f, f.area(), &state); - })?; - - let Some(event) = event_handler.next().await else { - break; - }; - - if let Event::Input(key) = &event { - if key.code == crossterm::event::KeyCode::Char('c') - && key - .modifiers - .contains(crossterm::event::KeyModifiers::CONTROL) - { - break; - } - } - - if let Event::Server(msg) = &event { - if let goose_server::routes::reply::MessageEvent::Finish { .. } = msg.as_ref() { - state::reducer::update(&mut state, Action::ServerMessage(msg.clone())); - terminal.draw(|f| { - app.render(f, f.area(), &state); - })?; - break; - } - } - - if process_event(event, &mut app, &mut state, &client, &tx, &mut reply_task) { - break; - } - - while let Some(event) = event_handler.try_next() { - if let Event::Server(msg) = &event { - if let goose_server::routes::reply::MessageEvent::Finish { .. } = msg.as_ref() { - state::reducer::update(&mut state, Action::ServerMessage(msg.clone())); - terminal.draw(|f| { - app.render(f, f.area(), &state); - })?; - return Ok(()); - } - } - - if process_event(event, &mut app, &mut state, &client, &tx, &mut reply_task) { - break; - } - } - } - - Ok(()) -} - -fn process_event( - event: Event, - app: &mut App, - state: &mut AppState, - client: &Client, - tx: &mpsc::UnboundedSender, - reply_task: &mut Option>, -) -> bool { - if let Ok(Some(action)) = app.handle_event(&event, state) { - if handle_action(&action, state, client, tx, reply_task) { - state::reducer::update(state, action); - return true; - } - let was_copy_mode = state.copy_mode; - state::reducer::update(state, action); - if state.copy_mode != was_copy_mode { - let _ = tui::set_mouse_capture(!state.copy_mode); - } - } - false -} - -#[allow(clippy::too_many_lines)] -fn handle_action( - action: &Action, - state: &AppState, - client: &Client, - tx: &mpsc::UnboundedSender, - reply_task: &mut Option>, -) -> bool { - match action { - Action::SendMessage(message_to_send) => { - 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 task = tokio::spawn(async move { - match client.reply(messages_snapshot, session_id).await { - Ok(mut stream) => { - while let Some(result) = stream.next().await { - match result { - Ok(msg) => { - let _ = tx.send(Event::Server(std::sync::Arc::new(msg))); - } - Err(e) => { - let _ = tx.send(Event::Error(e.to_string())); - } - } - } - } - Err(e) => { - let _ = tx.send(Event::Error(e.to_string())); - } - } - }); - *reply_task = Some(task); - } - Action::ResumeSession(id) => { - let client = client.clone(); - let tx = tx.clone(); - let id = id.clone(); - tokio::spawn(async move { - match client.resume_agent(&id).await { - Ok(s) => { - let _ = tx.send(Event::SessionResumed(Box::new(s))); - } - Err(e) => { - let _ = tx.send(Event::Error(e.to_string())); - } - } - }); - } - Action::CreateNewSession => { - let client = client.clone(); - let tx = tx.clone(); - tokio::spawn(async move { - let cwd = std::env::current_dir().unwrap_or_default(); - match client.start_agent(cwd.to_string_lossy().to_string()).await { - Ok(s) => { - // Configure the new session with global defaults - let global_config = goose::config::Config::global(); - let provider = global_config - .get_goose_provider() - .unwrap_or_else(|_| "openai".to_string()); - let model = global_config.get_goose_model().ok(); - - if let Err(e) = client.update_provider(&s.id, provider, model).await { - let _ = - tx.send(Event::Error(format!("Failed to update provider: {e}"))); - } - - for ext in goose::config::get_enabled_extensions() { - if let Err(e) = client.add_extension(&s.id, ext.clone()).await { - let _ = tx.send(Event::Error(format!( - "Failed to add extension {}: {e}", - ext.name() - ))); - } - } - - let _ = tx.send(Event::SessionResumed(Box::new(s))); - } - Err(e) => { - let _ = tx.send(Event::Error(e.to_string())); - } - } - }); - } - Action::OpenSessionPicker => { - let client = client.clone(); - let tx = tx.clone(); - tokio::spawn(async move { - match client.list_sessions().await { - Ok(sessions) => { - let _ = tx.send(Event::SessionsList(sessions)); - } - Err(e) => { - let _ = tx.send(Event::Error(e.to_string())); - } - } - }); - } - Action::OpenConfig => { - let client = client.clone(); - let tx = tx.clone(); - tokio::spawn(async move { - // Fetch providers - match client.get_providers().await { - Ok(providers) => { - let _ = tx.send(Event::ProvidersLoaded(providers)); - } - Err(e) => { - let _ = tx.send(Event::Error(e.to_string())); - } - } - // Fetch extensions - match client.get_extensions().await { - Ok(extensions) => { - let _ = tx.send(Event::ExtensionsLoaded(extensions)); - } - Err(e) => { - let _ = tx.send(Event::Error(e.to_string())); - } - } - // Fetch config (for active provider) - match client.read_config().await { - Ok(config) => { - let _ = tx.send(Event::ConfigLoaded(config)); - } - Err(e) => { - let _ = tx.send(Event::Error(e.to_string())); - } - } - }); - } - Action::FetchModels(provider) => { - let client = client.clone(); - let tx = tx.clone(); - let p = provider.clone(); - tokio::spawn(async move { - match client.get_provider_models(&p).await { - Ok(models) => { - let _ = tx.send(Event::ModelsLoaded { - provider: p, - models, - }); - } - Err(e) => { - tracing::warn!("Failed to fetch models for {}: {}", p, e); - } - } - }); - } - Action::UpdateProvider { provider, model } => { - let client = client.clone(); - let tx = tx.clone(); - let session_id = state.session_id.clone(); - let p = provider.clone(); - let m = model.clone(); - tokio::spawn(async move { - // 1. Update session - if let Err(e) = client - .update_provider(&session_id, p.clone(), Some(m.clone())) - .await - { - let _ = tx.send(Event::Error(format!( - "Failed to update session provider: {e}" - ))); - return; - } - // 2. Update persistent config - if let Err(e) = client - .upsert_config("GOOSE_PROVIDER", serde_json::json!(p), false) - .await - { - let _ = tx.send(Event::Error(format!( - "Failed to update config provider: {e}" - ))); - } - if let Err(e) = client - .upsert_config("GOOSE_MODEL", serde_json::json!(m), false) - .await - { - let _ = tx.send(Event::Error(format!("Failed to update config model: {e}"))); - } - }); - } - Action::ToggleExtension { name, enabled } => { - let client = client.clone(); - let tx = tx.clone(); - let session_id = state.session_id.clone(); - let ext_name = name.clone(); - let enabled = *enabled; - - let ext_config = state - .extensions - .iter() - .find(|e| e.config.name() == ext_name) - .map(|e| e.config.clone()); - - if let Some(config) = ext_config { - tokio::spawn(async move { - if enabled { - // Enable - if let Err(e) = client.add_extension(&session_id, config.clone()).await { - let _ = tx.send(Event::Error(format!( - "Failed to enable extension in session: {e}" - ))); - } - if let Err(e) = client.add_config_extension(ext_name, config, true).await { - let _ = tx.send(Event::Error(format!( - "Failed to enable extension in config: {e}" - ))); - } - } else { - // Disable - if let Err(e) = client.remove_extension(&session_id, &ext_name).await { - let _ = tx.send(Event::Error(format!( - "Failed to disable extension in session: {e}" - ))); - } - if let Err(e) = client.add_config_extension(ext_name, config, false).await { - let _ = tx.send(Event::Error(format!( - "Failed to disable extension in config: {e}" - ))); - } - } - - // Refresh - match client.get_extensions().await { - Ok(extensions) => { - let _ = tx.send(Event::ExtensionsLoaded(extensions)); - } - Err(e) => { - let _ = tx.send(Event::Error(e.to_string())); - } - } - }); - } - } - Action::ForkFromMessage(msg_idx) => { - let client = client.clone(); - let tx = tx.clone(); - let session_id = state.session_id.clone(); - let msg_idx = *msg_idx; - - tokio::spawn(async move { - let exported = match client.export_session(&session_id).await { - Ok(json) => { - tracing::debug!( - "Fork: exported session (first 500 chars): {}", - &json[..json.len().min(500)] - ); - json - } - Err(e) => { - let _ = tx.send(Event::Error(format!("Export failed: {e}"))); - return; - } - }; - - let mut session: serde_json::Value = match serde_json::from_str(&exported) { - Ok(v) => v, - Err(e) => { - tracing::error!("Fork: failed to parse exported JSON: {e}"); - let _ = tx.send(Event::Error(format!("Parse failed: {e}"))); - return; - } - }; - - let original_count = session - .get("conversation") - .and_then(|c| c.as_array()) - .map(|a| a.len()) - .unwrap_or(0); - - if let Some(conv) = session.get_mut("conversation") { - if let Some(messages) = conv.as_array_mut() { - messages.truncate(msg_idx + 1); - } - } - - let new_count = session - .get("conversation") - .and_then(|c| c.as_array()) - .map(|a| a.len()) - .unwrap_or(0); - - tracing::info!( - "Fork: truncating from {} to {} messages (up to index {})", - original_count, - new_count, - msg_idx - ); - - if let Some(name) = session.get("name").and_then(|n| n.as_str()) { - session["name"] = serde_json::json!(format!("Fork: {}", name)); - } - - let modified_json = serde_json::to_string(&session).unwrap_or_default(); - tracing::info!( - "Fork: sending import request with {} bytes", - modified_json.len() - ); - let forked = match client.import_session(&modified_json).await { - Ok(s) => { - tracing::info!("Fork: import succeeded, new session id: {}", s.id); - s - } - Err(e) => { - tracing::error!("Fork: import failed: {e}"); - let _ = tx.send(Event::Error(format!("Import failed: {e}"))); - return; - } - }; - - tracing::info!( - "Fork: imported session {} with {} messages", - forked.id, - forked - .conversation - .as_ref() - .map(|c| c.messages().len()) - .unwrap_or(0) - ); - - match client.resume_agent(&forked.id).await { - Ok(s) => { - let _ = tx.send(Event::SessionResumed(Box::new(s))); - } - Err(e) => { - let _ = tx.send(Event::Error(format!("Resume failed: {e}"))); - } - } - }); - } - Action::Quit => { - return true; - } - Action::Interrupt => { - if let Some(task) = reply_task.take() { - task.abort(); - } - } - _ => {} - } - false -} diff --git a/crates/goose-tui/src/runner.rs b/crates/goose-tui/src/runner.rs new file mode 100644 index 0000000000..2e43262d65 --- /dev/null +++ b/crates/goose-tui/src/runner.rs @@ -0,0 +1,203 @@ +use crate::action_handler::handle_action; +use crate::app::App; +use crate::components::Component; +use crate::services::events::{Event, EventHandler}; +use crate::state::action::Action; +use crate::state::AppState; +use crate::tui; +use anyhow::Result; +use goose_client::Client; +use tokio_stream::StreamExt; + +fn needs_redraw(event: &Event, state: &AppState) -> bool { + match event { + Event::Tick => state.is_working, + _ => true, + } +} + +pub async fn run_event_loop( + mut terminal: tui::Tui, + mut app: App<'_>, + mut event_handler: EventHandler, + mut state: AppState, + client: Client, +) -> Result<()> { + let tx = event_handler.sender(); + let c_tools = client.clone(); + let s_id = state.session_id.clone(); + let tx_tools = tx.clone(); + + tokio::spawn(async move { + if let Ok(tools) = c_tools.get_tools(&s_id).await { + let _ = tx_tools.send(Event::ToolsLoaded(tools)); + } + }); + + let mut reply_task: Option> = None; + let mut should_redraw = true; + + loop { + if state.needs_refresh { + terminal.clear()?; + state.needs_refresh = false; + should_redraw = true; + } + + if should_redraw { + terminal.draw(|f| { + app.render(f, f.area(), &state); + })?; + should_redraw = false; + } + + let Some(event) = event_handler.next().await else { + break; + }; + + if needs_redraw(&event, &state) { + should_redraw = true; + } + + if process_event(event, &mut app, &mut state, &client, &tx, &mut reply_task) { + break; + } + + let mut quit = false; + while let Some(event) = event_handler.try_next() { + if needs_redraw(&event, &state) { + should_redraw = true; + } + if process_event(event, &mut app, &mut state, &client, &tx, &mut reply_task) { + quit = true; + break; + } + } + if quit { + break; + } + } + + Ok(()) +} + +pub async fn run_recipe_event_loop( + mut terminal: tui::Tui, + mut app: App<'_>, + mut event_handler: EventHandler, + mut state: AppState, + client: Client, + prompt: String, +) -> Result<()> { + let tx = event_handler.sender(); + + let user_message = goose::conversation::message::Message::user().with_text(&prompt); + state.messages.push(user_message.clone()); + state.is_working = true; + + let client_clone = client.clone(); + let tx_clone = tx.clone(); + let session_id = state.session_id.clone(); + let messages_snapshot = state.messages.clone(); + + tokio::spawn(async move { + match client_clone.reply(messages_snapshot, session_id).await { + Ok(mut stream) => { + while let Some(result) = stream.next().await { + match result { + Ok(msg) => { + let _ = tx_clone.send(Event::Server(std::sync::Arc::new(msg))); + } + Err(e) => { + let _ = tx_clone.send(Event::Error(e.to_string())); + } + } + } + } + Err(e) => { + let _ = tx_clone.send(Event::Error(e.to_string())); + } + } + }); + + let mut reply_task: Option> = None; + + loop { + if state.needs_refresh { + terminal.clear()?; + state.needs_refresh = false; + } + + terminal.draw(|f| { + app.render(f, f.area(), &state); + })?; + + let Some(event) = event_handler.next().await else { + break; + }; + + if let Event::Input(key) = &event { + if key.code == crossterm::event::KeyCode::Char('c') + && key + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL) + { + break; + } + } + + if let Event::Server(msg) = &event { + if let goose_server::routes::reply::MessageEvent::Finish { .. } = msg.as_ref() { + crate::state::reducer::update(&mut state, Action::ServerMessage(msg.clone())); + terminal.draw(|f| { + app.render(f, f.area(), &state); + })?; + break; + } + } + + if process_event(event, &mut app, &mut state, &client, &tx, &mut reply_task) { + break; + } + + while let Some(event) = event_handler.try_next() { + if let Event::Server(msg) = &event { + if let goose_server::routes::reply::MessageEvent::Finish { .. } = msg.as_ref() { + crate::state::reducer::update(&mut state, Action::ServerMessage(msg.clone())); + terminal.draw(|f| { + app.render(f, f.area(), &state); + })?; + return Ok(()); + } + } + + if process_event(event, &mut app, &mut state, &client, &tx, &mut reply_task) { + break; + } + } + } + + Ok(()) +} + +fn process_event( + event: Event, + app: &mut App, + state: &mut AppState, + client: &Client, + tx: &tokio::sync::mpsc::UnboundedSender, + reply_task: &mut Option>, +) -> bool { + if let Ok(Some(action)) = app.handle_event(&event, state) { + if handle_action(&action, state, client, tx, reply_task) { + crate::state::reducer::update(state, action); + return true; + } + let was_copy_mode = state.copy_mode; + crate::state::reducer::update(state, action); + if state.copy_mode != was_copy_mode { + let _ = tui::set_mouse_capture(!state.copy_mode); + } + } + false +} diff --git a/crates/goose-tui/src/utils/json.rs b/crates/goose-tui/src/utils/json.rs new file mode 100644 index 0000000000..2466ae4a28 --- /dev/null +++ b/crates/goose-tui/src/utils/json.rs @@ -0,0 +1,8 @@ +pub fn has_input_placeholder(args: &serde_json::Value) -> bool { + match args { + serde_json::Value::String(s) => s.contains("{input}"), + serde_json::Value::Object(obj) => obj.values().any(has_input_placeholder), + serde_json::Value::Array(arr) => arr.iter().any(has_input_placeholder), + _ => false, + } +} diff --git a/crates/goose-tui/src/utils/mod.rs b/crates/goose-tui/src/utils/mod.rs index ccbfe3574d..650c422c13 100644 --- a/crates/goose-tui/src/utils/mod.rs +++ b/crates/goose-tui/src/utils/mod.rs @@ -1,4 +1,5 @@ pub mod ascii_art; +pub mod json; pub mod layout; pub mod message_format; pub mod sanitize; diff --git a/crates/goose-tui/src/utils/sanitize.rs b/crates/goose-tui/src/utils/sanitize.rs index 9b5bf8d4f7..b35120689d 100644 --- a/crates/goose-tui/src/utils/sanitize.rs +++ b/crates/goose-tui/src/utils/sanitize.rs @@ -22,7 +22,7 @@ pub fn sanitize_line(s: &str) -> (String, usize) { (sanitized, width) } -fn strip_ansi_codes(s: &str) -> String { +pub fn strip_ansi_codes(s: &str) -> String { let mut result = String::with_capacity(s.len()); let mut chars = s.chars().peekable(); diff --git a/crates/goose-tui/src/utils/styles.rs b/crates/goose-tui/src/utils/styles.rs index 4c8269d598..963516fa46 100644 --- a/crates/goose-tui/src/utils/styles.rs +++ b/crates/goose-tui/src/utils/styles.rs @@ -1,5 +1,27 @@ use ratatui::style::Color; +pub fn color_to_rgb(color: Color) -> (u8, u8, u8) { + match color { + Color::Rgb(r, g, b) => (r, g, b), + _ => (128, 128, 128), + } +} + +pub fn breathing_color(base: Color, frame_count: usize, is_active: bool) -> Color { + let (r, g, b) = color_to_rgb(base); + if is_active { + let t = frame_count as f32 * 0.1; + let factor = 0.85 + 0.15 * t.sin(); + Color::Rgb( + (r as f32 * factor) as u8, + (g as f32 * factor) as u8, + (b as f32 * factor) as u8, + ) + } else { + Color::Rgb(r, g, b) + } +} + #[derive(Debug, Clone)] #[allow(dead_code)] pub struct Theme { diff --git a/crates/goose-tui/src/utils/termimad_renderer.rs b/crates/goose-tui/src/utils/termimad_renderer.rs index ff9869c126..94b23533ae 100644 --- a/crates/goose-tui/src/utils/termimad_renderer.rs +++ b/crates/goose-tui/src/utils/termimad_renderer.rs @@ -1,3 +1,4 @@ +use crate::utils::sanitize::strip_ansi_codes; use crate::utils::styles::Theme; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; @@ -277,23 +278,3 @@ fn parse_ansi_line(line: &str) -> Line<'static> { Line::from(spans) } - -/// Strip ANSI escape codes from a string (for emptiness check) -fn strip_ansi_codes(s: &str) -> String { - let mut result = String::new(); - let mut in_escape = false; - - for ch in s.chars() { - if ch == '\x1b' { - in_escape = true; - } else if in_escape { - if ch == 'm' { - in_escape = false; - } - } else { - result.push(ch); - } - } - - result -}