mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
refactor 2 initial
This commit is contained in:
@@ -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}")));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,4 +1,5 @@
|
||||
pub mod ascii_art;
|
||||
pub mod json;
|
||||
pub mod layout;
|
||||
pub mod message_format;
|
||||
pub mod sanitize;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user