refactor 2 initial

This commit is contained in:
Tyler Longwell
2025-11-29 14:59:14 -05:00
parent 248d00bbe2
commit add44cfe21
12 changed files with 675 additions and 734 deletions
+401
View File
@@ -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<Event>,
reply_task: &mut Option<tokio::task::JoinHandle<()>>,
) -> 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<Event>,
reply_task: &mut Option<tokio::task::JoinHandle<()>>,
) {
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<Event>) {
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<Event>) {
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<Event>) {
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<Event>) {
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<Event>) {
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<Event>,
) {
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<Event>,
) {
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<Event>,
) {
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}")));
}
}
});
}
-79
View File
@@ -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::<Vec<_>>()
.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())
}
}
}
}
+2 -22
View File
@@ -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<Line> = GOOSE_LOGO
.lines()
+6 -34
View File
@@ -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<Action> {
// 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,
}
}
@@ -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(
" <args>".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()
+28 -567
View File
@@ -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<goose::recipe::Recipe> {
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<tokio::task::JoinHandle<()>> = 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<tokio::task::JoinHandle<()>> = 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<Event>,
reply_task: &mut Option<tokio::task::JoinHandle<()>>,
) -> 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<Event>,
reply_task: &mut Option<tokio::task::JoinHandle<()>>,
) -> 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
}
+203
View File
@@ -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<tokio::task::JoinHandle<()>> = 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<tokio::task::JoinHandle<()>> = 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<Event>,
reply_task: &mut Option<tokio::task::JoinHandle<()>>,
) -> 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
}
+8
View File
@@ -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,
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod ascii_art;
pub mod json;
pub mod layout;
pub mod message_format;
pub mod sanitize;
+1 -1
View File
@@ -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();
+22
View File
@@ -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 {
@@ -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
}