diff --git a/CHANGELOG.md b/CHANGELOG.md index b906f134..d1538684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to this project will be documented in this file. - Added queued message mode with chips, batching, and idle auto‑send (including attachments). - Added queue mode toggle to OpenChamber settings (chat section) with persistence across runtimes. - Fixed scroll position persistence for active conversation turns across session switches. -- Refactored command management with ability to configure project/user scoped commands. +- Refactored Agents/Commands management with ability to configure project/user scopes. ## [1.3.7] - 2025-12-28 diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index da05b23a..d97734b9 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -1190,18 +1190,27 @@ async fn handle_agent_route( req: Request, name: String, ) -> Result, StatusCode> { + // Get working directory for project-level agent detection + let working_directory = state.opencode.get_working_directory(); + match method { Method::GET => { - match opencode_config::get_agent_sources(&name).await { - Ok(sources) => Ok(json_response( - StatusCode::OK, - ConfigMetadataResponse { - name, - is_built_in: !sources.md.exists && !sources.json.exists, - scope: None, - sources, - }, - )), + match opencode_config::get_agent_sources(&name, Some(&working_directory)).await { + Ok(sources) => { + let scope = sources.md.scope.clone().map(|s| match s { + opencode_config::Scope::User => opencode_config::CommandScope::User, + opencode_config::Scope::Project => opencode_config::CommandScope::Project, + }); + Ok(json_response( + StatusCode::OK, + ConfigMetadataResponse { + name, + is_built_in: !sources.md.exists && !sources.json.exists, + scope, + sources, + }, + )) + } Err(err) => { error!("[desktop:config] Failed to read agent sources: {}", err); Ok(config_error_response( @@ -1216,8 +1225,17 @@ async fn handle_agent_route( Ok(data) => data, Err(resp) => return Ok(resp), }; + + // Extract scope from payload if present + let scope = payload.get("scope") + .and_then(|v| v.as_str()) + .and_then(|s| match s { + "project" => Some(opencode_config::AgentScope::Project), + "user" => Some(opencode_config::AgentScope::User), + _ => None, + }); - match opencode_config::create_agent(&name, &payload).await { + match opencode_config::create_agent(&name, &payload, Some(&working_directory), scope).await { Ok(()) => { if let Err(resp) = refresh_opencode_after_config_change(state, "agent creation").await @@ -1253,7 +1271,7 @@ async fn handle_agent_route( Err(resp) => return Ok(resp), }; - match opencode_config::update_agent(&name, &payload).await { + match opencode_config::update_agent(&name, &payload, Some(&working_directory)).await { Ok(()) => { if let Err(resp) = refresh_opencode_after_config_change(state, "agent update").await @@ -1283,7 +1301,7 @@ async fn handle_agent_route( } } } - Method::DELETE => match opencode_config::delete_agent(&name).await { + Method::DELETE => match opencode_config::delete_agent(&name, Some(&working_directory)).await { Ok(()) => { if let Err(resp) = refresh_opencode_after_config_change(state, "agent deletion").await @@ -1329,7 +1347,10 @@ async fn handle_command_route( Method::GET => { match opencode_config::get_command_sources(&name, Some(&working_directory)).await { Ok(sources) => { - let scope = sources.md.scope.clone(); + let scope = sources.md.scope.clone().map(|s| match s { + opencode_config::Scope::User => opencode_config::CommandScope::User, + opencode_config::Scope::Project => opencode_config::CommandScope::Project, + }); Ok(json_response( StatusCode::OK, ConfigMetadataResponse { diff --git a/packages/desktop/src-tauri/src/opencode_config.rs b/packages/desktop/src-tauri/src/opencode_config.rs index 2ac7e811..cfef9174 100644 --- a/packages/desktop/src-tauri/src/opencode_config.rs +++ b/packages/desktop/src-tauri/src/opencode_config.rs @@ -5,12 +5,21 @@ use regex::Regex; use serde::Serialize; use serde_json::{Map, Value}; use std::collections::HashMap; +use std::env; use std::path::{Path, PathBuf}; use tokio::fs; static PROMPT_FILE_PATTERN: Lazy = Lazy::new(|| Regex::new(r"(?i)^\{file:(.+)\}$").expect("valid regex")); +/// Agent scope types +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum AgentScope { + User, + Project, +} + /// Command scope types #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] @@ -19,6 +28,32 @@ pub enum CommandScope { Project, } +/// Generic scope enum for SourceInfo (agents and commands share same structure) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Scope { + User, + Project, +} + +impl From for Scope { + fn from(scope: AgentScope) -> Self { + match scope { + AgentScope::User => Scope::User, + AgentScope::Project => Scope::Project, + } + } +} + +impl From for Scope { + fn from(scope: CommandScope) -> Self { + match scope { + CommandScope::User => Scope::User, + CommandScope::Project => Scope::Project, + } + } +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SourceInfo { @@ -26,7 +61,7 @@ pub struct SourceInfo { pub path: Option, pub fields: Vec, #[serde(skip_serializing_if = "Option::is_none")] - pub scope: Option, + pub scope: Option, } #[derive(Debug, Serialize)] @@ -70,6 +105,246 @@ fn get_config_file() -> PathBuf { get_config_dir().join("opencode.json") } +/// Get project config file path +fn get_project_config_file(working_directory: &Path) -> PathBuf { + working_directory.join("opencode.json") +} + +/// Get custom config file path from OPENCODE_CONFIG env var +fn get_custom_config_file() -> Option { + env::var("OPENCODE_CONFIG").ok().map(PathBuf::from) +} + +struct ConfigPaths { + user: PathBuf, + project: Option, + custom: Option, +} + +struct ConfigLayers { + user: Value, + project: Value, + custom: Value, + #[allow(dead_code)] + merged: Value, + paths: ConfigPaths, +} + +fn get_config_paths(working_directory: Option<&Path>) -> ConfigPaths { + ConfigPaths { + user: get_config_file(), + project: working_directory.map(get_project_config_file), + custom: get_custom_config_file(), + } +} + +fn merge_values(base: &Value, overlay: &Value) -> Value { + match (base, overlay) { + (Value::Object(base_map), Value::Object(overlay_map)) => { + let mut merged = base_map.clone(); + for (key, value) in overlay_map.iter() { + let base_value = merged.get(key).unwrap_or(&Value::Null).clone(); + let merged_value = merge_values(&base_value, value); + merged.insert(key.clone(), merged_value); + } + Value::Object(merged) + } + _ => overlay.clone(), + } +} + +async fn read_config_file(path: &Path) -> Result { + if !path.exists() { + return Ok(Value::Object(serde_json::Map::new())); + } + + let content = fs::read_to_string(path).await?; + let normalized = strip_json_comments(&content).trim().to_string(); + + if normalized.is_empty() { + return Ok(Value::Object(serde_json::Map::new())); + } + + serde_json::from_str(&normalized).map_err(|e| anyhow!("Failed to parse config: {}", e)) +} + +async fn read_config_layers(working_directory: Option<&Path>) -> Result { + let paths = get_config_paths(working_directory); + let user = read_config_file(&paths.user).await?; + let project = if let Some(ref path) = paths.project { + read_config_file(path).await? + } else { + Value::Object(serde_json::Map::new()) + }; + let custom = if let Some(ref path) = paths.custom { + read_config_file(path).await? + } else { + Value::Object(serde_json::Map::new()) + }; + + let merged = merge_values(&merge_values(&user, &project), &custom); + + Ok(ConfigLayers { + user, + project, + custom, + merged, + paths, + }) +} + +struct JsonEntrySource { + exists: bool, + path: Option, + section: Option, +} + +fn get_json_entry_source(layers: &ConfigLayers, section_key: &str, entry_name: &str) -> JsonEntrySource { + if let Some(ref custom_path) = layers.paths.custom { + if let Some(section) = layers.custom.get(section_key).and_then(|v| v.as_object()) { + if let Some(value) = section.get(entry_name) { + return JsonEntrySource { + exists: true, + path: Some(custom_path.clone()), + section: Some(value.clone()), + }; + } + } + } + + if let Some(ref project_path) = layers.paths.project { + if let Some(section) = layers.project.get(section_key).and_then(|v| v.as_object()) { + if let Some(value) = section.get(entry_name) { + return JsonEntrySource { + exists: true, + path: Some(project_path.clone()), + section: Some(value.clone()), + }; + } + } + } + + if let Some(section) = layers.user.get(section_key).and_then(|v| v.as_object()) { + if let Some(value) = section.get(entry_name) { + return JsonEntrySource { + exists: true, + path: Some(layers.paths.user.clone()), + section: Some(value.clone()), + }; + } + } + + JsonEntrySource { + exists: false, + path: None, + section: None, + } +} + +fn get_json_write_target(layers: &ConfigLayers, preferred_scope: Option) -> PathBuf { + if let Some(ref custom_path) = layers.paths.custom { + return custom_path.clone(); + } + + if preferred_scope == Some(Scope::Project) { + if let Some(ref project_path) = layers.paths.project { + return project_path.clone(); + } + } + + if let Some(ref project_path) = layers.paths.project { + return project_path.clone(); + } + + layers.paths.user.clone() +} + +fn get_default_json_path(layers: &ConfigLayers) -> PathBuf { + if let Some(ref custom_path) = layers.paths.custom { + return custom_path.clone(); + } + if let Some(ref project_path) = layers.paths.project { + return project_path.clone(); + } + layers.paths.user.clone() +} + +fn get_config_for_path<'a>(layers: &'a mut ConfigLayers, target_path: &Path) -> &'a mut Value { + if let Some(ref custom_path) = layers.paths.custom { + if custom_path == target_path { + return &mut layers.custom; + } + } + if let Some(ref project_path) = layers.paths.project { + if project_path == target_path { + return &mut layers.project; + } + } + &mut layers.user +} + +// ============== AGENT SCOPE HELPERS ============== + +/// Get project-level agent directory path +fn get_project_agent_dir(working_directory: &Path) -> PathBuf { + working_directory.join(".opencode").join("agent") +} + +/// Get project-level agent path +fn get_project_agent_path(working_directory: &Path, agent_name: &str) -> PathBuf { + get_project_agent_dir(working_directory).join(format!("{}.md", agent_name)) +} + +/// Get user-level agent path +fn get_user_agent_path(agent_name: &str) -> PathBuf { + get_agent_dir().join(format!("{}.md", agent_name)) +} + +/// Ensure project agent directory exists +async fn ensure_project_agent_dir(working_directory: &Path) -> Result { + let project_agent_dir = get_project_agent_dir(working_directory); + fs::create_dir_all(&project_agent_dir).await?; + Ok(project_agent_dir) +} + +/// Determine agent scope based on where the .md file exists +pub fn get_agent_scope(agent_name: &str, working_directory: Option<&Path>) -> (Option, Option) { + if let Some(wd) = working_directory { + let project_path = get_project_agent_path(wd, agent_name); + if project_path.exists() { + return (Some(AgentScope::Project), Some(project_path)); + } + } + + let user_path = get_user_agent_path(agent_name); + if user_path.exists() { + return (Some(AgentScope::User), Some(user_path)); + } + + (None, None) +} + +/// Get the path where an agent should be written based on scope +fn get_agent_write_path(agent_name: &str, working_directory: Option<&Path>, requested_scope: Option) -> (AgentScope, PathBuf) { + // For updates: check existing location first (project takes precedence) + let (existing_scope, existing_path) = get_agent_scope(agent_name, working_directory); + if let Some(path) = existing_path { + return (existing_scope.unwrap(), path); + } + + // For new agents or built-in overrides: use requested scope or default to user + let scope = requested_scope.unwrap_or(AgentScope::User); + if scope == AgentScope::Project { + if let Some(wd) = working_directory { + return (AgentScope::Project, get_project_agent_path(wd, agent_name)); + } + } + + (AgentScope::User, get_user_agent_path(agent_name)) +} + +// ============== COMMAND SCOPE HELPERS ============== + /// Get project-level command directory path fn get_project_command_dir(working_directory: &Path) -> PathBuf { working_directory.join(".opencode").join("command") @@ -238,28 +513,14 @@ fn strip_json_comments(content: &str) -> String { result } -/// Read opencode.json configuration file -pub async fn read_config() -> Result { - let config_file = get_config_file(); - - if !config_file.exists() { - return Ok(Value::Object(serde_json::Map::new())); - } - - let content = fs::read_to_string(&config_file).await?; - let normalized = strip_json_comments(&content).trim().to_string(); - - if normalized.is_empty() { - return Ok(Value::Object(serde_json::Map::new())); - } - - serde_json::from_str(&normalized).map_err(|e| anyhow!("Failed to parse config: {}", e)) +/// Read merged opencode.json configuration files +#[allow(dead_code)] +pub async fn read_config(working_directory: Option<&Path>) -> Result { + Ok(read_config_layers(working_directory).await?.merged) } /// Write opencode.json configuration file with backup -pub async fn write_config(config: &Value) -> Result<()> { - let config_file = get_config_file(); - +pub async fn write_config_at(config: &Value, config_file: &Path) -> Result<()> { // Create/overwrite single backup before writing if config_file.exists() { let file_name = config_file @@ -273,12 +534,22 @@ pub async fn write_config(config: &Value) -> Result<()> { } let json_string = serde_json::to_string_pretty(config)?; - fs::write(&config_file, json_string).await?; - info!("Successfully wrote config file"); + if let Some(parent) = config_file.parent() { + fs::create_dir_all(parent).await?; + } + fs::write(config_file, json_string).await?; + info!("Successfully wrote config file: {}", config_file.display()); Ok(()) } +/// Write user-level opencode.json configuration file +#[allow(dead_code)] +pub async fn write_config(config: &Value) -> Result<()> { + let config_file = get_config_file(); + write_config_at(config, &config_file).await +} + /// Markdown file data #[derive(Debug)] struct MdData { @@ -335,110 +606,207 @@ async fn write_md_file( } /// Get information about where agent configuration is stored -pub async fn get_agent_sources(agent_name: &str) -> Result { +pub async fn get_agent_sources(agent_name: &str, working_directory: Option<&Path>) -> Result { ensure_dirs().await?; - let md_path = get_agent_dir().join(format!("{}.md", agent_name)); - let md_exists = md_path.exists(); + // Check project level first (takes precedence) + let project_path = working_directory.map(|wd| get_project_agent_path(wd, agent_name)); + let project_exists = project_path.as_ref().map(|p| p.exists()).unwrap_or(false); + + // Then check user level + let user_path = get_user_agent_path(agent_name); + let user_exists = user_path.exists(); + + // Determine which md file to use (project takes precedence) + let (md_path, md_exists, md_scope) = if project_exists { + (project_path.clone(), true, Some(Scope::Project)) + } else if user_exists { + (Some(user_path.clone()), true, Some(Scope::User)) + } else { + (None, false, None) + }; let mut md_fields = Vec::new(); if md_exists { - let md_data = parse_md_file(&md_path).await?; - md_fields.extend(md_data.frontmatter.keys().cloned()); - if !md_data.body.trim().is_empty() { - md_fields.push("prompt".to_string()); + if let Some(ref path) = md_path { + let md_data = parse_md_file(path).await?; + md_fields.extend(md_data.frontmatter.keys().cloned()); + if !md_data.body.trim().is_empty() { + md_fields.push("prompt".to_string()); + } } } - let config = read_config().await?; - let json_section = config - .get("agent") - .and_then(|v| v.as_object()) - .and_then(|obj| obj.get(agent_name)); + let layers = read_config_layers(working_directory).await?; + let json_source = get_json_entry_source(&layers, "agent", agent_name); + let json_section = json_source.section.as_ref(); let json_fields = json_section .and_then(|value| value.as_object()) .map(|obj| obj.keys().cloned().collect::>()) .unwrap_or_default(); + let json_path_buf = json_source + .path + .unwrap_or_else(|| get_default_json_path(&layers)); + let json_path = json_path_buf.display().to_string(); + let json_scope = if layers.paths.project.as_ref() == Some(&json_path_buf) { + Some(Scope::Project) + } else { + Some(Scope::User) + }; + let sources = ConfigSources { md: SourceInfo { exists: md_exists, - path: md_exists.then(|| md_path.display().to_string()), + path: md_path.map(|p| p.display().to_string()), fields: md_fields, - scope: None, // Agents don't have project/user scope distinction yet + scope: md_scope, }, json: SourceInfo { - exists: json_section.is_some(), - path: Some(get_config_file().display().to_string()), + exists: json_source.exists, + path: Some(json_path), fields: json_fields, - scope: None, + scope: if json_source.exists { json_scope } else { None }, }, - project_md: None, - user_md: None, + project_md: Some(MdLocationInfo { + exists: project_exists, + path: project_path.map(|p| p.display().to_string()), + }), + user_md: Some(MdLocationInfo { + exists: user_exists, + path: Some(user_path.display().to_string()), + }), }; Ok(sources) } /// Create new agent as .md file -pub async fn create_agent(agent_name: &str, config: &HashMap) -> Result<()> { +pub async fn create_agent( + agent_name: &str, + config: &HashMap, + working_directory: Option<&Path>, + scope: Option +) -> Result<()> { ensure_dirs().await?; - let md_path = get_agent_dir().join(format!("{}.md", agent_name)); - - // Check if agent already exists - if md_path.exists() { - return Err(anyhow!("Agent {} already exists as .md file", agent_name)); - } - - let existing_config = read_config().await?; - if let Some(agents) = existing_config.get("agent").and_then(|v| v.as_object()) { - if agents.contains_key(agent_name) { + // Check if agent already exists at either level + if let Some(wd) = working_directory { + let project_path = get_project_agent_path(wd, agent_name); + if project_path.exists() { return Err(anyhow!( - "Agent {} already exists in opencode.json", + "Agent {} already exists as project-level .md file", agent_name )); } } + + let user_path = get_user_agent_path(agent_name); + if user_path.exists() { + return Err(anyhow!( + "Agent {} already exists as user-level .md file", + agent_name + )); + } - // Extract prompt from config + let layers = read_config_layers(working_directory).await?; + let json_source = get_json_entry_source(&layers, "agent", agent_name); + if json_source.exists { + return Err(anyhow!( + "Agent {} already exists in opencode.json", + agent_name + )); + } + + // Determine target path based on requested scope + let (target_scope, target_path) = if scope == Some(AgentScope::Project) { + if let Some(wd) = working_directory { + ensure_project_agent_dir(wd).await?; + (AgentScope::Project, get_project_agent_path(wd, agent_name)) + } else { + (AgentScope::User, user_path) + } + } else { + (AgentScope::User, user_path) + }; + + // Extract prompt and scope from config - scope is only used for path determination, not written to file let mut frontmatter = config.clone(); let prompt = frontmatter .remove("prompt") .and_then(|v| v.as_str().map(|s| s.to_string())) .unwrap_or_default(); + frontmatter.remove("scope"); // Remove scope - it's not a valid agent field // Write .md file - write_md_file(&md_path, &frontmatter, &prompt).await?; - info!("Created new agent: {}", agent_name); + write_md_file(&target_path, &frontmatter, &prompt).await?; + info!("Created new agent: {} (scope: {:?}, path: {})", agent_name, target_scope, target_path.display()); Ok(()) } /// Update existing agent using field-level logic -pub async fn update_agent(agent_name: &str, updates: &HashMap) -> Result<()> { +pub async fn update_agent( + agent_name: &str, + updates: &HashMap, + working_directory: Option<&Path>, +) -> Result<()> { ensure_dirs().await?; - let md_path = get_agent_dir().join(format!("{}.md", agent_name)); + // Determine correct path: project level takes precedence + let (scope, md_path) = get_agent_write_path(agent_name, working_directory, None); let md_exists = md_path.exists(); - - let mut md_data = if md_exists { - Some(parse_md_file(&md_path).await?) - } else { - None - }; - - let mut config = read_config().await?; - let mut existing_agent = config - .get("agent") - .and_then(|v| v.as_object()) - .and_then(|obj| obj.get(agent_name)) + + // Check if agent exists in opencode.json across all config layers + let mut layers = read_config_layers(working_directory).await?; + let json_source = get_json_entry_source(&layers, "agent", agent_name); + let mut existing_agent = json_source + .section + .as_ref() .and_then(|v| v.as_object()) .cloned() .unwrap_or_else(Map::new); let had_json_fields = !existing_agent.is_empty(); + let preferred_scope = if working_directory.is_some() { + Some(Scope::Project) + } else { + Some(Scope::User) + }; + let json_target_path = if json_source.exists { + json_source + .path + .clone() + .unwrap_or_else(|| get_json_write_target(&layers, preferred_scope)) + } else { + get_json_write_target(&layers, preferred_scope) + }; + let config = get_config_for_path(&mut layers, &json_target_path); + + // Determine if we should create a new md file: + // Only for built-in agents (no md file AND no json config) + let is_builtin_override = !md_exists && !had_json_fields; + + let target_path = if !md_exists && is_builtin_override { + // Built-in agent override - create at user level + get_user_agent_path(agent_name) + } else { + md_path.clone() + }; + + let mut md_data = if md_exists { + Some(parse_md_file(&md_path).await?) + } else if is_builtin_override { + // Only create new md data for built-in overrides + Some(MdData { frontmatter: HashMap::new(), body: String::new() }) + } else { + None + }; + + // Only create new md if it's a built-in override + let creating_new_md = is_builtin_override; + let mut md_modified = false; let mut json_modified = false; @@ -462,11 +830,12 @@ pub async fn update_agent(agent_name: &str, updates: &HashMap) -> if field == "prompt" { let normalized_value = value.as_str().unwrap_or("").to_string(); - if md_exists { + if md_exists || creating_new_md { if let Some(ref mut data) = md_data { data.body = normalized_value.clone(); md_modified = true; } + continue; } else if let Some(prompt_ref) = existing_agent.get("prompt").and_then(|v| v.as_str()) { if is_prompt_file_reference(prompt_ref) { @@ -482,10 +851,9 @@ pub async fn update_agent(agent_name: &str, updates: &HashMap) -> } } - // Write prompt directly to JSON entry (file ref or inline string) + // For JSON-only agents, store prompt inline in JSON existing_agent.insert("prompt".to_string(), Value::String(normalized_value)); json_modified = true; - continue; } @@ -496,30 +864,26 @@ pub async fn update_agent(agent_name: &str, updates: &HashMap) -> .unwrap_or(false); let in_json = existing_agent.contains_key(field); - if in_md { + // JSON takes precedence over md, so update JSON first if field exists there + if in_json { + // Update in opencode.json (takes precedence) + existing_agent.insert(field.clone(), value.clone()); + json_modified = true; + } else if in_md || creating_new_md { // Update in .md frontmatter if let Some(ref mut data) = md_data { data.frontmatter.insert(field.clone(), value.clone()); md_modified = true; } - } else if in_json { - // Update in opencode.json while preserving existing fields - existing_agent.insert(field.clone(), value.clone()); - json_modified = true; } else { - // Field not defined - apply priority rules - if md_exists && !existing_agent.is_empty() { - // Both exist → add to opencode.json (higher priority) without dropping other keys - existing_agent.insert(field.clone(), value.clone()); - json_modified = true; - } else if md_exists { - // Only .md exists → add to frontmatter + // New field - add to the appropriate location based on agent source + if (md_exists || creating_new_md) && md_data.is_some() { if let Some(ref mut data) = md_data { data.frontmatter.insert(field.clone(), value.clone()); md_modified = true; } } else { - // Only JSON or built-in → add/create section in opencode.json + // JSON-only agent or has JSON fields - add to JSON existing_agent.insert(field.clone(), value.clone()); json_modified = true; } @@ -529,7 +893,7 @@ pub async fn update_agent(agent_name: &str, updates: &HashMap) -> // Write changes if md_modified { if let Some(data) = md_data { - write_md_file(&md_path, &data.frontmatter, &data.body).await?; + write_md_file(&target_path, &data.frontmatter, &data.body).await?; } } @@ -542,7 +906,7 @@ pub async fn update_agent(agent_name: &str, updates: &HashMap) -> if json_modified { if !config.is_object() { - config = Value::Object(Map::new()); + *config = Value::Object(Map::new()); } let config_obj = config.as_object_mut().unwrap(); @@ -557,43 +921,66 @@ pub async fn update_agent(agent_name: &str, updates: &HashMap) -> let agents_obj = agents_entry.as_object_mut().unwrap(); agents_obj.insert(agent_name.to_string(), Value::Object(existing_agent)); - write_config(&config).await?; + write_config_at(config, &json_target_path).await?; } info!( - "Updated agent: {} (md: {}, json: {})", - agent_name, md_modified, json_modified + "Updated agent: {} (scope: {:?}, md: {}, json: {})", + agent_name, scope, md_modified, json_modified ); Ok(()) } /// Delete agent configuration -pub async fn delete_agent(agent_name: &str) -> Result<()> { - let md_path = get_agent_dir().join(format!("{}.md", agent_name)); +pub async fn delete_agent(agent_name: &str, working_directory: Option<&Path>) -> Result<()> { let mut deleted = false; - // 1. Delete .md file if exists - if md_path.exists() { - fs::remove_file(&md_path).await?; - info!("Deleted agent .md file: {}", md_path.display()); - deleted = true; - } - - // 2. Remove section from opencode.json if exists - let mut config = read_config().await?; - if let Some(agents) = config.get_mut("agent").and_then(|v| v.as_object_mut()) { - if agents.remove(agent_name).is_some() { - write_config(&config).await?; - info!("Removed agent from opencode.json: {}", agent_name); + // 1. Check project level first (takes precedence) + if let Some(wd) = working_directory { + let project_path = get_project_agent_path(wd, agent_name); + if project_path.exists() { + fs::remove_file(&project_path).await?; + info!("Deleted project-level agent .md file: {}", project_path.display()); deleted = true; } } - // 3. If nothing was deleted (built-in agent), disable it + // 2. Check user level + let user_path = get_user_agent_path(agent_name); + if user_path.exists() { + fs::remove_file(&user_path).await?; + info!("Deleted user-level agent .md file: {}", user_path.display()); + deleted = true; + } + + // 3. Remove section from opencode.json if exists (highest precedence entry only) + let mut layers = read_config_layers(working_directory).await?; + let json_source = get_json_entry_source(&layers, "agent", agent_name); + if json_source.exists { + if let Some(json_path) = json_source.path.clone() { + let config = get_config_for_path(&mut layers, &json_path); + if let Some(agents) = config.get_mut("agent").and_then(|v| v.as_object_mut()) { + if agents.remove(agent_name).is_some() { + write_config_at(config, &json_path).await?; + info!("Removed agent from opencode.json: {}", agent_name); + deleted = true; + } + } + } + } + + // 4. If nothing was deleted (built-in agent), disable it in highest-precedence config if !deleted { + let preferred_scope = if working_directory.is_some() { + Some(Scope::Project) + } else { + Some(Scope::User) + }; + let json_path = get_json_write_target(&layers, preferred_scope); + let config = get_config_for_path(&mut layers, &json_path); if !config.is_object() { - config = Value::Object(serde_json::Map::new()); + *config = Value::Object(serde_json::Map::new()); } let config_obj = config.as_object_mut().unwrap(); if !config_obj.contains_key("agent") { @@ -609,7 +996,7 @@ pub async fn delete_agent(agent_name: &str) -> Result<()> { .as_object_mut() .unwrap() .insert(agent_name.to_string(), Value::Object(disable_obj)); - write_config(&config).await?; + write_config_at(config, &json_path).await?; info!("Disabled built-in agent: {}", agent_name); } @@ -630,9 +1017,9 @@ pub async fn get_command_sources(command_name: &str, working_directory: Option<& // Determine which md file to use (project takes precedence) let (md_path, md_exists, md_scope) = if project_exists { - (project_path.clone(), true, Some(CommandScope::Project)) + (project_path.clone(), true, Some(Scope::Project)) } else if user_exists { - (Some(user_path.clone()), true, Some(CommandScope::User)) + (Some(user_path.clone()), true, Some(Scope::User)) } else { (None, false, None) }; @@ -648,17 +1035,25 @@ pub async fn get_command_sources(command_name: &str, working_directory: Option<& } } - let config = read_config().await?; - let json_section = config - .get("command") - .and_then(|v| v.as_object()) - .and_then(|obj| obj.get(command_name)); + let layers = read_config_layers(working_directory).await?; + let json_source = get_json_entry_source(&layers, "command", command_name); + let json_section = json_source.section.as_ref(); let json_fields = json_section .and_then(|value| value.as_object()) .map(|obj| obj.keys().cloned().collect::>()) .unwrap_or_default(); + let json_path_buf = json_source + .path + .unwrap_or_else(|| get_default_json_path(&layers)); + let json_path = json_path_buf.display().to_string(); + let json_scope = if layers.paths.project.as_ref() == Some(&json_path_buf) { + Some(Scope::Project) + } else { + Some(Scope::User) + }; + let sources = ConfigSources { md: SourceInfo { exists: md_exists, @@ -667,10 +1062,10 @@ pub async fn get_command_sources(command_name: &str, working_directory: Option<& scope: md_scope, }, json: SourceInfo { - exists: json_section.is_some(), - path: Some(get_config_file().display().to_string()), + exists: json_source.exists, + path: Some(json_path), fields: json_fields, - scope: None, + scope: if json_source.exists { json_scope } else { None }, }, project_md: Some(MdLocationInfo { exists: project_exists, @@ -713,14 +1108,13 @@ pub async fn create_command( )); } - let existing_config = read_config().await?; - if let Some(commands) = existing_config.get("command").and_then(|v| v.as_object()) { - if commands.contains_key(command_name) { - return Err(anyhow!( - "Command {} already exists in opencode.json", - command_name - )); - } + let layers = read_config_layers(working_directory).await?; + let json_source = get_json_entry_source(&layers, "command", command_name); + if json_source.exists { + return Err(anyhow!( + "Command {} already exists in opencode.json", + command_name + )); } // Determine target path based on requested scope @@ -761,10 +1155,37 @@ pub async fn update_command( // Determine correct path: project level takes precedence let (scope, md_path) = get_command_write_path(command_name, working_directory, None); let md_exists = md_path.exists(); - - // If no existing md file, we need to create one (for built-in command overrides) - let target_path = if !md_exists { - // No existing md file - this is a built-in override, create at user level + + let mut layers = read_config_layers(working_directory).await?; + let json_source = get_json_entry_source(&layers, "command", command_name); + let mut existing_command = json_source + .section + .as_ref() + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_else(Map::new); + let had_json_fields = !existing_command.is_empty(); + + let preferred_scope = if working_directory.is_some() { + Some(Scope::Project) + } else { + Some(Scope::User) + }; + let json_target_path = if json_source.exists { + json_source + .path + .clone() + .unwrap_or_else(|| get_json_write_target(&layers, preferred_scope)) + } else { + get_json_write_target(&layers, preferred_scope) + }; + let config = get_config_for_path(&mut layers, &json_target_path); + + // Only create a new md file for built-in overrides (no md + no json) + let is_builtin_override = !md_exists && !had_json_fields; + + let target_path = if !md_exists && is_builtin_override { + // Built-in command override - create at user level get_user_command_path(command_name) } else { md_path.clone() @@ -772,21 +1193,13 @@ pub async fn update_command( let mut md_data = if md_exists { Some(parse_md_file(&md_path).await?) - } else { + } else if is_builtin_override { Some(MdData { frontmatter: HashMap::new(), body: String::new() }) + } else { + None }; - let creating_new_md = !md_exists; - - let mut config = read_config().await?; - let mut existing_command = config - .get("command") - .and_then(|v| v.as_object()) - .and_then(|obj| obj.get(command_name)) - .and_then(|v| v.as_object()) - .cloned() - .unwrap_or_else(Map::new); - let had_json_fields = !existing_command.is_empty(); + let creating_new_md = is_builtin_override; let mut md_modified = false; let mut json_modified = false; @@ -831,11 +1244,9 @@ pub async fn update_command( } } - // Create new md file for the update - if let Some(ref mut data) = md_data { - data.body = normalized_value; - md_modified = true; - } + // For JSON-only commands, store template inline in JSON + existing_command.insert("template".to_string(), Value::String(normalized_value)); + json_modified = true; continue; } @@ -846,24 +1257,26 @@ pub async fn update_command( .unwrap_or(false); let in_json = existing_command.contains_key(field); - if in_md || creating_new_md { + // JSON takes precedence over md, so update JSON first if field exists there + if in_json { + // Update in opencode.json while preserving existing fields + existing_command.insert(field.clone(), value.clone()); + json_modified = true; + } else if in_md || creating_new_md { // Update in .md frontmatter if let Some(ref mut data) = md_data { data.frontmatter.insert(field.clone(), value.clone()); md_modified = true; } - } else if in_json { - // Update in opencode.json while preserving existing fields - existing_command.insert(field.clone(), value.clone()); - json_modified = true; } else { - // New field - add to md if it exists or we're creating one - if md_exists || creating_new_md { + // New field - add to the appropriate location based on command source + if (md_exists || creating_new_md) && md_data.is_some() { if let Some(ref mut data) = md_data { data.frontmatter.insert(field.clone(), value.clone()); md_modified = true; } } else { + // JSON-only command or built-in - add to JSON existing_command.insert(field.clone(), value.clone()); json_modified = true; } @@ -886,7 +1299,7 @@ pub async fn update_command( if json_modified { if !config.is_object() { - config = Value::Object(Map::new()); + *config = Value::Object(Map::new()); } let config_obj = config.as_object_mut().unwrap(); @@ -901,7 +1314,7 @@ pub async fn update_command( let commands_obj = commands_entry.as_object_mut().unwrap(); commands_obj.insert(command_name.to_string(), Value::Object(existing_command)); - write_config(&config).await?; + write_config_at(config, &json_target_path).await?; } info!( @@ -934,13 +1347,19 @@ pub async fn delete_command(command_name: &str, working_directory: Option<&Path> deleted = true; } - // 3. Remove section from opencode.json if exists - let mut config = read_config().await?; - if let Some(commands) = config.get_mut("command").and_then(|v| v.as_object_mut()) { - if commands.remove(command_name).is_some() { - write_config(&config).await?; - info!("Removed command from opencode.json: {}", command_name); - deleted = true; + // 3. Remove section from opencode.json if exists (highest precedence entry only) + let mut layers = read_config_layers(working_directory).await?; + let json_source = get_json_entry_source(&layers, "command", command_name); + if json_source.exists { + if let Some(json_path) = json_source.path.clone() { + let config = get_config_for_path(&mut layers, &json_path); + if let Some(commands) = config.get_mut("command").and_then(|v| v.as_object_mut()) { + if commands.remove(command_name).is_some() { + write_config_at(config, &json_path).await?; + info!("Removed command from opencode.json: {}", command_name); + deleted = true; + } + } } } diff --git a/packages/ui/src/components/sections/agents/AgentsPage.tsx b/packages/ui/src/components/sections/agents/AgentsPage.tsx index fc383b7d..6baa0843 100644 --- a/packages/ui/src/components/sections/agents/AgentsPage.tsx +++ b/packages/ui/src/components/sections/agents/AgentsPage.tsx @@ -4,646 +4,931 @@ import { ButtonSmall } from '@/components/ui/button-small'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { toast } from 'sonner'; -import { useAgentsStore, type AgentConfig } from '@/stores/useAgentsStore'; +import { useAgentsStore, type AgentConfig, type AgentScope } from '@/stores/useAgentsStore'; import { useConfigStore } from '@/stores/useConfigStore'; -import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiInformationLine, RiRobot2Line, RiRobotLine, RiSaveLine, RiSubtractLine } from '@remixicon/react'; +import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiInformationLine, RiRobot2Line, RiRobotLine, RiSaveLine, RiSubtractLine, RiUser3Line, RiFolderLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { ModelSelector } from './ModelSelector'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { useAvailableTools } from '@/hooks/useAvailableTools'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from '@/components/ui/select'; export const AgentsPage: React.FC = () => { - const { selectedAgentName, getAgentByName, createAgent, updateAgent, agents } = useAgentsStore(); - useConfigStore(); - const { tools: availableTools } = useAvailableTools(); + const { selectedAgentName, getAgentByName, createAgent, updateAgent, agents, agentDraft, setAgentDraft } = useAgentsStore(); + useConfigStore(); + const { tools: availableTools } = useAvailableTools(); - const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName) : null; - const isNewAgent = selectedAgentName && !selectedAgent; + const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName) : null; + const isNewAgent = Boolean(agentDraft && agentDraft.name === selectedAgentName && !selectedAgent); - const [name, setName] = React.useState(''); - const [description, setDescription] = React.useState(''); - const [mode, setMode] = React.useState<'primary' | 'subagent' | 'all'>('subagent'); - const [model, setModel] = React.useState(''); - const [temperature, setTemperature] = React.useState(undefined); - const [topP, setTopP] = React.useState(undefined); - const [prompt, setPrompt] = React.useState(''); - const [tools, setTools] = React.useState>({}); - const [editPermission, setEditPermission] = React.useState<'allow' | 'ask' | 'deny' | 'full'>('allow'); - const [webfetchPermission, setWebfetchPermission] = React.useState<'allow' | 'ask' | 'deny'>('allow'); - const [bashPermission, setBashPermission] = React.useState<'allow' | 'ask' | 'deny'>('ask'); - const [isSaving, setIsSaving] = React.useState(false); - - React.useEffect(() => { - if (isNewAgent) { - - setName(selectedAgentName || ''); - setDescription(''); - setMode('subagent'); - setModel(''); - setTemperature(undefined); - setTopP(undefined); - setPrompt(''); - setTools({}); - setEditPermission('allow'); - setWebfetchPermission('allow'); - setBashPermission('ask'); - } else if (selectedAgent) { - - setName(selectedAgent.name); - setDescription(selectedAgent.description || ''); - setMode(selectedAgent.mode || 'subagent'); - - if (selectedAgent.model?.providerID && selectedAgent.model?.modelID) { - setModel(`${selectedAgent.model.providerID}/${selectedAgent.model.modelID}`); - } else { - setModel(''); - } - - setTemperature(selectedAgent.temperature); - setTopP(selectedAgent.topP); - setPrompt(selectedAgent.prompt || ''); - setTools(selectedAgent.tools || {}); - - if (selectedAgent.permission) { - const editMode = selectedAgent.permission.edit; - if (editMode === 'allow' || editMode === 'ask' || editMode === 'deny' || editMode === 'full') { - setEditPermission(editMode); - } - if (selectedAgent.permission.webfetch) { - setWebfetchPermission(selectedAgent.permission.webfetch); - } - if (typeof selectedAgent.permission.bash === 'string') { - setBashPermission(selectedAgent.permission.bash as 'allow' | 'ask' | 'deny'); - } - } + const [draftName, setDraftName] = React.useState(''); + const [draftScope, setDraftScope] = React.useState('user'); + const [description, setDescription] = React.useState(''); + const [mode, setMode] = React.useState<'primary' | 'subagent' | 'all'>('subagent'); + const [model, setModel] = React.useState(''); + const [temperature, setTemperature] = React.useState(undefined); + const [topP, setTopP] = React.useState(undefined); + const [prompt, setPrompt] = React.useState(''); + const [tools, setTools] = React.useState>({}); + const [editPermission, setEditPermission] = React.useState<'allow' | 'ask' | 'deny'>('allow'); + const [webfetchPermission, setWebfetchPermission] = React.useState<'allow' | 'ask' | 'deny'>('allow'); + const [bashPermission, setBashPermission] = React.useState<'allow' | 'ask' | 'deny'>('allow'); + const [skillPermission, setSkillPermission] = React.useState<'allow' | 'ask' | 'deny'>('allow'); + const [doomLoopPermission, setDoomLoopPermission] = React.useState<'allow' | 'ask' | 'deny'>('ask'); + const [externalDirectoryPermission, setExternalDirectoryPermission] = React.useState<'allow' | 'ask' | 'deny'>('ask'); + const [isSaving, setIsSaving] = React.useState(false); + React.useEffect(() => { + if (isNewAgent && agentDraft) { + // Prefill from draft (for new or duplicated agents) + setDraftName(agentDraft.name || ''); + setDraftScope(agentDraft.scope || 'user'); + setDescription(agentDraft.description || ''); + setMode(agentDraft.mode || 'subagent'); + setModel(agentDraft.model || ''); + setTemperature(agentDraft.temperature); + setTopP(agentDraft.top_p); + setPrompt(agentDraft.prompt || ''); + + const draftTools = agentDraft.tools || {}; + setTools(draftTools); + + // Determine permission based on explicit permission first, then tool state + const resolvePermission = ( + explicitPerm: string | undefined, + toolDisabled: boolean, + fallback: 'allow' | 'ask' | 'deny' + ): 'allow' | 'ask' | 'deny' => { + if (explicitPerm === 'allow' || explicitPerm === 'ask' || explicitPerm === 'deny') { + return explicitPerm; } - }, [selectedAgent, isNewAgent, selectedAgentName, agents]); - - const handleSave = async () => { - - if (!name.trim()) { - toast.error('Agent name is required'); - return; + if (toolDisabled) { + return 'deny'; } + return fallback; + }; - setIsSaving(true); - - try { - const trimmedModel = model.trim(); - const config: AgentConfig = { - name: name.trim(), - description: description.trim() || undefined, - mode, - model: trimmedModel === '' ? null : trimmedModel, - temperature, - top_p: topP, - prompt: prompt.trim() || undefined, - tools: Object.keys(tools).length > 0 ? tools : undefined, - permission: { - edit: editPermission, - webfetch: webfetchPermission, - bash: bashPermission, - }, - }; - - let success: boolean; - if (isNewAgent) { - success = await createAgent(config); - } else { - success = await updateAgent(name, config); - } - - if (success) { - toast.success(isNewAgent ? 'Agent created successfully' : 'Agent updated successfully'); - } else { - toast.error(isNewAgent ? 'Failed to create agent' : 'Failed to update agent'); - } - } catch (error) { - console.error('Error saving agent:', error); - toast.error('An error occurred while saving'); - } finally { - setIsSaving(false); + const permission = (agentDraft.permission || {}) as { + edit?: unknown; + bash?: unknown; + skill?: unknown; + webfetch?: unknown; + doom_loop?: unknown; + external_directory?: unknown; + }; + + const getPermissionValue = (value: unknown): 'allow' | 'ask' | 'deny' | undefined => { + if (value === 'allow' || value === 'ask' || value === 'deny') { + return value; } - }; + if (value && typeof value === 'object' && !Array.isArray(value)) { + const wildcard = (value as Record)['*']; + if (wildcard === 'allow' || wildcard === 'ask' || wildcard === 'deny') { + return wildcard; + } + } + return undefined; + }; - const toggleTool = (tool: string) => { - setTools((prev) => ({ - ...prev, - [tool]: !prev[tool], - })); - }; + const editToolDisabled = draftTools.edit === false || draftTools.write === false || draftTools.patch === false; + setEditPermission(resolvePermission(getPermissionValue(permission.edit), editToolDisabled, 'allow')); + + setBashPermission(resolvePermission(getPermissionValue(permission.bash), draftTools.bash === false, 'allow')); + + setWebfetchPermission(resolvePermission(getPermissionValue(permission.webfetch), draftTools.webfetch === false, 'allow')); - const toggleAllTools = (enabled: boolean) => { - const allTools: Record = {}; - availableTools.forEach((tool: string) => { - allTools[tool] = enabled; - }); - setTools(allTools); - }; + setSkillPermission(resolvePermission(getPermissionValue(permission.skill), draftTools.skill === false, 'allow')); + setDoomLoopPermission(resolvePermission(getPermissionValue(permission.doom_loop), false, 'ask')); + setExternalDirectoryPermission(resolvePermission(getPermissionValue(permission.external_directory), false, 'ask')); + } else if (selectedAgent) { + setDescription(selectedAgent.description || ''); + setMode(selectedAgent.mode || 'subagent'); - if (!selectedAgentName) { - return ( -
-
- -

