Slash commands (#5718)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2025-11-20 21:14:15 +01:00
committed by GitHub
parent 6e511d1e8a
commit bdf68bb4f5
28 changed files with 1675 additions and 1048 deletions
+2 -2
View File
@@ -94,7 +94,7 @@ pub async fn handle_schedule_add(
.await
.context("Failed to initialize scheduler")?;
match scheduler.add_scheduled_job(job).await {
match scheduler.add_scheduled_job(job, true).await {
Ok(_) => {
// The scheduler has copied the recipe to its internal directory.
// We can reconstruct the likely path for display if needed, or adjust success message.
@@ -175,7 +175,7 @@ pub async fn handle_schedule_remove(schedule_id: String) -> Result<()> {
.await
.context("Failed to initialize scheduler")?;
match scheduler.remove_scheduled_job(&schedule_id).await {
match scheduler.remove_scheduled_job(&schedule_id, true).await {
Ok(_) => {
println!(
"Scheduled job '{}' and its associated recipe removed.",
+2 -2
View File
@@ -30,7 +30,7 @@ use anyhow::{Context, Result};
use completion::GooseCompleter;
use goose::agents::extension::{Envs, ExtensionConfig, PLATFORM_EXTENSIONS};
use goose::agents::types::RetryConfig;
use goose::agents::{Agent, SessionConfig, MANUAL_COMPACT_TRIGGER};
use goose::agents::{Agent, SessionConfig, MANUAL_COMPACT_TRIGGERS};
use goose::config::{Config, GooseMode};
use goose::providers::pricing::initialize_pricing_cache;
use goose::session::SessionManager;
@@ -703,7 +703,7 @@ impl CliSession {
};
if should_summarize {
self.push_message(Message::user().with_text(MANUAL_COMPACT_TRIGGER));
self.push_message(Message::user().with_text(MANUAL_COMPACT_TRIGGERS[0]));
output::show_thinking();
self.process_agent_response(true, CancellationToken::default())
.await?;
+10 -1
View File
@@ -23,6 +23,7 @@ use goose::conversation::message::{
ToolConfirmationRequest, ToolRequest, ToolResponse,
};
use crate::routes::recipe_utils::RecipeManifest;
use crate::routes::reply::MessageEvent;
use utoipa::openapi::schema::{
AdditionalProperties, AnyOfBuilder, ArrayBuilder, ObjectBuilder, OneOfBuilder, Schema,
@@ -340,6 +341,7 @@ derive_utoipa!(Icon as IconSchema);
super::routes::config_management::read_all_config,
super::routes::config_management::providers,
super::routes::config_management::get_provider_models,
super::routes::config_management::get_slash_commands,
super::routes::config_management::upsert_permissions,
super::routes::config_management::create_custom_provider,
super::routes::config_management::get_custom_provider,
@@ -381,6 +383,8 @@ derive_utoipa!(Icon as IconSchema);
super::routes::recipe::scan_recipe,
super::routes::recipe::list_recipes,
super::routes::recipe::delete_recipe,
super::routes::recipe::schedule_recipe,
super::routes::recipe::set_recipe_slash_command,
super::routes::recipe::save_recipe,
super::routes::recipe::parse_recipe,
super::routes::setup::start_openrouter_setup,
@@ -392,6 +396,9 @@ derive_utoipa!(Icon as IconSchema);
super::routes::config_management::ConfigResponse,
super::routes::config_management::ProvidersResponse,
super::routes::config_management::ProviderDetails,
super::routes::config_management::SlashCommandsResponse,
super::routes::config_management::SlashCommand,
super::routes::config_management::CommandType,
super::routes::config_management::ExtensionResponse,
super::routes::config_management::ExtensionQuery,
super::routes::config_management::ToolPermission,
@@ -441,6 +448,7 @@ derive_utoipa!(Icon as IconSchema);
ExtensionConfig,
ConfigKey,
Envs,
RecipeManifest,
ToolSchema,
ToolAnnotationsSchema,
ToolInfo,
@@ -471,8 +479,9 @@ derive_utoipa!(Icon as IconSchema);
super::routes::recipe::DecodeRecipeResponse,
super::routes::recipe::ScanRecipeRequest,
super::routes::recipe::ScanRecipeResponse,
super::routes::recipe::RecipeManifestResponse,
super::routes::recipe::ListRecipeResponse,
super::routes::recipe::ScheduleRecipeRequest,
super::routes::recipe::SetSlashCommandRequest,
super::routes::recipe::DeleteRecipeRequest,
super::routes::recipe::SaveRecipeRequest,
super::routes::recipe::SaveRecipeResponse,
@@ -17,7 +17,7 @@ use goose::providers::pricing::{
get_all_pricing, get_model_pricing, parse_model_id, refresh_pricing,
};
use goose::providers::providers as get_providers;
use goose::{agents::ExtensionConfig, config::permission::PermissionLevel};
use goose::{agents::ExtensionConfig, config::permission::PermissionLevel, slash_commands};
use http::StatusCode;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -113,6 +113,23 @@ pub enum ConfigValueResponse {
MaskedValue(MaskedSecret),
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub enum CommandType {
Builtin,
Recipe,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SlashCommand {
pub command: String,
pub help: String,
pub command_type: CommandType,
}
#[derive(Serialize, ToSchema)]
pub struct SlashCommandsResponse {
pub commands: Vec<SlashCommand>,
}
#[utoipa::path(
post,
path = "/config/upsert",
@@ -390,6 +407,30 @@ pub async fn get_provider_models(
}
}
#[utoipa::path(
get,
path = "/config/slash_commands",
responses(
(status = 200, description = "Slash commands retrieved successfully", body = SlashCommandsResponse)
)
)]
pub async fn get_slash_commands() -> Result<Json<SlashCommandsResponse>, StatusCode> {
let mut commands: Vec<_> = slash_commands::list_commands()
.iter()
.map(|command| SlashCommand {
command: command.command.clone(),
help: command.recipe_path.clone(),
command_type: CommandType::Recipe,
})
.collect();
commands.push(SlashCommand {
command: "compact".to_string(),
help: "Compact the current conversation to save tokens".to_string(),
command_type: CommandType::Builtin,
});
Ok(Json(SlashCommandsResponse { commands }))
}
#[derive(Serialize, ToSchema)]
pub struct PricingData {
pub provider: String,
@@ -408,8 +449,7 @@ pub struct PricingResponse {
#[derive(Deserialize, ToSchema)]
pub struct PricingQuery {
/// If true, only return pricing for configured providers. If false, return all.
pub configured_only: Option<bool>,
pub configured_only: bool,
}
#[utoipa::path(
@@ -423,7 +463,7 @@ pub struct PricingQuery {
pub async fn get_pricing(
Json(query): Json<PricingQuery>,
) -> Result<Json<PricingResponse>, StatusCode> {
let configured_only = query.configured_only.unwrap_or(true);
let configured_only = query.configured_only;
// If refresh requested (configured_only = false), refresh the cache
if !configured_only {
@@ -792,6 +832,7 @@ pub fn routes(state: Arc<AppState>) -> Router {
.route("/config/extensions/{name}", delete(remove_extension))
.route("/config/providers", get(providers))
.route("/config/providers/{name}/models", get(get_provider_models))
.route("/config/slash_commands", get(get_slash_commands))
.route("/config/pricing", post(get_pricing))
.route("/config/init", post(init_config))
.route("/config/backup", post(backup_config))
+107 -28
View File
@@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use axum::extract::rejection::JsonRejection;
@@ -8,8 +9,8 @@ use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
use goose::recipe::local_recipes;
use goose::recipe::validate_recipe::validate_recipe_template_from_content;
use goose::recipe::Recipe;
use goose::recipe_deeplink;
use goose::session::SessionManager;
use goose::{recipe_deeplink, slash_commands};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -39,7 +40,7 @@ fn clean_data_error(err: &axum::extract::rejection::JsonDataError) -> String {
use crate::routes::errors::ErrorResponse;
use crate::routes::recipe_utils::{
get_all_recipes_manifests, get_recipe_file_path_by_id, short_id_from_path, validate_recipe,
RecipeValidationError,
RecipeManifest, RecipeValidationError,
};
use crate::state::AppState;
@@ -114,14 +115,6 @@ pub struct ParseRecipeResponse {
pub recipe: Recipe,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct RecipeManifestResponse {
recipe: Recipe,
#[serde(rename = "lastModified")]
last_modified: String,
id: String,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct DeleteRecipeRequest {
id: String,
@@ -129,7 +122,19 @@ pub struct DeleteRecipeRequest {
#[derive(Debug, Serialize, ToSchema)]
pub struct ListRecipeResponse {
recipe_manifest_responses: Vec<RecipeManifestResponse>,
manifests: Vec<RecipeManifest>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct ScheduleRecipeRequest {
id: String,
cron_schedule: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct SetSlashCommandRequest {
id: String,
slash_command: Option<String>,
}
#[utoipa::path(
@@ -281,26 +286,36 @@ async fn scan_recipe(
async fn list_recipes(
State(state): State<Arc<AppState>>,
) -> Result<Json<ListRecipeResponse>, StatusCode> {
let recipe_manifest_with_paths = get_all_recipes_manifests().unwrap_or_default();
let mut recipe_file_hash_map = HashMap::new();
let recipe_manifest_responses = recipe_manifest_with_paths
let mut manifests = get_all_recipes_manifests().unwrap_or_default();
let recipe_file_hash_map: HashMap<_, _> = manifests
.iter()
.map(|recipe_manifest_with_path| {
let id = &recipe_manifest_with_path.id;
let file_path = recipe_manifest_with_path.file_path.clone();
recipe_file_hash_map.insert(id.clone(), file_path);
RecipeManifestResponse {
recipe: recipe_manifest_with_path.recipe.clone(),
id: id.clone(),
last_modified: recipe_manifest_with_path.last_modified.clone(),
}
})
.collect::<Vec<RecipeManifestResponse>>();
.map(|m| (m.id.clone(), m.file_path.clone()))
.collect();
state.set_recipe_file_hash_map(recipe_file_hash_map).await;
Ok(Json(ListRecipeResponse {
recipe_manifest_responses,
}))
let scheduler = state.scheduler();
let scheduled_jobs = scheduler.list_scheduled_jobs().await;
let schedule_map: HashMap<_, _> = scheduled_jobs
.into_iter()
.map(|j| (PathBuf::from(j.source), j.cron))
.collect();
let all_commands = slash_commands::list_commands();
let slash_map: HashMap<_, _> = all_commands
.into_iter()
.map(|sc| (PathBuf::from(sc.recipe_path), sc.command))
.collect();
for manifest in &mut manifests {
if let Some(cron) = schedule_map.get(&manifest.file_path) {
manifest.schedule_cron = Some(cron.clone());
}
if let Some(command) = slash_map.get(&manifest.file_path) {
manifest.slash_command = Some(command.clone());
}
}
Ok(Json(ListRecipeResponse { manifests }))
}
#[utoipa::path(
@@ -331,6 +346,68 @@ async fn delete_recipe(
StatusCode::NO_CONTENT
}
#[utoipa::path(
post,
path = "/recipes/schedule",
request_body = ScheduleRecipeRequest,
responses(
(status = 200, description = "Recipe scheduled successfully"),
(status = 404, description = "Recipe not found"),
(status = 500, description = "Internal server error")
),
tag = "Recipe Management"
)]
async fn schedule_recipe(
State(state): State<Arc<AppState>>,
Json(request): Json<ScheduleRecipeRequest>,
) -> Result<StatusCode, StatusCode> {
let file_path = match get_recipe_file_path_by_id(state.as_ref(), &request.id).await {
Ok(path) => path,
Err(err) => return Err(err.status),
};
let scheduler = state.scheduler();
match scheduler
.schedule_recipe(file_path, request.cron_schedule)
.await
{
Ok(_) => Ok(StatusCode::OK),
Err(e) => {
tracing::error!("Failed to schedule recipe: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
#[utoipa::path(
post,
path = "/recipes/slash-command",
request_body = SetSlashCommandRequest,
responses(
(status = 200, description = "Slash command set successfully"),
(status = 404, description = "Recipe not found"),
(status = 500, description = "Internal server error")
),
tag = "Recipe Management"
)]
async fn set_recipe_slash_command(
State(state): State<Arc<AppState>>,
Json(request): Json<SetSlashCommandRequest>,
) -> Result<StatusCode, StatusCode> {
let file_path = match get_recipe_file_path_by_id(state.as_ref(), &request.id).await {
Ok(path) => path,
Err(err) => return Err(err.status),
};
match slash_commands::set_recipe_slash_command(file_path, request.slash_command) {
Ok(_) => Ok(StatusCode::OK),
Err(e) => {
tracing::error!("Failed to set slash command: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
#[utoipa::path(
post,
path = "/recipes/save",
@@ -447,6 +524,8 @@ pub fn routes(state: Arc<AppState>) -> Router {
.route("/recipes/scan", post(scan_recipe))
.route("/recipes/list", get(list_recipes))
.route("/recipes/delete", post(delete_recipe))
.route("/recipes/schedule", post(schedule_recipe))
.route("/recipes/slash-command", post(set_recipe_slash_command))
.route("/recipes/save", post(save_recipe))
.route("/recipes/parse", post(parse_recipe))
.with_state(state)
+13 -6
View File
@@ -5,30 +5,35 @@ use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use axum::http::StatusCode;
use crate::routes::errors::ErrorResponse;
use crate::state::AppState;
use anyhow::Result;
use axum::http::StatusCode;
use goose::agents::Agent;
use goose::prompt_template::render_global_file;
use goose::recipe::build_recipe::{build_recipe_from_template, RecipeError};
use goose::recipe::local_recipes::{get_recipe_library_dir, list_local_recipes};
use goose::recipe::validate_recipe::validate_recipe_template_from_content;
use goose::recipe::Recipe;
use serde::Serialize;
use serde_json::Value;
use tracing::error;
use utoipa::ToSchema;
pub struct RecipeValidationError {
pub status: StatusCode,
pub message: String,
}
pub struct RecipeManifestWithPath {
#[derive(Debug, Serialize, ToSchema)]
pub struct RecipeManifest {
pub id: String,
pub recipe: Recipe,
#[schema(value_type = String)]
pub file_path: PathBuf,
pub last_modified: String,
pub schedule_cron: Option<String>,
pub slash_command: Option<String>,
}
pub fn short_id_from_path(path: &str) -> String {
@@ -38,7 +43,7 @@ pub fn short_id_from_path(path: &str) -> String {
format!("{:016x}", h)
}
pub fn get_all_recipes_manifests() -> Result<Vec<RecipeManifestWithPath>> {
pub fn get_all_recipes_manifests() -> Result<Vec<RecipeManifest>> {
let recipes_with_path = list_local_recipes()?;
let mut recipe_manifests_with_path = Vec::new();
for (file_path, recipe) in recipes_with_path {
@@ -48,11 +53,13 @@ pub fn get_all_recipes_manifests() -> Result<Vec<RecipeManifestWithPath>> {
continue;
};
let manifest_with_path = RecipeManifestWithPath {
let manifest_with_path = RecipeManifest {
id: short_id_from_path(file_path.to_string_lossy().as_ref()),
recipe,
file_path,
last_modified,
schedule_cron: None,
slash_command: None,
};
recipe_manifests_with_path.push(manifest_with_path);
}
+20 -47
View File
@@ -88,10 +88,7 @@ async fn create_schedule(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateScheduleRequest>,
) -> Result<Json<ScheduledJob>, StatusCode> {
let scheduler = state
.scheduler()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let scheduler = state.scheduler();
tracing::info!(
"Server: Calling scheduler.add_scheduled_job() for job '{}'",
@@ -108,7 +105,7 @@ async fn create_schedule(
process_start_time: None,
};
scheduler
.add_scheduled_job(job.clone())
.add_scheduled_job(job.clone(), true)
.await
.map_err(|e| {
eprintln!("Error creating schedule: {:?}", e); // Log error
@@ -136,10 +133,7 @@ async fn create_schedule(
async fn list_schedules(
State(state): State<Arc<AppState>>,
) -> Result<Json<ListSchedulesResponse>, StatusCode> {
let scheduler = state
.scheduler()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let scheduler = state.scheduler();
tracing::info!("Server: Calling scheduler.list_scheduled_jobs()");
let jobs = scheduler.list_scheduled_jobs().await;
@@ -164,17 +158,17 @@ async fn delete_schedule(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
let scheduler = state
.scheduler()
let scheduler = state.scheduler();
scheduler
.remove_scheduled_job(&id, true)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
scheduler.remove_scheduled_job(&id).await.map_err(|e| {
eprintln!("Error deleting schedule '{}': {:?}", id, e);
match e {
goose::scheduler::SchedulerError::JobNotFound(_) => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
})?;
.map_err(|e| {
eprintln!("Error deleting schedule '{}': {:?}", id, e);
match e {
goose::scheduler::SchedulerError::JobNotFound(_) => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
})?;
Ok(StatusCode::NO_CONTENT)
}
@@ -196,10 +190,7 @@ async fn run_now_handler(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Json<RunNowResponse>, StatusCode> {
let scheduler = state
.scheduler()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let scheduler = state.scheduler();
let (recipe_display_name, recipe_version_opt) = if let Some(job) = scheduler
.list_scheduled_jobs()
@@ -291,10 +282,7 @@ async fn sessions_handler(
Path(schedule_id_param): Path<String>, // Renamed to avoid confusion with session_id
Query(query_params): Query<SessionsQuery>,
) -> Result<Json<Vec<SessionDisplayInfo>>, StatusCode> {
let scheduler = state
.scheduler()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let scheduler = state.scheduler();
match scheduler
.sessions(&schedule_id_param, query_params.limit)
@@ -349,10 +337,7 @@ async fn pause_schedule(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
let scheduler = state
.scheduler()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let scheduler = state.scheduler();
scheduler.pause_schedule(&id).await.map_err(|e| {
eprintln!("Error pausing schedule '{}': {:?}", id, e);
@@ -383,10 +368,7 @@ async fn unpause_schedule(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
let scheduler = state
.scheduler()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let scheduler = state.scheduler();
scheduler.unpause_schedule(&id).await.map_err(|e| {
eprintln!("Error unpausing schedule '{}': {:?}", id, e);
@@ -419,10 +401,7 @@ async fn update_schedule(
Path(id): Path<String>,
Json(req): Json<UpdateScheduleRequest>,
) -> Result<Json<ScheduledJob>, StatusCode> {
let scheduler = state
.scheduler()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let scheduler = state.scheduler();
scheduler
.update_schedule(&id, req.cron)
@@ -459,10 +438,7 @@ pub async fn kill_running_job(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Json<KillJobResponse>, StatusCode> {
let scheduler = state
.scheduler()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let scheduler = state.scheduler();
scheduler.kill_running_job(&id).await.map_err(|e| {
eprintln!("Error killing running job '{}': {:?}", id, e);
@@ -496,10 +472,7 @@ pub async fn inspect_running_job(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Json<InspectJobResponse>, StatusCode> {
let scheduler = state
.scheduler()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let scheduler = state.scheduler();
match scheduler.get_running_job_info(&id).await {
Ok(info) => {
+2 -2
View File
@@ -26,8 +26,8 @@ impl AppState {
}))
}
pub async fn scheduler(&self) -> Result<Arc<dyn SchedulerTrait>, anyhow::Error> {
self.agent_manager.scheduler().await
pub fn scheduler(&self) -> Arc<dyn SchedulerTrait> {
self.agent_manager.scheduler()
}
pub async fn set_recipe_file_hash_map(&self, hash_map: HashMap<String, PathBuf>) {
+31 -10
View File
@@ -59,14 +59,15 @@ use super::final_output_tool::FinalOutputTool;
use super::platform_tools;
use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE};
use crate::agents::subagent_task_config::TaskConfig;
use crate::conversation::message::{Message, MessageContent, SystemNotificationType, ToolRequest};
use crate::conversation::message::{Message, SystemNotificationType, ToolRequest};
use crate::scheduler_trait::SchedulerTrait;
use crate::session::extension_data::{EnabledExtensionsState, ExtensionState};
use crate::session::{Session, SessionManager};
const DEFAULT_MAX_TURNS: u32 = 1000;
const COMPACTION_THINKING_TEXT: &str = "goose is compacting the conversation...";
pub const MANUAL_COMPACT_TRIGGER: &str = "Please compact this conversation";
pub const MANUAL_COMPACT_TRIGGERS: &[&str] =
&["Please compact this conversation", "/compact", "/summarize"];
/// Context needed for the reply function
pub struct ReplyContext {
@@ -776,15 +777,35 @@ impl Agent {
session_config: SessionConfig,
cancel_token: Option<CancellationToken>,
) -> Result<BoxStream<'_, Result<AgentEvent>>> {
let is_manual_compact = user_message.content.iter().any(|c| {
if let MessageContent::Text(text) = c {
text.text.trim() == MANUAL_COMPACT_TRIGGER
} else {
false
}
});
let message_text = user_message.as_concat_text();
let is_manual_compact = MANUAL_COMPACT_TRIGGERS.contains(&message_text.trim());
let slash_command_recipe = if message_text.trim().starts_with('/') {
let command = message_text.split_whitespace().next();
command.and_then(crate::slash_commands::resolve_slash_command)
} else {
None
};
if let Some(recipe) = slash_command_recipe {
let prompt = [recipe.instructions.as_deref(), recipe.prompt.as_deref()]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join("\n\n");
let prompt_message = Message::user()
.with_text(prompt)
.with_visibility(false, true);
SessionManager::add_message(&session_config.id, &prompt_message).await?;
SessionManager::add_message(
&session_config.id,
&user_message.with_visibility(true, false),
)
.await?;
} else {
SessionManager::add_message(&session_config.id, &user_message).await?;
}
SessionManager::add_message(&session_config.id, &user_message).await?;
let session = SessionManager::get_session(&session_config.id, true).await?;
let conversation = session
+1 -1
View File
@@ -26,7 +26,7 @@ mod tool_route_manager;
mod tool_router_index_manager;
pub mod types;
pub use agent::{Agent, AgentEvent, MANUAL_COMPACT_TRIGGER};
pub use agent::{Agent, AgentEvent, MANUAL_COMPACT_TRIGGERS};
pub use extension::ExtensionConfig;
pub use extension_manager::ExtensionManager;
pub use prompt_manager::PromptManager;
+2 -2
View File
@@ -164,7 +164,7 @@ impl Agent {
process_start_time: None,
};
match scheduler.add_scheduled_job(job).await {
match scheduler.add_scheduled_job(job, true).await {
Ok(()) => Ok(vec![Content::text(format!(
"Successfully created scheduled job '{}' for recipe '{}' with cron expression '{}' in {} mode",
job_id, recipe_path, cron_expression, execution_mode
@@ -284,7 +284,7 @@ impl Agent {
)
})?;
match scheduler.remove_scheduled_job(job_id).await {
match scheduler.remove_scheduled_job(job_id, true).await {
Ok(()) => Ok(vec![Content::text(format!(
"Successfully deleted job '{}'",
job_id
+2 -2
View File
@@ -59,8 +59,8 @@ impl AgentManager {
.cloned()
}
pub async fn scheduler(&self) -> Result<Arc<dyn SchedulerTrait>> {
Ok(Arc::clone(&self.scheduler))
pub fn scheduler(&self) -> Arc<dyn SchedulerTrait> {
Arc::clone(&self.scheduler)
}
pub async fn set_default_provider(&self, provider: Arc<dyn crate::providers::base::Provider>) {
+1
View File
@@ -18,6 +18,7 @@ pub mod scheduler_trait;
pub mod security;
pub mod session;
pub mod session_context;
pub mod slash_commands;
pub mod subprocess;
pub mod token_counter;
pub mod tool_inspection;
+1 -1
View File
@@ -15,7 +15,7 @@ pub fn get_recipe_library_dir(is_global: bool) -> PathBuf {
if is_global {
Paths::config_dir().join("recipes")
} else {
std::env::current_dir().unwrap().join(".goose/recipes")
env::current_dir().unwrap().join(".goose/recipes")
}
}
+119 -32
View File
@@ -269,6 +269,7 @@ impl Scheduler {
pub async fn add_scheduled_job(
&self,
original_job_spec: ScheduledJob,
make_copy: bool,
) -> Result<(), SchedulerError> {
{
let jobs_guard = self.jobs.lock().await;
@@ -277,29 +278,30 @@ impl Scheduler {
}
}
let original_recipe_path = Path::new(&original_job_spec.source);
if !original_recipe_path.is_file() {
return Err(SchedulerError::RecipeLoadError(format!(
"Recipe file not found: {}",
original_job_spec.source
)));
}
let scheduled_recipes_dir = get_default_scheduled_recipes_dir()?;
let original_extension = original_recipe_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("yaml");
let destination_filename = format!("{}.{}", original_job_spec.id, original_extension);
let destination_recipe_path = scheduled_recipes_dir.join(destination_filename);
fs::copy(original_recipe_path, &destination_recipe_path)?;
let mut stored_job = original_job_spec;
stored_job.source = destination_recipe_path.to_string_lossy().into_owned();
stored_job.current_session_id = None;
stored_job.process_start_time = None;
if make_copy {
let original_recipe_path = Path::new(&stored_job.source);
if !original_recipe_path.is_file() {
return Err(SchedulerError::RecipeLoadError(format!(
"Recipe file not found: {}",
stored_job.source
)));
}
let scheduled_recipes_dir = get_default_scheduled_recipes_dir()?;
let original_extension = original_recipe_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("yaml");
let destination_filename = format!("{}.{}", stored_job.id, original_extension);
let destination_recipe_path = scheduled_recipes_dir.join(destination_filename);
fs::copy(original_recipe_path, &destination_recipe_path)?;
stored_job.source = destination_recipe_path.to_string_lossy().into_owned();
stored_job.current_session_id = None;
stored_job.process_start_time = None;
}
let cron_task = self.create_cron_task(stored_job.clone())?;
@@ -318,6 +320,69 @@ impl Scheduler {
Ok(())
}
pub async fn schedule_recipe(
&self,
recipe_path: PathBuf,
cron_schedule: Option<String>,
) -> Result<(), SchedulerError> {
let recipe_path_str = recipe_path.to_string_lossy().to_string();
let existing_job_id = {
let jobs_guard = self.jobs.lock().await;
jobs_guard
.iter()
.find(|(_, (_, job))| job.source == recipe_path_str)
.map(|(id, _)| id.clone())
};
match cron_schedule {
Some(cron) => {
if let Some(job_id) = existing_job_id {
self.update_schedule(&job_id, cron).await
} else {
let job_id = self.generate_unique_job_id(&recipe_path).await;
let job = ScheduledJob {
id: job_id,
source: recipe_path_str,
cron,
last_run: None,
currently_running: false,
paused: false,
current_session_id: None,
process_start_time: None,
};
self.add_scheduled_job(job, false).await
}
}
None => {
if let Some(job_id) = existing_job_id {
self.remove_scheduled_job(&job_id, false).await
} else {
Ok(())
}
}
}
}
async fn generate_unique_job_id(&self, path: &Path) -> String {
let base_id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unnamed")
.to_string();
let jobs_guard = self.jobs.lock().await;
let mut id = base_id.clone();
let mut counter = 1;
while jobs_guard.contains_key(&id) {
id = format!("{}_{}", base_id, counter);
counter += 1;
}
id
}
async fn load_jobs_from_storage(self: &Arc<Self>) {
if !self.storage_path.exists() {
return;
@@ -395,7 +460,11 @@ impl Scheduler {
.collect()
}
pub async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError> {
pub async fn remove_scheduled_job(
&self,
id: &str,
remove_recipe: bool,
) -> Result<(), SchedulerError> {
let (job_uuid, recipe_path) = {
let mut jobs_guard = self.jobs.lock().await;
match jobs_guard.remove(id) {
@@ -409,9 +478,11 @@ impl Scheduler {
.await
.map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?;
let path = Path::new(&recipe_path);
if path.exists() {
fs::remove_file(path)?;
if remove_recipe {
let path = Path::new(&recipe_path);
if path.exists() {
fs::remove_file(path)?;
}
}
persist_jobs(&self.storage_path, &self.jobs).await?;
@@ -733,16 +804,32 @@ async fn execute_job(
#[async_trait]
impl SchedulerTrait for Scheduler {
async fn add_scheduled_job(&self, job: ScheduledJob) -> Result<(), SchedulerError> {
self.add_scheduled_job(job).await
async fn add_scheduled_job(
&self,
job: ScheduledJob,
make_copy: bool,
) -> Result<(), SchedulerError> {
self.add_scheduled_job(job, make_copy).await
}
async fn schedule_recipe(
&self,
recipe_path: PathBuf,
cron_schedule: Option<String>,
) -> Result<(), SchedulerError> {
self.schedule_recipe(recipe_path, cron_schedule).await
}
async fn list_scheduled_jobs(&self) -> Vec<ScheduledJob> {
self.list_scheduled_jobs().await
}
async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError> {
self.remove_scheduled_job(id).await
async fn remove_scheduled_job(
&self,
id: &str,
remove_recipe: bool,
) -> Result<(), SchedulerError> {
self.remove_scheduled_job(id, remove_recipe).await
}
async fn pause_schedule(&self, id: &str) -> Result<(), SchedulerError> {
@@ -815,7 +902,7 @@ mod tests {
process_start_time: None,
};
scheduler.add_scheduled_job(job).await.unwrap();
scheduler.add_scheduled_job(job, true).await.unwrap();
sleep(Duration::from_millis(1500)).await;
let jobs = scheduler.list_scheduled_jobs().await;
@@ -840,7 +927,7 @@ mod tests {
process_start_time: None,
};
scheduler.add_scheduled_job(job).await.unwrap();
scheduler.add_scheduled_job(job, true).await.unwrap();
scheduler.pause_schedule("paused_job").await.unwrap();
sleep(Duration::from_millis(1500)).await;
+16 -2
View File
@@ -1,14 +1,28 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use std::path::PathBuf;
use crate::scheduler::{ScheduledJob, SchedulerError};
use crate::session::Session;
#[async_trait]
pub trait SchedulerTrait: Send + Sync {
async fn add_scheduled_job(&self, job: ScheduledJob) -> Result<(), SchedulerError>;
async fn add_scheduled_job(
&self,
job: ScheduledJob,
copy_recipe: bool,
) -> Result<(), SchedulerError>;
async fn schedule_recipe(
&self,
recipe_path: PathBuf,
cron_schedule: Option<String>,
) -> anyhow::Result<(), SchedulerError>;
async fn list_scheduled_jobs(&self) -> Vec<ScheduledJob>;
async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError>;
async fn remove_scheduled_job(
&self,
id: &str,
remove_recipe: bool,
) -> Result<(), SchedulerError>;
async fn pause_schedule(&self, id: &str) -> Result<(), SchedulerError>;
async fn unpause_schedule(&self, id: &str) -> Result<(), SchedulerError>;
async fn run_now(&self, id: &str) -> Result<String, SchedulerError>;
+74
View File
@@ -0,0 +1,74 @@
use std::path::PathBuf;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::config::Config;
use crate::recipe::Recipe;
const SLASH_COMMANDS_CONFIG_KEY: &str = "slash_commands";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlashCommandMapping {
pub command: String,
pub recipe_path: String,
}
pub fn list_commands() -> Vec<SlashCommandMapping> {
Config::global()
.get_param(SLASH_COMMANDS_CONFIG_KEY)
.unwrap_or_else(|err| {
warn!(
"Failed to load {}: {}. Falling back to empty list.",
SLASH_COMMANDS_CONFIG_KEY, err
);
Vec::new()
})
}
fn save_slash_commands(commands: Vec<SlashCommandMapping>) -> Result<()> {
Config::global()
.set_param(SLASH_COMMANDS_CONFIG_KEY, &commands)
.map_err(|e| anyhow::anyhow!("Failed to save slash commands: {}", e))
}
pub fn set_recipe_slash_command(recipe_path: PathBuf, command: Option<String>) -> Result<()> {
let recipe_path_str = recipe_path.to_string_lossy().to_string();
let mut commands = list_commands();
commands.retain(|mapping| mapping.recipe_path != recipe_path_str);
if let Some(cmd) = command {
let normalized_cmd = cmd.trim_start_matches('/').to_lowercase();
if !normalized_cmd.is_empty() {
commands.push(SlashCommandMapping {
command: normalized_cmd,
recipe_path: recipe_path_str,
});
}
}
save_slash_commands(commands)
}
pub fn get_recipe_for_command(command: &str) -> Option<PathBuf> {
let normalized = command.trim_start_matches('/').to_lowercase();
let commands = list_commands();
commands
.into_iter()
.find(|mapping| mapping.command == normalized)
.map(|mapping| PathBuf::from(mapping.recipe_path))
}
pub fn resolve_slash_command(command: &str) -> Option<Recipe> {
let recipe_path = get_recipe_for_command(command)?;
if !recipe_path.exists() {
return None;
}
let recipe_content = std::fs::read_to_string(&recipe_path).ok()?;
let recipe = Recipe::from_content(&recipe_content).ok()?;
Some(recipe)
}
+19 -2
View File
@@ -18,6 +18,7 @@ mod tests {
use goose::scheduler::{ScheduledJob, SchedulerError};
use goose::scheduler_trait::SchedulerTrait;
use goose::session::Session;
use std::path::PathBuf;
use std::sync::Arc;
struct MockScheduler {
@@ -34,18 +35,34 @@ mod tests {
#[async_trait]
impl SchedulerTrait for MockScheduler {
async fn add_scheduled_job(&self, job: ScheduledJob) -> Result<(), SchedulerError> {
async fn add_scheduled_job(
&self,
job: ScheduledJob,
_copy: bool,
) -> Result<(), SchedulerError> {
let mut jobs = self.jobs.lock().await;
jobs.push(job);
Ok(())
}
async fn schedule_recipe(
&self,
_recipe_path: PathBuf,
_cron_schedule: Option<String>,
) -> Result<(), SchedulerError> {
Ok(())
}
async fn list_scheduled_jobs(&self) -> Vec<ScheduledJob> {
let jobs = self.jobs.lock().await;
jobs.clone()
}
async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError> {
async fn remove_scheduled_job(
&self,
id: &str,
_remove: bool,
) -> Result<(), SchedulerError> {
let mut jobs = self.jobs.lock().await;
if let Some(pos) = jobs.iter().position(|job| job.id == id) {
jobs.remove(pos);
+167 -7
View File
@@ -878,6 +878,26 @@
"responses": {}
}
},
"/config/slash_commands": {
"get": {
"tags": [
"super::routes::config_management"
],
"operationId": "get_slash_commands",
"responses": {
"200": {
"description": "Slash commands retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SlashCommandsResponse"
}
}
}
}
}
}
},
"/config/upsert": {
"post": {
"tags": [
@@ -1372,6 +1392,64 @@
}
}
},
"/recipes/schedule": {
"post": {
"tags": [
"Recipe Management"
],
"operationId": "schedule_recipe",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScheduleRecipeRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Recipe scheduled successfully"
},
"404": {
"description": "Recipe not found"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/recipes/slash-command": {
"post": {
"tags": [
"Recipe Management"
],
"operationId": "set_recipe_slash_command",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SetSlashCommandRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Slash command set successfully"
},
"404": {
"description": "Recipe not found"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/reply": {
"post": {
"tags": [
@@ -2227,6 +2305,13 @@
}
}
},
"CommandType": {
"type": "string",
"enum": [
"Builtin",
"Recipe"
]
},
"ConfigKey": {
"type": "object",
"description": "Configuration key metadata for provider setup",
@@ -3077,13 +3162,13 @@
"ListRecipeResponse": {
"type": "object",
"required": [
"recipe_manifest_responses"
"manifests"
],
"properties": {
"recipe_manifest_responses": {
"manifests": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RecipeManifestResponse"
"$ref": "#/components/schemas/RecipeManifest"
}
}
}
@@ -3897,22 +3982,34 @@
}
}
},
"RecipeManifestResponse": {
"RecipeManifest": {
"type": "object",
"required": [
"id",
"recipe",
"lastModified",
"id"
"file_path",
"last_modified"
],
"properties": {
"file_path": {
"type": "string"
},
"id": {
"type": "string"
},
"lastModified": {
"last_modified": {
"type": "string"
},
"recipe": {
"$ref": "#/components/schemas/Recipe"
},
"schedule_cron": {
"type": "string",
"nullable": true
},
"slash_command": {
"type": "string",
"nullable": true
}
}
},
@@ -4177,6 +4274,21 @@
}
}
},
"ScheduleRecipeRequest": {
"type": "object",
"required": [
"id"
],
"properties": {
"cron_schedule": {
"type": "string",
"nullable": true
},
"id": {
"type": "string"
}
}
},
"ScheduledJob": {
"type": "object",
"required": [
@@ -4447,6 +4559,21 @@
}
}
},
"SetSlashCommandRequest": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"type": "string"
},
"slash_command": {
"type": "string",
"nullable": true
}
}
},
"Settings": {
"type": "object",
"properties": {
@@ -4480,6 +4607,39 @@
}
}
},
"SlashCommand": {
"type": "object",
"required": [
"command",
"help",
"command_type"
],
"properties": {
"command": {
"type": "string"
},
"command_type": {
"$ref": "#/components/schemas/CommandType"
},
"help": {
"type": "string"
}
}
},
"SlashCommandsResponse": {
"type": "object",
"required": [
"commands"
],
"properties": {
"commands": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SlashCommand"
}
}
}
},
"StartAgentRequest": {
"type": "object",
"required": [
+30 -1
View File
@@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
@@ -260,6 +260,13 @@ export const setConfigProvider = <ThrowOnError extends boolean = false>(options:
});
};
export const getSlashCommands = <ThrowOnError extends boolean = false>(options?: Options<GetSlashCommandsData, ThrowOnError>) => {
return (options?.client ?? client).get<GetSlashCommandsResponses, unknown, ThrowOnError>({
url: '/config/slash_commands',
...options
});
};
export const upsertConfig = <ThrowOnError extends boolean = false>(options: Options<UpsertConfigData, ThrowOnError>) => {
return (options.client ?? client).post<UpsertConfigResponses, UpsertConfigErrors, ThrowOnError>({
url: '/config/upsert',
@@ -401,6 +408,28 @@ export const scanRecipe = <ThrowOnError extends boolean = false>(options: Option
});
};
export const scheduleRecipe = <ThrowOnError extends boolean = false>(options: Options<ScheduleRecipeData, ThrowOnError>) => {
return (options.client ?? client).post<ScheduleRecipeResponses, ScheduleRecipeErrors, ThrowOnError>({
url: '/recipes/schedule',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
export const setRecipeSlashCommand = <ThrowOnError extends boolean = false>(options: Options<SetRecipeSlashCommandData, ThrowOnError>) => {
return (options.client ?? client).post<SetRecipeSlashCommandResponses, SetRecipeSlashCommandErrors, ThrowOnError>({
url: '/recipes/slash-command',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
export const reply = <ThrowOnError extends boolean = false>(options: Options<ReplyData, ThrowOnError>) => {
return (options.client ?? client).sse.post<ReplyResponses, ReplyErrors, ThrowOnError>({
url: '/reply',
+94 -3
View File
@@ -36,6 +36,8 @@ export type CheckProviderRequest = {
provider: string;
};
export type CommandType = 'Builtin' | 'Recipe';
/**
* Configuration key metadata for provider setup
*/
@@ -322,7 +324,7 @@ export type KillJobResponse = {
};
export type ListRecipeResponse = {
recipe_manifest_responses: Array<RecipeManifestResponse>;
manifests: Array<RecipeManifest>;
};
export type ListSchedulesResponse = {
@@ -564,10 +566,13 @@ export type Recipe = {
version?: string;
};
export type RecipeManifestResponse = {
export type RecipeManifest = {
file_path: string;
id: string;
lastModified: string;
last_modified: string;
recipe: Recipe;
schedule_cron?: string | null;
slash_command?: string | null;
};
export type RecipeParameter = {
@@ -666,6 +671,11 @@ export type ScanRecipeResponse = {
has_security_warnings: boolean;
};
export type ScheduleRecipeRequest = {
cron_schedule?: string | null;
id: string;
};
export type ScheduledJob = {
cron: string;
current_session_id?: string | null;
@@ -739,6 +749,11 @@ export type SetProviderRequest = {
provider: string;
};
export type SetSlashCommandRequest = {
id: string;
slash_command?: string | null;
};
export type Settings = {
goose_model?: string | null;
goose_provider?: string | null;
@@ -750,6 +765,16 @@ export type SetupResponse = {
success: boolean;
};
export type SlashCommand = {
command: string;
command_type: CommandType;
help: string;
};
export type SlashCommandsResponse = {
commands: Array<SlashCommand>;
};
export type StartAgentRequest = {
recipe?: Recipe | null;
recipe_deeplink?: string | null;
@@ -1604,6 +1629,22 @@ export type SetConfigProviderData = {
url: '/config/set_provider';
};
export type GetSlashCommandsData = {
body?: never;
path?: never;
query?: never;
url: '/config/slash_commands';
};
export type GetSlashCommandsResponses = {
/**
* Slash commands retrieved successfully
*/
200: SlashCommandsResponse;
};
export type GetSlashCommandsResponse = GetSlashCommandsResponses[keyof GetSlashCommandsResponses];
export type UpsertConfigData = {
body: UpsertConfigQuery;
path?: never;
@@ -1965,6 +2006,56 @@ export type ScanRecipeResponses = {
export type ScanRecipeResponse2 = ScanRecipeResponses[keyof ScanRecipeResponses];
export type ScheduleRecipeData = {
body: ScheduleRecipeRequest;
path?: never;
query?: never;
url: '/recipes/schedule';
};
export type ScheduleRecipeErrors = {
/**
* Recipe not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type ScheduleRecipeResponses = {
/**
* Recipe scheduled successfully
*/
200: unknown;
};
export type SetRecipeSlashCommandData = {
body: SetSlashCommandRequest;
path?: never;
query?: never;
url: '/recipes/slash-command';
};
export type SetRecipeSlashCommandErrors = {
/**
* Recipe not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type SetRecipeSlashCommandResponses = {
/**
* Slash command set successfully
*/
200: unknown;
};
export type ReplyData = {
body: ChatRequest;
path?: never;
+18 -15
View File
@@ -18,7 +18,7 @@ import { useModelAndProvider } from './ModelAndProviderContext';
import { useWhisper } from '../hooks/useWhisper';
import { WaveformVisualizer } from './WaveformVisualizer';
import { toastError } from '../toasts';
import MentionPopover, { FileItemWithMatch } from './MentionPopover';
import MentionPopover, { DisplayItemWithMatch } from './MentionPopover';
import { useDictationSettings } from '../hooks/useDictationSettings';
import { COST_TRACKING_ENABLED, VOICE_DICTATION_ELEVENLABS_ENABLED } from '../updates';
import { CostTracker } from './bottom_menu/CostTracker';
@@ -215,15 +215,17 @@ export default function ChatInput({
query: string;
mentionStart: number;
selectedIndex: number;
isSlashCommand: boolean;
}>({
isOpen: false,
position: { x: 0, y: 0 },
query: '',
mentionStart: -1,
selectedIndex: 0,
isSlashCommand: false,
});
const mentionPopoverRef = useRef<{
getDisplayFiles: () => FileItemWithMatch[];
getDisplayFiles: () => DisplayItemWithMatch[];
selectFile: (index: number) => void;
}>(null);
@@ -563,13 +565,17 @@ export default function ChatInput({
setDisplayValue(val);
updateValue(val);
setHasUserTyped(true);
checkForMention(val, cursorPosition, evt.target);
checkForMentionOrSlash(val, cursorPosition, evt.target);
};
const checkForMention = (text: string, cursorPosition: number, textArea: HTMLTextAreaElement) => {
// Find the last @ before the cursor
const checkForMentionOrSlash = (
text: string,
cursorPosition: number,
textArea: HTMLTextAreaElement
) => {
const isSlashCommand = text.startsWith('/');
const beforeCursor = text.slice(0, cursorPosition);
const lastAtIndex = beforeCursor.lastIndexOf('@');
const lastAtIndex = isSlashCommand ? 0 : beforeCursor.lastIndexOf('@');
if (lastAtIndex === -1) {
// No @ found, close mention popover
@@ -597,6 +603,7 @@ export default function ChatInput({
query: afterAt,
mentionStart: lastAtIndex,
selectedIndex: 0, // Reset selection when query changes
isSlashCommand,
// filteredFiles will be populated by the MentionPopover component
}));
};
@@ -1024,13 +1031,13 @@ export default function ChatInput({
}
};
const handleMentionFileSelect = (filePath: string) => {
const handleMentionItemSelect = (itemText: string) => {
// Replace the @ mention with the file path
const beforeMention = displayValue.slice(0, mentionPopover.mentionStart);
const afterMention = displayValue.slice(
mentionPopover.mentionStart + 1 + mentionPopover.query.length
);
const newValue = `${beforeMention}${filePath}${afterMention}`;
const newValue = `${beforeMention}${itemText}${afterMention}`;
setDisplayValue(newValue);
setValue(newValue);
@@ -1040,7 +1047,7 @@ export default function ChatInput({
// Set cursor position after the inserted file path
setTimeout(() => {
if (textAreaRef.current) {
const newCursorPosition = beforeMention.length + filePath.length;
const newCursorPosition = beforeMention.length + itemText.length;
textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
}
}, 0);
@@ -1453,7 +1460,6 @@ export default function ChatInput({
{/* Directory path */}
<DirSwitcher className="mr-0" />
<div className="w-px h-4 bg-border-default mx-2" />
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -1468,9 +1474,7 @@ export default function ChatInput({
</TooltipTrigger>
<TooltipContent>Attach file or directory</TooltipContent>
</Tooltip>
<div className="w-px h-4 bg-border-default mx-2" />
{/* Model selector, mode selector, alerts, summarize button */}
<div className="flex flex-row items-center">
{/* Cost Tracker */}
@@ -1538,7 +1542,6 @@ export default function ChatInput({
</Tooltip>
)}
</div>
{sessionId && diagnosticsOpen && (
<DiagnosticsModal
isOpen={diagnosticsOpen}
@@ -1546,12 +1549,12 @@ export default function ChatInput({
sessionId={sessionId}
/>
)}
<MentionPopover
ref={mentionPopoverRef}
isOpen={mentionPopover.isOpen}
isSlashCommand={mentionPopover.isSlashCommand}
onClose={() => setMentionPopover((prev) => ({ ...prev, isOpen: false }))}
onSelect={handleMentionFileSelect}
onSelect={handleMentionItemSelect}
position={mentionPopover.position}
query={mentionPopover.query}
selectedIndex={mentionPopover.selectedIndex}
-365
View File
@@ -1,365 +0,0 @@
import React from 'react';
interface FileIconProps {
fileName: string;
isDirectory: boolean;
className?: string;
}
export const FileIcon: React.FC<FileIconProps> = ({
fileName,
isDirectory,
className = 'w-4 h-4',
}) => {
if (isDirectory) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
);
}
const ext = fileName.split('.').pop()?.toLowerCase();
// Image files
if (
['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp', 'tiff', 'tif'].includes(ext || '')
) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21,15 16,10 5,21" />
</svg>
);
}
// Video files
if (['mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'wmv'].includes(ext || '')) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polygon points="23 7 16 12 23 17 23 7" />
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
</svg>
);
}
// Audio files
if (['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a'].includes(ext || '')) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
);
}
// Archive/compressed files
if (['zip', 'tar', 'gz', 'rar', '7z', 'bz2'].includes(ext || '')) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M16 22h2a2 2 0 0 0 2-2V7.5L14.5 2H6a2 2 0 0 0-2 2v3" />
<polyline points="14,2 14,8 20,8" />
<path d="M10 20v-1a2 2 0 1 1 4 0v1a2 2 0 1 1-4 0Z" />
<path d="M10 7h4" />
<path d="M10 11h4" />
</svg>
);
}
// PDF files
if (ext === 'pdf') {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<path d="M10 12h4" />
<path d="M10 16h2" />
<path d="M10 8h2" />
</svg>
);
}
// Design files
if (['ai', 'eps', 'sketch', 'fig', 'xd', 'psd'].includes(ext || '')) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<path d="M9 9h6v6h-6z" />
<path d="M16 3.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-3z" />
</svg>
);
}
// JavaScript/TypeScript files
if (['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'].includes(ext || '')) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<path d="M10 13l2 2 4-4" />
</svg>
);
}
// Python files
if (['py', 'pyw', 'pyc'].includes(ext || '')) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<circle cx="10" cy="12" r="2" />
<circle cx="14" cy="16" r="2" />
<path d="M12 10c0-1 1-2 2-2s2 1 2 2-1 2-2 2" />
</svg>
);
}
// HTML files
if (['html', 'htm', 'xhtml'].includes(ext || '')) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<polyline points="9,13 9,17 15,17 15,13" />
<line x1="12" y1="13" x2="12" y2="17" />
</svg>
);
}
// CSS files
if (['css', 'scss', 'sass', 'less', 'stylus'].includes(ext || '')) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<path d="M8 13h8" />
<path d="M8 17h8" />
<circle cx="12" cy="15" r="1" />
</svg>
);
}
// JSON/Data files
if (['json', 'xml', 'yaml', 'yml', 'toml', 'csv'].includes(ext || '')) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<path d="M9 13v-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1" />
<path d="M9 17v-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1" />
</svg>
);
}
// Markdown files
if (['md', 'markdown', 'mdx'].includes(ext || '')) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10,9 9,9 8,9" />
</svg>
);
}
// Database files
if (['sql', 'db', 'sqlite', 'sqlite3'].includes(ext || '')) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
</svg>
);
}
// Configuration files
if (
[
'env',
'ini',
'cfg',
'conf',
'config',
'gitignore',
'dockerignore',
'editorconfig',
'prettierrc',
'eslintrc',
].includes(ext || '') ||
['dockerfile', 'makefile', 'rakefile', 'gemfile'].includes(fileName.toLowerCase())
) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1 1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
);
}
// Text files
if (
['txt', 'log', 'readme', 'license', 'changelog', 'contributing'].includes(ext || '') ||
['readme', 'license', 'changelog', 'contributing'].includes(fileName.toLowerCase())
) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10,9 9,9 8,9" />
</svg>
);
}
// Executable files
if (['exe', 'app', 'deb', 'rpm', 'dmg', 'pkg', 'msi'].includes(ext || '')) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polygon points="14 2 18 6 18 20 6 20 6 4 14 4" />
<polyline points="14,2 14,8 20,8" />
<path d="M10 12l2 2 4-4" />
</svg>
);
}
// Script files
if (['sh', 'bash', 'zsh', 'fish', 'bat', 'cmd', 'ps1', 'rb', 'pl', 'php'].includes(ext || '')) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
);
}
// Default file icon
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10,9 9,9 8,9" />
</svg>
);
};
+155
View File
@@ -0,0 +1,155 @@
import React from 'react';
import {
Folder,
File,
Image,
Video,
Music,
Archive,
FileText,
Palette,
Code,
Database,
Settings,
Terminal,
Zap,
BookOpen,
Wrench,
} from 'lucide-react';
import { DisplayItem } from './MentionPopover';
interface FileIconProps {
item: DisplayItem;
}
interface IconInfo {
Icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
color: string;
}
export const getItemIcon = (item: DisplayItem): IconInfo => {
switch (item.itemType) {
case 'Builtin':
return { Icon: Zap, color: '#3b82f6' }; // Blue
case 'Recipe':
return { Icon: BookOpen, color: '#10b981' }; // Green
case 'Directory':
return { Icon: Folder, color: '#f59e0b' }; // Amber
default: {
const ext = item.name.split('.').pop()?.toLowerCase() || '';
// Image files
if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp', 'tiff', 'tif'].includes(ext)) {
return { Icon: Image, color: '#8b5cf6' }; // Purple
}
// Video files
if (['mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) {
return { Icon: Video, color: '#ef4444' }; // Red
}
// Audio files
if (['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a'].includes(ext)) {
return { Icon: Music, color: '#f97316' }; // Orange
}
// Archive/compressed files
if (['zip', 'tar', 'gz', 'rar', '7z', 'bz2'].includes(ext)) {
return { Icon: Archive, color: '#6b7280' }; // Gray
}
// PDF files
if (ext === 'pdf') {
return { Icon: FileText, color: '#dc2626' }; // Red
}
// Design files
if (['ai', 'eps', 'sketch', 'fig', 'xd', 'psd'].includes(ext)) {
return { Icon: Palette, color: '#ec4899' }; // Pink
}
// JavaScript/TypeScript files
if (['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'].includes(ext)) {
return { Icon: Code, color: '#eab308' }; // Yellow
}
// Python files
if (['py', 'pyw', 'pyc'].includes(ext)) {
return { Icon: Code, color: '#3b82f6' }; // Blue
}
// HTML files
if (['html', 'htm', 'xhtml'].includes(ext)) {
return { Icon: Code, color: '#f97316' }; // Orange
}
// CSS files
if (['css', 'scss', 'sass', 'less', 'stylus'].includes(ext)) {
return { Icon: Code, color: '#06b6d4' }; // Cyan
}
// JSON/Data files
if (['json', 'xml', 'yaml', 'yml', 'toml', 'csv'].includes(ext)) {
return { Icon: FileText, color: '#10b981' }; // Green
}
// Markdown files
if (['md', 'markdown', 'mdx'].includes(ext)) {
return { Icon: FileText, color: '#6366f1' }; // Indigo
}
// Database files
if (['sql', 'db', 'sqlite', 'sqlite3'].includes(ext)) {
return { Icon: Database, color: '#059669' }; // Emerald
}
// Configuration files
if (
[
'env',
'ini',
'cfg',
'conf',
'config',
'gitignore',
'dockerignore',
'editorconfig',
'prettierrc',
'eslintrc',
].includes(ext || '') ||
['dockerfile', 'makefile', 'rakefile', 'gemfile'].includes(item.name.toLowerCase())
) {
return { Icon: Settings, color: '#6b7280' }; // Gray
}
// Text files
if (
['txt', 'log', 'readme', 'license', 'changelog', 'contributing'].includes(ext || '') ||
['readme', 'license', 'changelog', 'contributing'].includes(item.name.toLowerCase())
) {
return { Icon: FileText, color: '#374151' }; // Dark gray
}
// Executable files
if (['exe', 'app', 'deb', 'rpm', 'dmg', 'pkg', 'msi'].includes(ext || '')) {
return { Icon: Wrench, color: '#7c3aed' }; // Purple
}
// Script files
if (
['sh', 'bash', 'zsh', 'fish', 'bat', 'cmd', 'ps1', 'rb', 'pl', 'php'].includes(ext || '')
) {
return { Icon: Terminal, color: '#059669' }; // Emerald
}
// Default file icon
return { Icon: File, color: '#6b7280' }; // Gray
}
}
};
export const ItemIcon: React.FC<FileIconProps> = ({ item }) => {
const { Icon, color } = getItemIcon(item);
return <Icon className="w-4 h-4" style={{ color }} />;
};
+455 -420
View File
@@ -1,22 +1,32 @@
import {
useState,
useEffect,
useRef,
useMemo,
forwardRef,
useImperativeHandle,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { FileIcon } from './FileIcon';
import { ItemIcon } from './ItemIcon';
import { CommandType, getSlashCommands } from '../api';
interface FileItem {
path: string;
type DisplayItemType = CommandType | 'Directory' | 'File';
const typeOrder: Record<DisplayItemType, number> = {
Directory: 0,
File: 1,
Builtin: 2,
Recipe: 3,
};
export interface DisplayItem {
name: string;
isDirectory: boolean;
extra: string;
itemType: DisplayItemType;
relativePath: string;
}
export interface FileItemWithMatch extends FileItem {
export interface DisplayItemWithMatch extends DisplayItem {
matchScore: number;
matches: number[];
matchedText: string;
@@ -28,6 +38,7 @@ interface MentionPopoverProps {
onSelect: (filePath: string) => void;
position: { x: number; y: number };
query: string;
isSlashCommand: boolean;
selectedIndex: number;
onSelectedIndexChange: (index: number) => void;
}
@@ -97,440 +108,464 @@ const fuzzyMatch = (pattern: string, text: string): { score: number; matches: nu
};
const MentionPopover = forwardRef<
{ getDisplayFiles: () => FileItemWithMatch[]; selectFile: (index: number) => void },
{ getDisplayFiles: () => DisplayItemWithMatch[]; selectFile: (index: number) => void },
MentionPopoverProps
>(({ isOpen, onClose, onSelect, position, query, selectedIndex, onSelectedIndexChange }, ref) => {
const [files, setFiles] = useState<FileItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
>(
(
{
isOpen,
onClose,
onSelect,
position,
query,
isSlashCommand,
selectedIndex,
onSelectedIndexChange,
},
ref
) => {
const [items, setItems] = useState<DisplayItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const currentWorkingDir = window.appConfig.get('GOOSE_WORKING_DIR') as string;
const currentWorkingDir = window.appConfig.get('GOOSE_WORKING_DIR') as string;
const compareByType = (a: FileItemWithMatch, b: FileItemWithMatch) =>
a.isDirectory !== b.isDirectory ? (a.isDirectory ? 1 : -1) : 0;
const scanDirectoryFromRoot = useCallback(
async (dirPath: string, relativePath = '', depth = 0): Promise<DisplayItem[]> => {
// Increase depth limit for better file discovery
if (depth > 5) return [];
// Filter and sort files based on query
const displayFiles = useMemo((): FileItemWithMatch[] => {
if (!query.trim()) {
return files
.map((file) => ({
...file,
matchScore: 0,
matches: [],
matchedText: file.name,
depth: currentWorkingDir
? file.path.replace(currentWorkingDir, '').split('/').length - 1
: 0,
}))
try {
const items = await window.electron.listFiles(dirPath);
const results: DisplayItem[] = [];
// Common directories to prioritize or skip
const priorityDirs = [
'Desktop',
'Documents',
'Downloads',
'Projects',
'Development',
'Code',
'src',
'components',
'icons',
];
const skipDirs = [
'.git',
'.svn',
'.hg',
'node_modules',
'__pycache__',
'.vscode',
'.idea',
'target',
'dist',
'build',
'.cache',
'.npm',
'.yarn',
'Library',
'System',
'Applications',
'.Trash',
];
// Don't skip as many directories at deeper levels to find more items
const skipDirsAtDepth =
depth > 2 ? ['.git', '.svn', '.hg', 'node_modules', '__pycache__'] : skipDirs;
// Sort items to prioritize certain directories
const sortedItems = items.sort((a, b) => {
const aPriority = priorityDirs.includes(a);
const bPriority = priorityDirs.includes(b);
if (aPriority && !bPriority) return -1;
if (!aPriority && bPriority) return 1;
return a.localeCompare(b);
});
// Increase item limit per directory for better coverage
const itemLimit = depth === 0 ? 50 : depth === 1 ? 40 : 30;
for (const item of sortedItems.slice(0, itemLimit)) {
const fullPath = `${dirPath}/${item}`;
const itemRelativePath = relativePath ? `${relativePath}/${item}` : item;
// Skip hidden items and common ignore patterns
if (item.startsWith('.') || skipDirsAtDepth.includes(item)) {
continue;
}
// First, check if this looks like a file based on extension
const hasExtension = item.includes('.');
const ext = item.split('.').pop()?.toLowerCase();
const commonExtensions = [
// Code items
'txt',
'md',
'js',
'ts',
'jsx',
'tsx',
'py',
'java',
'cpp',
'c',
'h',
'css',
'html',
'json',
'xml',
'yaml',
'yml',
'toml',
'ini',
'cfg',
'sh',
'bat',
'ps1',
'rb',
'go',
'rs',
'php',
'sql',
'r',
'scala',
'swift',
'kt',
'dart',
'vue',
'svelte',
'astro',
'scss',
'less',
// Documentation
'readme',
'license',
'changelog',
'contributing',
// Config items
'gitignore',
'dockerignore',
'editorconfig',
'prettierrc',
'eslintrc',
// Images and assets
'png',
'jpg',
'jpeg',
'gif',
'svg',
'ico',
'webp',
'bmp',
'tiff',
'tif',
// Vector and design items
'ai',
'eps',
'sketch',
'fig',
'xd',
'psd',
// Other common items
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
];
// If it has a known file extension, treat it as a file
if (hasExtension && ext && commonExtensions.includes(ext)) {
results.push({
extra: fullPath,
name: item,
itemType: 'File',
relativePath: itemRelativePath,
});
continue;
}
// If it's a known file without extension (README, LICENSE, etc.)
const knownFiles = [
'readme',
'license',
'changelog',
'contributing',
'dockerfile',
'makefile',
];
if (!hasExtension && knownFiles.includes(item.toLowerCase())) {
results.push({
extra: fullPath,
name: item,
itemType: 'File',
relativePath: itemRelativePath,
});
continue;
}
// Otherwise, try to determine if it's a directory
try {
await window.electron.listFiles(fullPath);
results.push({
name: item,
extra: fullPath,
itemType: 'Directory',
relativePath: itemRelativePath,
});
// Recursively scan directories more aggressively
if (depth < 4 || priorityDirs.includes(item)) {
const subFiles = await scanDirectoryFromRoot(fullPath, itemRelativePath, depth + 1);
results.push(...subFiles);
}
} catch {
// If we can't list it and it doesn't have a known extension, skip it
// This could be a file with an unknown extension or a permission issue
}
}
return results;
} catch (error) {
console.error(`Error scanning directory ${dirPath}:`, error);
return [];
}
},
[]
);
const scanFilesFromRoot = useCallback(async () => {
setIsLoading(true);
try {
let startPath = currentWorkingDir;
if (!startPath) {
if (window.electron.platform === 'win32') {
startPath = 'C:\\Users';
} else if (window.electron.platform === 'linux') {
startPath = '/home';
} else {
startPath = '/Users'; // Default to macOS
}
}
const scannedFiles = await scanDirectoryFromRoot(startPath);
setItems(scannedFiles);
} catch (error) {
console.error('Error scanning items from root:', error);
setItems([]);
} finally {
setIsLoading(false);
}
}, [scanDirectoryFromRoot, currentWorkingDir]);
const compareByType = (a: DisplayItemWithMatch, b: DisplayItemWithMatch) => {
const orderA = typeOrder[a.itemType] ?? Number.MAX_SAFE_INTEGER;
const orderB = typeOrder[b.itemType] ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
};
const displayItems = useMemo((): DisplayItemWithMatch[] => {
if (!query.trim()) {
return items
.map((file) => ({
...file,
matchScore: 0,
matches: [],
matchedText: file.name,
depth: currentWorkingDir
? file.extra.replace(currentWorkingDir, '').split('/').length - 1
: 0,
}))
.sort((a, b) => {
if (a.depth !== b.depth) return a.depth - b.depth;
const typeComparison = compareByType(a, b);
return typeComparison || a.name.localeCompare(b.name);
});
}
return items
.map((file) => {
const matches = [
{ match: fuzzyMatch(query, file.name), text: file.name },
{ match: fuzzyMatch(query, file.relativePath), text: file.relativePath },
{ match: fuzzyMatch(query, file.extra), text: file.extra },
];
const { match: bestMatch, text: matchedText } = matches.reduce((best, current) =>
current.match.score > best.match.score ? current : best
);
let finalScore = bestMatch.score;
if (finalScore > 0 && currentWorkingDir) {
const depth = file.extra.replace(currentWorkingDir, '').split('/').length - 1;
finalScore += depth <= 1 ? 50 : depth <= 2 ? 30 : depth <= 3 ? 15 : 0;
}
return {
...file,
matchScore: finalScore,
matches: bestMatch.matches,
matchedText,
};
})
.filter((file) => file.matchScore > 0)
.sort((a, b) => {
if (a.depth !== b.depth) return a.depth - b.depth;
// Sort by score first, then prefer items over directories, then alphabetically
const scoreDiff = b.matchScore - a.matchScore;
if (Math.abs(scoreDiff) >= 1) return scoreDiff;
const typeComparison = compareByType(a, b);
return typeComparison || a.name.localeCompare(b.name);
});
}
}, [items, query, currentWorkingDir]);
const results = files
.map((file) => {
const matches = [
{ match: fuzzyMatch(query, file.name), text: file.name },
{ match: fuzzyMatch(query, file.relativePath), text: file.relativePath },
{ match: fuzzyMatch(query, file.path), text: file.path },
];
// Expose methods to parent component
useImperativeHandle(
ref,
() => ({
getDisplayFiles: () => displayItems,
selectFile: (index: number) => {
if (displayItems[index]) {
onSelect(displayItems[index].extra);
onClose();
}
},
}),
[displayItems, onSelect, onClose]
);
const { match: bestMatch, text: matchedText } = matches.reduce((best, current) =>
current.match.score > best.match.score ? current : best
);
let finalScore = bestMatch.score;
if (finalScore > 0 && currentWorkingDir) {
const depth = file.path.replace(currentWorkingDir, '').split('/').length - 1;
finalScore += depth <= 1 ? 50 : depth <= 2 ? 30 : depth <= 3 ? 15 : 0;
useEffect(() => {
const loadData = async () => {
if (isSlashCommand) {
const response = await getSlashCommands({ throwOnError: true });
const commandItems: DisplayItem[] = (response.data?.commands || []).map((cmd) => ({
name: cmd.command,
extra: cmd.help,
itemType: cmd.command_type,
relativePath: cmd.command,
}));
setItems(commandItems);
} else {
await scanFilesFromRoot();
}
};
return {
...file,
matchScore: finalScore,
matches: bestMatch.matches,
matchedText,
};
})
.filter((file) => file.matchScore > 0)
.sort((a, b) => {
// Sort by score first, then prefer files over directories, then alphabetically
const scoreDiff = b.matchScore - a.matchScore;
if (Math.abs(scoreDiff) >= 1) return scoreDiff;
const typeComparison = compareByType(a, b);
return typeComparison || a.name.localeCompare(b.name);
});
if (isOpen) {
loadData();
}
}, [isOpen, isSlashCommand, scanFilesFromRoot]);
return results;
}, [files, query, currentWorkingDir]);
// Expose methods to parent component
useImperativeHandle(
ref,
() => ({
getDisplayFiles: () => displayFiles,
selectFile: (index: number) => {
if (displayFiles[index]) {
onSelect(displayFiles[index].path);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
onClose();
}
},
}),
[displayFiles, onSelect, onClose]
);
};
// Scan files when component opens
useEffect(() => {
if (isOpen && files.length === 0) {
scanFilesFromRoot();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, files.length]); // scanFilesFromRoot intentionally omitted to avoid circular dependency
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
// Handle clicks outside the popover
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
// Scroll selected item into view
useEffect(() => {
if (listRef.current && selectedIndex >= 0 && selectedIndex < displayItems.length) {
const selectedElement = listRef.current.children[selectedIndex] as HTMLElement;
if (selectedElement) {
selectedElement.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
});
}
}
}, [selectedIndex, displayItems.length]);
const handleItemClick = (index: number) => {
if (index >= 0 && index < displayItems.length) {
onSelectedIndexChange(index);
const displayItem = displayItems[index];
onSelect(
['Builtin', 'Recipe'].includes(displayItem.itemType)
? '/' + displayItem.name
: displayItem.extra
);
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
if (!isOpen) return null;
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
const scanDirectoryFromRoot = useCallback(
async (dirPath: string, relativePath = '', depth = 0): Promise<FileItem[]> => {
// Increase depth limit for better file discovery
if (depth > 5) return [];
try {
const items = await window.electron.listFiles(dirPath);
const results: FileItem[] = [];
// Common directories to prioritize or skip
const priorityDirs = [
'Desktop',
'Documents',
'Downloads',
'Projects',
'Development',
'Code',
'src',
'components',
'icons',
];
const skipDirs = [
'.git',
'.svn',
'.hg',
'node_modules',
'__pycache__',
'.vscode',
'.idea',
'target',
'dist',
'build',
'.cache',
'.npm',
'.yarn',
'Library',
'System',
'Applications',
'.Trash',
];
// Don't skip as many directories at deeper levels to find more files
const skipDirsAtDepth =
depth > 2 ? ['.git', '.svn', '.hg', 'node_modules', '__pycache__'] : skipDirs;
// Sort items to prioritize certain directories
const sortedItems = items.sort((a, b) => {
const aPriority = priorityDirs.includes(a);
const bPriority = priorityDirs.includes(b);
if (aPriority && !bPriority) return -1;
if (!aPriority && bPriority) return 1;
return a.localeCompare(b);
});
// Increase item limit per directory for better coverage
const itemLimit = depth === 0 ? 50 : depth === 1 ? 40 : 30;
for (const item of sortedItems.slice(0, itemLimit)) {
const fullPath = `${dirPath}/${item}`;
const itemRelativePath = relativePath ? `${relativePath}/${item}` : item;
// Skip hidden files and common ignore patterns
if (item.startsWith('.') || skipDirsAtDepth.includes(item)) {
continue;
}
// First, check if this looks like a file based on extension
const hasExtension = item.includes('.');
const ext = item.split('.').pop()?.toLowerCase();
const commonExtensions = [
// Code files
'txt',
'md',
'js',
'ts',
'jsx',
'tsx',
'py',
'java',
'cpp',
'c',
'h',
'css',
'html',
'json',
'xml',
'yaml',
'yml',
'toml',
'ini',
'cfg',
'sh',
'bat',
'ps1',
'rb',
'go',
'rs',
'php',
'sql',
'r',
'scala',
'swift',
'kt',
'dart',
'vue',
'svelte',
'astro',
'scss',
'less',
// Documentation
'readme',
'license',
'changelog',
'contributing',
// Config files
'gitignore',
'dockerignore',
'editorconfig',
'prettierrc',
'eslintrc',
// Images and assets
'png',
'jpg',
'jpeg',
'gif',
'svg',
'ico',
'webp',
'bmp',
'tiff',
'tif',
// Vector and design files
'ai',
'eps',
'sketch',
'fig',
'xd',
'psd',
// Other common files
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
];
// If it has a known file extension, treat it as a file
if (hasExtension && ext && commonExtensions.includes(ext)) {
results.push({
path: fullPath,
name: item,
isDirectory: false,
relativePath: itemRelativePath,
});
continue;
}
// If it's a known file without extension (README, LICENSE, etc.)
const knownFiles = [
'readme',
'license',
'changelog',
'contributing',
'dockerfile',
'makefile',
];
if (!hasExtension && knownFiles.includes(item.toLowerCase())) {
results.push({
path: fullPath,
name: item,
isDirectory: false,
relativePath: itemRelativePath,
});
continue;
}
// Otherwise, try to determine if it's a directory
try {
await window.electron.listFiles(fullPath);
// It's a directory
results.push({
path: fullPath,
name: item,
isDirectory: true,
relativePath: itemRelativePath,
});
// Recursively scan directories more aggressively
if (depth < 4 || priorityDirs.includes(item)) {
const subFiles = await scanDirectoryFromRoot(fullPath, itemRelativePath, depth + 1);
results.push(...subFiles);
}
} catch {
// If we can't list it and it doesn't have a known extension, skip it
// This could be a file with an unknown extension or a permission issue
}
}
return results;
} catch (error) {
console.error(`Error scanning directory ${dirPath}:`, error);
return [];
}
},
[]
);
const scanFilesFromRoot = useCallback(async () => {
setIsLoading(true);
try {
let startPath = currentWorkingDir;
if (!startPath) {
if (window.electron.platform === 'win32') {
startPath = 'C:\\Users';
} else if (window.electron.platform === 'linux') {
startPath = '/home';
} else {
startPath = '/Users'; // Default to macOS
}
}
const scannedFiles = await scanDirectoryFromRoot(startPath);
setFiles(scannedFiles);
} catch (error) {
console.error('Error scanning files from root:', error);
setFiles([]);
} finally {
setIsLoading(false);
}
}, [scanDirectoryFromRoot, currentWorkingDir]);
// Scroll selected item into view
useEffect(() => {
if (listRef.current && selectedIndex >= 0 && selectedIndex < displayFiles.length) {
const selectedElement = listRef.current.children[selectedIndex] as HTMLElement;
if (selectedElement) {
selectedElement.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
});
}
}
}, [selectedIndex, displayFiles.length]);
const handleItemClick = (index: number) => {
if (index >= 0 && index < displayFiles.length) {
onSelectedIndexChange(index);
onSelect(displayFiles[index].path);
onClose();
}
};
if (!isOpen) return null;
return (
<div
ref={popoverRef}
className="fixed z-50 bg-background-default border border-borderStandard rounded-lg shadow-lg min-w-96 max-w-lg max-h-80"
style={{
left: position.x,
top: position.y - 10, // Position above the chat input
transform: 'translateY(-100%)', // Move it fully above
}}
>
<div className="p-3 flex flex-col max-h-80">
{isLoading ? (
<div className="flex items-center justify-center py-4">
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-textSubtle"></div>
<span className="ml-2 text-sm text-textSubtle">Scanning files...</span>
</div>
) : (
<>
{displayFiles.length > 0 && (
<div className="text-xs text-textSubtle mb-2 px-1">
{displayFiles.length} file{displayFiles.length !== 1 ? 's' : ''} found
</div>
)}
<div
ref={listRef}
className="space-y-1 overflow-y-auto flex-1 scrollbar-thin scrollbar-thumb-borderStandard scrollbar-track-transparent"
style={{ maxHeight: '280px' }}
>
{displayFiles.map((file, index) => (
<div
key={file.path}
onClick={() => handleItemClick(index)}
className={`flex items-center gap-3 p-2 rounded-md cursor-pointer transition-colors ${
index === selectedIndex
? 'bg-bgProminent text-textProminentInverse'
: 'hover:bg-bgSubtle'
}`}
>
<div className="flex-shrink-0 text-textSubtle">
<FileIcon fileName={file.name} isDirectory={file.isDirectory} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm truncate text-textStandard">{file.name}</div>
<div className="text-xs text-textSubtle truncate">{file.path}</div>
</div>
</div>
))}
{!isLoading && displayFiles.length === 0 && query && (
<div className="p-4 text-center text-textSubtle text-sm">
No files found matching "{query}"
</div>
)}
{!isLoading && displayFiles.length === 0 && !query && (
<div className="p-4 text-center text-textSubtle text-sm">
Start typing to search for files
</div>
)}
return (
<div
ref={popoverRef}
className="fixed z-50 bg-background-default border border-borderStandard rounded-lg shadow-lg min-w-96 max-w-lg max-h-80"
style={{
left: position.x,
top: position.y - 10, // Position above the chat input
transform: 'translateY(-100%)', // Move it fully above
}}
>
<div className="p-3 flex flex-col max-h-80">
{isLoading ? (
<div className="flex items-center justify-center py-4">
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-textSubtle"></div>
<span className="ml-2 text-sm text-textSubtle">Scanning files...</span>
</div>
</>
)}
) : (
<>
{displayItems.length > 0 && (
<div className="text-xs text-textSubtle mb-2 px-1">
{displayItems.length} item{displayItems.length !== 1 ? 's' : ''} found
</div>
)}
<div
ref={listRef}
className="space-y-1 overflow-y-auto flex-1 scrollbar-thin scrollbar-thumb-borderStandard scrollbar-track-transparent"
style={{ maxHeight: '280px' }}
>
{displayItems.map((item, index) => (
<div
key={item.extra}
onClick={() => handleItemClick(index)}
className={`flex items-center gap-3 p-2 rounded-md cursor-pointer transition-colors ${
index === selectedIndex
? 'bg-bgProminent text-textProminentInverse'
: 'hover:bg-bgSubtle'
}`}
>
<div className="flex-shrink-0 text-textSubtle">
<ItemIcon item={item} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm truncate text-textStandard">{item.name}</div>
<div className="text-xs text-textSubtle truncate">{item.extra}</div>
</div>
</div>
))}
{!isLoading && displayItems.length === 0 && query && (
<div className="p-4 text-center text-textSubtle text-sm">
No items found matching "{query}"
</div>
)}
</div>
</>
)}
</div>
</div>
</div>
);
});
);
}
);
MentionPopover.displayName = 'MentionPopover';
@@ -3,11 +3,9 @@ import { useForm } from '@tanstack/react-form';
import { Recipe, generateDeepLink, Parameter } from '../../recipe';
import { Geese } from '../icons/Geese';
import Copy from '../icons/Copy';
import { Check, Save, Calendar, X, Play } from 'lucide-react';
import { Check, Save, X, Play } from 'lucide-react';
import { ExtensionConfig } from '../ConfigContext';
import { ScheduleFromRecipeModal } from '../schedule/ScheduleFromRecipeModal';
import { Button } from '../ui/button';
import { useNavigation } from '../../hooks/useNavigation';
import { RecipeFormFields } from './shared/RecipeFormFields';
import { RecipeFormData } from './shared/recipeFormSchema';
@@ -29,7 +27,6 @@ export default function CreateEditRecipeModal({
isCreateMode = false,
recipeId,
}: CreateEditRecipeModalProps) {
const setView = useNavigation();
const getInitialValues = React.useCallback((): RecipeFormData => {
if (recipe) {
return {
@@ -81,7 +78,6 @@ export default function CreateEditRecipeModal({
});
}, [form]);
const [copied, setCopied] = useState(false);
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Initialize selected extensions for the recipe
@@ -415,16 +411,6 @@ export default function CreateEditRecipeModal({
</Button>
<div className="flex gap-3">
<Button
onClick={() => setIsScheduleModalOpen(true)}
disabled={!requiredFieldsAreFilled()}
variant="outline"
size="default"
className="inline-flex items-center justify-center gap-2 px-4 py-2"
>
<Calendar className="w-4 h-4" />
Create Schedule
</Button>
<Button
onClick={handleSaveRecipeClick}
disabled={!requiredFieldsAreFilled() || isSaving}
@@ -448,17 +434,6 @@ export default function CreateEditRecipeModal({
</div>
</div>
</div>
<ScheduleFromRecipeModal
isOpen={isScheduleModalOpen}
onClose={() => setIsScheduleModalOpen(false)}
recipe={getCurrentRecipe()}
onCreateSchedule={(deepLink) => {
// Navigate to schedules view with the deep link in state
setView('schedules', { pendingScheduleDeepLink: deepLink });
setIsScheduleModalOpen(false);
}}
/>
</div>
);
}
+285 -64
View File
@@ -1,6 +1,16 @@
import { useState, useEffect } from 'react';
import { listSavedRecipes, convertToLocaleDateString } from '../../recipe/recipe_management';
import { FileText, Edit, Trash2, Play, Calendar, AlertCircle, Link } from 'lucide-react';
import {
FileText,
Edit,
Trash2,
Play,
Calendar,
AlertCircle,
Link,
Clock,
Terminal,
} from 'lucide-react';
import { ScrollArea } from '../ui/scroll-area';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
@@ -8,46 +18,58 @@ import { Skeleton } from '../ui/skeleton';
import { MainPanelLayout } from '../Layout/MainPanelLayout';
import { toastSuccess } from '../../toasts';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { deleteRecipe, RecipeManifestResponse, startAgent } from '../../api';
import {
deleteRecipe,
RecipeManifest,
startAgent,
scheduleRecipe,
setRecipeSlashCommand,
} from '../../api';
import ImportRecipeForm, { ImportRecipeButton } from './ImportRecipeForm';
import CreateEditRecipeModal from './CreateEditRecipeModal';
import { generateDeepLink, Recipe } from '../../recipe';
import { ScheduleFromRecipeModal } from '../schedule/ScheduleFromRecipeModal';
import { useNavigation } from '../../hooks/useNavigation';
import { CronPicker } from '../schedule/CronPicker';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
import cronstrue from 'cronstrue';
export default function RecipesView() {
const setView = useNavigation();
const [savedRecipes, setSavedRecipes] = useState<RecipeManifestResponse[]>([]);
const [savedRecipes, setSavedRecipes] = useState<RecipeManifest[]>([]);
const [loading, setLoading] = useState(true);
const [showSkeleton, setShowSkeleton] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedRecipe, setSelectedRecipe] = useState<RecipeManifestResponse | null>(null);
const [selectedRecipe, setSelectedRecipe] = useState<RecipeManifest | null>(null);
const [showEditor, setShowEditor] = useState(false);
const [showContent, setShowContent] = useState(false);
// Form dialog states
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
const [showScheduleModal, setShowScheduleModal] = useState(false);
const [selectedRecipeForSchedule, setSelectedRecipeForSchedule] = useState<Recipe | null>(null);
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
const [scheduleRecipeManifest, setScheduleRecipeManifest] = useState<RecipeManifest | null>(null);
const [scheduleCron, setScheduleCron] = useState<string>('');
const [showSlashCommandDialog, setShowSlashCommandDialog] = useState(false);
const [slashCommandRecipeManifest, setSlashCommandRecipeManifest] =
useState<RecipeManifest | null>(null);
const [slashCommand, setSlashCommand] = useState<string>('');
const [scheduleValid, setScheduleIsValid] = useState(true);
useEffect(() => {
loadSavedRecipes();
}, []);
// Handle Esc key for editor modal
useEscapeKey(showEditor, () => setShowEditor(false));
// Minimum loading time to prevent skeleton flash
useEffect(() => {
if (!loading && showSkeleton) {
const timer = setTimeout(() => {
setShowSkeleton(false);
// Add a small delay before showing content for fade-in effect
setTimeout(() => {
setShowContent(true);
}, 50);
}, 300); // Show skeleton for at least 300ms
}, 300);
return () => clearTimeout(timer);
}
@@ -91,25 +113,15 @@ export default function RecipesView() {
}
} else {
try {
// onLoadRecipe is not working for loading recipes. It looks correct
// but the instructions are not flowing through to the server.
// Needs a fix but commenting out to get prod back up and running.
//
// if (onLoadRecipe) {
// // Use the callback to navigate within the same window
// onLoadRecipe(savedRecipe.recipe);
// } else {
// Fallback to creating a new window (for backwards compatibility)
window.electron.createChatWindow(
undefined, // query
undefined, // dir
undefined, // version
undefined, // resumeSessionId
recipe, // recipe config
undefined, // view type,
recipeId // recipe id
undefined,
undefined,
undefined,
undefined,
recipe,
undefined,
recipeId
);
// }
} catch (err) {
console.error('Failed to load recipe:', err);
setError(err instanceof Error ? err.message : 'Failed to load recipe');
@@ -117,8 +129,7 @@ export default function RecipesView() {
}
};
const handleDeleteRecipe = async (recipeManifest: RecipeManifestResponse) => {
// TODO: Use Electron's dialog API for confirmation
const handleDeleteRecipe = async (recipeManifest: RecipeManifest) => {
const result = await window.electron.showMessageBox({
type: 'warning',
buttons: ['Cancel', 'Delete'],
@@ -145,7 +156,7 @@ export default function RecipesView() {
}
};
const handleEditRecipe = async (recipeManifest: RecipeManifestResponse) => {
const handleEditRecipe = async (recipeManifest: RecipeManifest) => {
setSelectedRecipe(recipeManifest);
setShowEditor(true);
};
@@ -153,13 +164,12 @@ export default function RecipesView() {
const handleEditorClose = (wasSaved?: boolean) => {
setShowEditor(false);
setSelectedRecipe(null);
// Only reload recipes if a recipe was actually saved/updated
if (wasSaved) {
loadSavedRecipes();
}
};
const handleCopyDeeplink = async (recipeManifest: RecipeManifestResponse) => {
const handleCopyDeeplink = async (recipeManifest: RecipeManifest) => {
try {
const deeplink = await generateDeepLink(recipeManifest.recipe);
await navigator.clipboard.writeText(deeplink);
@@ -176,25 +186,132 @@ export default function RecipesView() {
}
};
const handleScheduleRecipe = (recipe: Recipe) => {
setSelectedRecipeForSchedule(recipe);
setShowScheduleModal(true);
const handleOpenScheduleDialog = (recipeManifest: RecipeManifest) => {
setScheduleRecipeManifest(recipeManifest);
setScheduleCron(recipeManifest.schedule_cron || '0 0 14 * * *');
setShowScheduleDialog(true);
};
const handleCreateScheduleFromRecipe = async (deepLink: string) => {
// Navigate to schedules view with the deep link in state
setView('schedules', { pendingScheduleDeepLink: deepLink });
const handleSaveSchedule = async () => {
if (!scheduleRecipeManifest) return;
setShowScheduleModal(false);
setSelectedRecipeForSchedule(null);
try {
await scheduleRecipe({
body: {
id: scheduleRecipeManifest.id,
cron_schedule: scheduleCron,
},
});
toastSuccess({
title: 'Schedule saved',
msg: `Recipe will run ${getReadableCron(scheduleCron)}`,
});
setShowScheduleDialog(false);
setScheduleRecipeManifest(null);
await loadSavedRecipes();
} catch (error) {
console.error('Failed to save schedule:', error);
setError(error instanceof Error ? error.message : 'Failed to save schedule');
}
};
const handleRemoveSchedule = async () => {
if (!scheduleRecipeManifest) return;
try {
await scheduleRecipe({
body: {
id: scheduleRecipeManifest.id,
cron_schedule: null,
},
});
toastSuccess({
title: 'Schedule removed',
msg: 'Recipe will no longer run automatically',
});
setShowScheduleDialog(false);
setScheduleRecipeManifest(null);
await loadSavedRecipes();
} catch (error) {
console.error('Failed to remove schedule:', error);
setError(error instanceof Error ? error.message : 'Failed to remove schedule');
}
};
const handleOpenSlashCommandDialog = (recipeManifest: RecipeManifest) => {
setSlashCommandRecipeManifest(recipeManifest);
setSlashCommand(recipeManifest.slash_command || '');
setShowSlashCommandDialog(true);
};
const handleSaveSlashCommand = async () => {
if (!slashCommandRecipeManifest) return;
try {
await setRecipeSlashCommand({
body: {
id: slashCommandRecipeManifest.id,
slash_command: slashCommand || null,
},
});
toastSuccess({
title: 'Slash command saved',
msg: slashCommand ? `Use /${slashCommand} to run this recipe` : 'Slash command removed',
});
setShowSlashCommandDialog(false);
setSlashCommandRecipeManifest(null);
await loadSavedRecipes();
} catch (error) {
console.error('Failed to save slash command:', error);
setError(error instanceof Error ? error.message : 'Failed to save slash command');
}
};
const handleRemoveSlashCommand = async () => {
if (!slashCommandRecipeManifest) return;
try {
await setRecipeSlashCommand({
body: {
id: slashCommandRecipeManifest.id,
slash_command: null,
},
});
toastSuccess({
title: 'Slash command removed',
msg: 'Recipe slash command has been removed',
});
setShowSlashCommandDialog(false);
setSlashCommandRecipeManifest(null);
await loadSavedRecipes();
} catch (error) {
console.error('Failed to remove slash command:', error);
setError(error instanceof Error ? error.message : 'Failed to remove slash command');
}
};
const getReadableCron = (cron: string): string => {
try {
const cronWithoutSeconds = cron.split(' ').slice(1).join(' ');
return cronstrue.toString(cronWithoutSeconds).toLowerCase();
} catch {
return cron;
}
};
// Render a recipe item
const RecipeItem = ({
recipeManifestResponse,
recipeManifestResponse: { recipe, lastModified },
recipeManifestResponse: { recipe, last_modified: lastModified, schedule_cron, slash_command },
}: {
recipeManifestResponse: RecipeManifestResponse;
recipeManifestResponse: RecipeManifest;
}) => (
<Card className="py-2 px-4 mb-2 bg-background-default border-none hover:bg-background-muted transition-all duration-150">
<div className="flex justify-between items-start gap-4">
@@ -203,12 +320,42 @@ export default function RecipesView() {
<h3 className="text-base truncate max-w-[50vw]">{recipe.title}</h3>
</div>
<p className="text-text-muted text-sm mb-2 line-clamp-2">{recipe.description}</p>
<div className="flex items-center text-xs text-text-muted">
<Calendar className="w-3 h-3 mr-1" />
{convertToLocaleDateString(lastModified)}
<div className="flex flex-col gap-1 text-xs text-text-muted">
<div className="flex items-center">
<Calendar className="w-3 h-3 mr-1" />
{convertToLocaleDateString(lastModified)}
</div>
{(schedule_cron || slash_command) && (
<div className="flex items-center gap-3">
{schedule_cron && (
<div className="flex items-center text-blue-600 dark:text-blue-400">
<Clock className="w-3 h-3 mr-1" />
Runs {getReadableCron(schedule_cron)}
</div>
)}
{slash_command && (
<div className="flex items-center text-purple-600 dark:text-purple-400">
/{slash_command}
</div>
)}
</div>
)}
</div>
</div>
<Button
onClick={(e) => {
e.stopPropagation();
handleOpenSlashCommandDialog(recipeManifestResponse);
}}
variant={slash_command ? 'default' : 'outline'}
size="sm"
className="h-8 w-8 p-0"
title={slash_command ? 'Edit slash command' : 'Add slash command'}
>
<Terminal className="w-4 h-4" />
</Button>
<div className="flex items-center gap-2 shrink-0">
<Button
onClick={(e) => {
@@ -248,14 +395,14 @@ export default function RecipesView() {
<Button
onClick={(e) => {
e.stopPropagation();
handleScheduleRecipe(recipe);
handleOpenScheduleDialog(recipeManifestResponse);
}}
variant="outline"
variant={schedule_cron ? 'default' : 'outline'}
size="sm"
className="h-8 w-8 p-0"
title="Create schedule"
title={schedule_cron ? 'Edit schedule' : 'Add schedule'}
>
<Calendar className="w-4 h-4" />
<Clock className="w-4 h-4" />
</Button>
<Button
onClick={(e) => {
@@ -274,7 +421,6 @@ export default function RecipesView() {
</Card>
);
// Render skeleton loader for recipe items
const RecipeSkeleton = () => (
<Card className="p-2 mb-2 bg-background-default">
<div className="flex justify-between items-start gap-4">
@@ -334,7 +480,7 @@ export default function RecipesView() {
return (
<div className="space-y-2">
{savedRecipes.map((recipeManifestResponse: RecipeManifestResponse) => (
{savedRecipes.map((recipeManifestResponse: RecipeManifest) => (
<RecipeItem
key={recipeManifestResponse.id}
recipeManifestResponse={recipeManifestResponse}
@@ -412,16 +558,91 @@ export default function RecipesView() {
/>
)}
{showScheduleModal && selectedRecipeForSchedule && (
<ScheduleFromRecipeModal
isOpen={showScheduleModal}
onClose={() => {
setShowScheduleModal(false);
setSelectedRecipeForSchedule(null);
}}
recipe={selectedRecipeForSchedule}
onCreateSchedule={handleCreateScheduleFromRecipe}
/>
{showScheduleDialog && scheduleRecipeManifest && (
<Dialog open={showScheduleDialog} onOpenChange={setShowScheduleDialog}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{scheduleRecipeManifest.schedule_cron ? 'Edit' : 'Add'} Schedule
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<CronPicker
schedule={
scheduleRecipeManifest.schedule_cron
? {
id: scheduleRecipeManifest.id,
source: '',
cron: scheduleRecipeManifest.schedule_cron,
last_run: null,
currently_running: false,
paused: false,
}
: null
}
onChange={setScheduleCron}
isValid={setScheduleIsValid}
/>
<div className="flex gap-2 justify-end">
{scheduleRecipeManifest.schedule_cron && (
<Button variant="outline" onClick={handleRemoveSchedule}>
Remove Schedule
</Button>
)}
<Button variant="outline" onClick={() => setShowScheduleDialog(false)}>
Cancel
</Button>
<Button onClick={handleSaveSchedule} disabled={!scheduleValid}>
Save
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)}
{showSlashCommandDialog && slashCommandRecipeManifest && (
<Dialog open={showSlashCommandDialog} onOpenChange={setShowSlashCommandDialog}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Slash Command</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<p className="text-sm text-muted-foreground mb-3">
Set a slash command to quickly run this recipe from any chat
</p>
<div className="flex gap-2 items-center">
<span className="text-muted-foreground">/</span>
<input
type="text"
value={slashCommand}
onChange={(e) => setSlashCommand(e.target.value)}
placeholder="command-name"
className="flex-1 px-3 py-2 border rounded text-sm"
/>
</div>
{slashCommand && (
<p className="text-xs text-muted-foreground mt-2">
Use /{slashCommand} in any chat to run this recipe
</p>
)}
</div>
<div className="flex gap-2 justify-end">
{slashCommandRecipeManifest.slash_command && (
<Button variant="outline" onClick={handleRemoveSlashCommand}>
Remove
</Button>
)}
<Button variant="outline" onClick={() => setShowSlashCommandDialog(false)}>
Cancel
</Button>
<Button onClick={handleSaveSlashCommand}>Save</Button>
</div>
</div>
</DialogContent>
</Dialog>
)}
</>
);
+3 -3
View File
@@ -1,4 +1,4 @@
import { Recipe, saveRecipe as saveRecipeApi, listRecipes, RecipeManifestResponse } from '../api';
import { Recipe, saveRecipe as saveRecipeApi, listRecipes, RecipeManifest } from '../api';
export const saveRecipe = async (recipe: Recipe, recipeId?: string | null): Promise<string> => {
try {
@@ -19,10 +19,10 @@ export const saveRecipe = async (recipe: Recipe, recipeId?: string | null): Prom
}
};
export const listSavedRecipes = async (): Promise<RecipeManifestResponse[]> => {
export const listSavedRecipes = async (): Promise<RecipeManifest[]> => {
try {
const listRecipeResponse = await listRecipes();
return listRecipeResponse?.data?.recipe_manifest_responses ?? [];
return listRecipeResponse?.data?.manifests ?? [];
} catch (error) {
console.warn('Failed to list saved recipes:', error);
return [];