Select an agent from the sidebar

-

or create a new one

-
-
- ); + if (selectedAgent.model?.providerID && selectedAgent.model?.modelID) { + setModel(`${selectedAgent.model.providerID}/${selectedAgent.model.modelID}`); + } else { + setModel(''); + } + + setTemperature(selectedAgent.temperature); + setTopP(selectedAgent.topP); + setPrompt(selectedAgent.prompt || ''); + + const agentTools = selectedAgent.tools || {}; + setTools(agentTools); + + // Determine permission based on explicit permission first, then tool state + const resolvePermission = ( + explicitPerm: string | undefined, + toolDisabled: boolean, + fallback: 'allow' | 'ask' | 'deny' + ): 'allow' | 'ask' | 'deny' => { + if (explicitPerm === 'allow' || explicitPerm === 'ask' || explicitPerm === 'deny') { + return explicitPerm; + } + if (toolDisabled) { + return 'deny'; + } + return fallback; + }; + + const permission = (selectedAgent.permission || {}) as { + edit?: unknown; + bash?: unknown; + skill?: unknown; + webfetch?: unknown; + doom_loop?: unknown; + external_directory?: unknown; + }; + + const getPermissionValue = (value: unknown): 'allow' | 'ask' | 'deny' | undefined => { + if (value === 'allow' || value === 'ask' || value === 'deny') { + return value; + } + if (value && typeof value === 'object' && !Array.isArray(value)) { + const wildcard = (value as Record)['*']; + if (wildcard === 'allow' || wildcard === 'ask' || wildcard === 'deny') { + return wildcard; + } + } + return undefined; + }; + + // For edit permission, check 'edit', 'write', and 'patch' tools + // If ANY of these tools is explicitly disabled (false), edit permission is 'deny' + const editToolDisabled = agentTools.edit === false || agentTools.write === false || agentTools.patch === false; + setEditPermission(resolvePermission(getPermissionValue(permission.edit), editToolDisabled, 'allow')); + + // For bash permission + setBashPermission(resolvePermission(getPermissionValue(permission.bash), agentTools.bash === false, 'allow')); + + // For webfetch permission + setWebfetchPermission(resolvePermission(getPermissionValue(permission.webfetch), agentTools.webfetch === false, 'allow')); + + setSkillPermission(resolvePermission(getPermissionValue(permission.skill), agentTools.skill === false, 'allow')); + setDoomLoopPermission(resolvePermission(getPermissionValue(permission.doom_loop), false, 'ask')); + setExternalDirectoryPermission(resolvePermission(getPermissionValue(permission.external_directory), false, 'ask')); + } + }, [selectedAgent, isNewAgent, selectedAgentName, agents, agentDraft]); + + const handleSave = async () => { + const agentName = isNewAgent ? draftName.trim().replace(/\s+/g, '-') : selectedAgentName?.trim(); + + if (!agentName) { + toast.error('Agent name is required'); + return; } + // Check for duplicate name when creating new agent + if (isNewAgent && agents.some((a) => a.name === agentName)) { + toast.error('An agent with this name already exists'); + return; + } + + setIsSaving(true); + + try { + const trimmedModel = model.trim(); + const config: AgentConfig = { + name: agentName, + description: description.trim() || undefined, + mode, + model: trimmedModel === '' ? null : trimmedModel, + temperature, + top_p: topP, + prompt: prompt.trim() || undefined, + tools: Object.keys(tools).length > 0 ? tools : undefined, + permission: { + edit: editPermission, + webfetch: webfetchPermission, + bash: bashPermission, + skill: skillPermission, + doom_loop: doomLoopPermission, + external_directory: externalDirectoryPermission, + }, + scope: isNewAgent ? draftScope : undefined, + }; + + let success: boolean; + if (isNewAgent) { + success = await createAgent(config); + if (success) { + setAgentDraft(null); // Clear draft after successful creation + } + } else { + success = await updateAgent(agentName, config); + } + + if (success) { + toast.success(isNewAgent ? 'Agent created successfully' : 'Agent updated successfully'); + } else { + toast.error(isNewAgent ? 'Failed to create agent' : 'Failed to update agent'); + } + } catch (error) { + console.error('Error saving agent:', error); + toast.error('An error occurred while saving'); + } finally { + setIsSaving(false); + } + }; + + const toggleTool = (tool: string) => { + setTools((prev) => ({ + ...prev, + [tool]: !prev[tool], + })); + }; + + const toggleAllTools = (enabled: boolean) => { + const allTools: Record = {}; + availableTools.forEach((tool: string) => { + allTools[tool] = enabled; + }); + setTools(allTools); + }; + + if (!selectedAgentName) { return ( - - {} -
-

- {isNewAgent ? 'New Agent' : name} -

-
- - {} -
-
-

Basic Information

-

- Configure agent identity and behavior mode -

-
- -
- - setName(e.target.value)} - placeholder="my-agent" - disabled={!isNewAgent} - /> -
- -
- -