diff --git a/CHANGELOG.md b/CHANGELOG.md index 81e0d40d..b906f134 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. ## [1.3.7] - 2025-12-28 diff --git a/bun.lock b/bun.lock index 5ca48194..850cde97 100644 --- a/bun.lock +++ b/bun.lock @@ -74,7 +74,7 @@ }, "packages/desktop": { "name": "@openchamber/desktop", - "version": "1.3.5", + "version": "1.3.7", "dependencies": { "@openchamber/ui": "workspace:*", "@tauri-apps/plugin-notification": "^2.3.3", @@ -97,7 +97,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.3.5", + "version": "1.3.7", "dependencies": { "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.1.1", @@ -165,7 +165,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.3.5", + "version": "1.3.7", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "^1.0.185", @@ -184,7 +184,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.3.5", + "version": "1.3.7", "bin": { "openchamber": "./bin/cli.js", }, @@ -517,7 +517,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.203", "", {}, "sha512-Chr/eWlb5jB+aluf70t9H0zv0tkb+otVnnkZQg3xrB7dtHJc6SxqsixAjTGnND6v3mURwchYb0UdcnGM7dOcng=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.207", "", {}, "sha512-/t4C3+PXZ5NpN7+HwKZmVwJsJmQ1eyub0eQcV8GZh7fuPo2wCxKHCD/lzk82CobW+3jVp2TSBKeDBBTtVvEFeQ=="], "@pierre/diffs": ["@pierre/diffs@1.0.2", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/transformers": "3.19.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.19.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-RkFSDD5X/U+8QjyilPViYGJfmJNWXR17zTL8zw48+DcVC1Ujbh6I1edyuRnFfgRzpft05x2DSCkz2cjoIAxPvQ=="], @@ -1627,7 +1627,7 @@ "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], - "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="], @@ -2369,7 +2369,7 @@ "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], - "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + "ts-api-utils": ["ts-api-utils@2.3.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg=="], "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], @@ -2819,6 +2819,8 @@ "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], "electron/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 5f0d26b1..da05b23a 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -236,6 +236,7 @@ struct ConfigErrorResponse { struct ConfigMetadataResponse { name: String, sources: opencode_config::ConfigSources, + scope: Option, is_built_in: bool, } @@ -1197,6 +1198,7 @@ async fn handle_agent_route( ConfigMetadataResponse { name, is_built_in: !sources.md.exists && !sources.json.exists, + scope: None, sources, }, )), @@ -1320,17 +1322,24 @@ async fn handle_command_route( req: Request, name: String, ) -> Result, StatusCode> { + // Get working directory for project-level command detection + let working_directory = state.opencode.get_working_directory(); + match method { Method::GET => { - match opencode_config::get_command_sources(&name).await { - Ok(sources) => Ok(json_response( - StatusCode::OK, - ConfigMetadataResponse { - name, - is_built_in: !sources.md.exists && !sources.json.exists, - sources, - }, - )), + match opencode_config::get_command_sources(&name, Some(&working_directory)).await { + Ok(sources) => { + let scope = sources.md.scope.clone(); + 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 command sources: {}", err); Ok(config_error_response( @@ -1345,8 +1354,17 @@ async fn handle_command_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::CommandScope::Project), + "user" => Some(opencode_config::CommandScope::User), + _ => None, + }); - match opencode_config::create_command(&name, &payload).await { + match opencode_config::create_command(&name, &payload, Some(&working_directory), scope).await { Ok(()) => { if let Err(resp) = refresh_opencode_after_config_change(state, "command creation").await @@ -1382,7 +1400,7 @@ async fn handle_command_route( Err(resp) => return Ok(resp), }; - match opencode_config::update_command(&name, &payload).await { + match opencode_config::update_command(&name, &payload, Some(&working_directory)).await { Ok(()) => { if let Err(resp) = refresh_opencode_after_config_change(state, "command update").await @@ -1412,7 +1430,7 @@ async fn handle_command_route( } } } - Method::DELETE => match opencode_config::delete_command(&name).await { + Method::DELETE => match opencode_config::delete_command(&name, Some(&working_directory)).await { Ok(()) => { if let Err(resp) = refresh_opencode_after_config_change(state, "command deletion").await diff --git a/packages/desktop/src-tauri/src/opencode_config.rs b/packages/desktop/src-tauri/src/opencode_config.rs index a3b3208b..2ac7e811 100644 --- a/packages/desktop/src-tauri/src/opencode_config.rs +++ b/packages/desktop/src-tauri/src/opencode_config.rs @@ -11,12 +11,29 @@ use tokio::fs; static PROMPT_FILE_PATTERN: Lazy = Lazy::new(|| Regex::new(r"(?i)^\{file:(.+)\}$").expect("valid regex")); +/// Command scope types +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum CommandScope { + User, + Project, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SourceInfo { pub exists: bool, pub path: Option, pub fields: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MdLocationInfo { + pub exists: bool, + pub path: Option, } #[derive(Debug, Serialize)] @@ -24,6 +41,10 @@ pub struct SourceInfo { pub struct ConfigSources { pub md: SourceInfo, pub json: SourceInfo, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_md: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user_md: Option, } /// Get OpenCode config directory path @@ -39,7 +60,7 @@ fn get_agent_dir() -> PathBuf { get_config_dir().join("agent") } -/// Get command directory path +/// Get user-level command directory path fn get_command_dir() -> PathBuf { get_config_dir().join("command") } @@ -49,6 +70,64 @@ fn get_config_file() -> PathBuf { get_config_dir().join("opencode.json") } +/// Get project-level command directory path +fn get_project_command_dir(working_directory: &Path) -> PathBuf { + working_directory.join(".opencode").join("command") +} + +/// Get project-level command path +fn get_project_command_path(working_directory: &Path, command_name: &str) -> PathBuf { + get_project_command_dir(working_directory).join(format!("{}.md", command_name)) +} + +/// Get user-level command path +fn get_user_command_path(command_name: &str) -> PathBuf { + get_command_dir().join(format!("{}.md", command_name)) +} + +/// Ensure project command directory exists +async fn ensure_project_command_dir(working_directory: &Path) -> Result { + let project_command_dir = get_project_command_dir(working_directory); + fs::create_dir_all(&project_command_dir).await?; + Ok(project_command_dir) +} + +/// Determine command scope based on where the .md file exists +pub fn get_command_scope(command_name: &str, working_directory: Option<&Path>) -> (Option, Option) { + if let Some(wd) = working_directory { + let project_path = get_project_command_path(wd, command_name); + if project_path.exists() { + return (Some(CommandScope::Project), Some(project_path)); + } + } + + let user_path = get_user_command_path(command_name); + if user_path.exists() { + return (Some(CommandScope::User), Some(user_path)); + } + + (None, None) +} + +/// Get the path where a command should be written based on scope +fn get_command_write_path(command_name: &str, working_directory: Option<&Path>, requested_scope: Option) -> (CommandScope, PathBuf) { + // For updates: check existing location first (project takes precedence) + let (existing_scope, existing_path) = get_command_scope(command_name, working_directory); + if let Some(path) = existing_path { + return (existing_scope.unwrap(), path); + } + + // For new commands or built-in overrides: use requested scope or default to user + let scope = requested_scope.unwrap_or(CommandScope::User); + if scope == CommandScope::Project { + if let Some(wd) = working_directory { + return (CommandScope::Project, get_project_command_path(wd, command_name)); + } + } + + (CommandScope::User, get_user_command_path(command_name)) +} + /// Ensure required directories exist async fn ensure_dirs() -> Result<()> { let config_dir = get_config_dir(); @@ -240,7 +319,13 @@ async fn write_md_file( frontmatter: &HashMap, body: &str, ) -> Result<()> { - let yaml_str = serde_yaml::to_string(frontmatter)?; + // Filter out null values - OpenCode expects keys to be omitted rather than set to null + let cleaned_frontmatter: HashMap = frontmatter + .iter() + .filter(|(_, v)| !v.is_null()) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let yaml_str = serde_yaml::to_string(&cleaned_frontmatter)?; let content = format!("---\n{}---\n\n{}", yaml_str, body); fs::write(file_path, content).await?; @@ -281,12 +366,16 @@ pub async fn get_agent_sources(agent_name: &str) -> Result { exists: md_exists, path: md_exists.then(|| md_path.display().to_string()), fields: md_fields, + scope: None, // Agents don't have project/user scope distinction yet }, json: SourceInfo { exists: json_section.is_some(), path: Some(get_config_file().display().to_string()), fields: json_fields, + scope: None, }, + project_md: None, + user_md: None, }; Ok(sources) @@ -528,18 +617,34 @@ pub async fn delete_agent(agent_name: &str) -> Result<()> { } /// Get information about where command configuration is stored -pub async fn get_command_sources(command_name: &str) -> Result { +pub async fn get_command_sources(command_name: &str, working_directory: Option<&Path>) -> Result { ensure_dirs().await?; - let md_path = get_command_dir().join(format!("{}.md", command_name)); - let md_exists = md_path.exists(); + // Check project level first (takes precedence) + let project_path = working_directory.map(|wd| get_project_command_path(wd, command_name)); + let project_exists = project_path.as_ref().map(|p| p.exists()).unwrap_or(false); + + // Then check user level + let user_path = get_user_command_path(command_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(CommandScope::Project)) + } else if user_exists { + (Some(user_path.clone()), true, Some(CommandScope::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("template".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("template".to_string()); + } } } @@ -557,29 +662,53 @@ pub async fn get_command_sources(command_name: &str) -> Result { 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: md_scope, }, json: SourceInfo { exists: json_section.is_some(), path: Some(get_config_file().display().to_string()), fields: json_fields, + scope: 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 command as .md file -pub async fn create_command(command_name: &str, config: &HashMap) -> Result<()> { +pub async fn create_command( + command_name: &str, + config: &HashMap, + working_directory: Option<&Path>, + scope: Option +) -> Result<()> { ensure_dirs().await?; - let md_path = get_command_dir().join(format!("{}.md", command_name)); - - // Check if command already exists - if md_path.exists() { + // Check if command already exists at either level + if let Some(wd) = working_directory { + let project_path = get_project_command_path(wd, command_name); + if project_path.exists() { + return Err(anyhow!( + "Command {} already exists as project-level .md file", + command_name + )); + } + } + + let user_path = get_user_command_path(command_name); + if user_path.exists() { return Err(anyhow!( - "Command {} already exists as .md file", + "Command {} already exists as user-level .md file", command_name )); } @@ -594,16 +723,29 @@ pub async fn create_command(command_name: &str, config: &HashMap) } } - // Extract template from config + // Determine target path based on requested scope + let (target_scope, target_path) = if scope == Some(CommandScope::Project) { + if let Some(wd) = working_directory { + ensure_project_command_dir(wd).await?; + (CommandScope::Project, get_project_command_path(wd, command_name)) + } else { + (CommandScope::User, user_path) + } + } else { + (CommandScope::User, user_path) + }; + + // Extract template and scope from config - scope is only used for path determination, not written to file let mut frontmatter = config.clone(); let template = frontmatter .remove("template") .and_then(|v| v.as_str().map(|s| s.to_string())) .unwrap_or_default(); + frontmatter.remove("scope"); // Remove scope - it's not a valid command field // Write .md file - write_md_file(&md_path, &frontmatter, &template).await?; - info!("Created new command: {}", command_name); + write_md_file(&target_path, &frontmatter, &template).await?; + info!("Created new command: {} (scope: {:?}, path: {})", command_name, target_scope, target_path.display()); Ok(()) } @@ -612,17 +754,29 @@ pub async fn create_command(command_name: &str, config: &HashMap) pub async fn update_command( command_name: &str, updates: &HashMap, + working_directory: Option<&Path>, ) -> Result<()> { ensure_dirs().await?; - let md_path = get_command_dir().join(format!("{}.md", command_name)); + // 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 + get_user_command_path(command_name) + } else { + md_path.clone() + }; let mut md_data = if md_exists { Some(parse_md_file(&md_path).await?) } else { - None + Some(MdData { frontmatter: HashMap::new(), body: String::new() }) }; + + let creating_new_md = !md_exists; let mut config = read_config().await?; let mut existing_command = config @@ -657,7 +811,7 @@ pub async fn update_command( if field == "template" { 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; @@ -677,10 +831,11 @@ pub async fn update_command( } } - // Write template directly to JSON entry (file ref or inline string) - existing_command.insert("template".to_string(), Value::String(normalized_value)); - json_modified = true; - + // Create new md file for the update + if let Some(ref mut data) = md_data { + data.body = normalized_value; + md_modified = true; + } continue; } @@ -691,7 +846,7 @@ pub async fn update_command( .unwrap_or(false); let in_json = existing_command.contains_key(field); - if in_md { + 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()); @@ -702,19 +857,13 @@ pub async fn update_command( existing_command.insert(field.clone(), value.clone()); json_modified = true; } else { - // Field not defined - apply priority rules - if md_exists && !existing_command.is_empty() { - // Both exist → add to opencode.json (higher priority) - existing_command.insert(field.clone(), value.clone()); - json_modified = true; - } else if md_exists { - // Only .md exists → add to frontmatter + // New field - add to md if it exists or we're creating one + if md_exists || creating_new_md { 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 existing_command.insert(field.clone(), value.clone()); json_modified = true; } @@ -724,7 +873,7 @@ pub async fn update_command( // 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?; } } @@ -756,26 +905,36 @@ pub async fn update_command( } info!( - "Updated command: {} (md: {}, json: {})", - command_name, md_modified, json_modified + "Updated command: {} (scope: {:?}, md: {}, json: {})", + command_name, scope, md_modified, json_modified ); Ok(()) } /// Delete command configuration -pub async fn delete_command(command_name: &str) -> Result<()> { - let md_path = get_command_dir().join(format!("{}.md", command_name)); +pub async fn delete_command(command_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 command .md file: {}", md_path.display()); + // 1. Check project level first (takes precedence) + if let Some(wd) = working_directory { + let project_path = get_project_command_path(wd, command_name); + if project_path.exists() { + fs::remove_file(&project_path).await?; + info!("Deleted project-level command .md file: {}", project_path.display()); + deleted = true; + } + } + + // 2. Check user level + let user_path = get_user_command_path(command_name); + if user_path.exists() { + fs::remove_file(&user_path).await?; + info!("Deleted user-level command .md file: {}", user_path.display()); deleted = true; } - // 2. Remove section from opencode.json if exists + // 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() { @@ -785,7 +944,7 @@ pub async fn delete_command(command_name: &str) -> Result<()> { } } - // 3. If nothing was deleted, throw error + // 4. If nothing was deleted, throw error if !deleted { return Err(anyhow!("Command \"{}\" not found", command_name)); } diff --git a/packages/ui/src/components/sections/commands/CommandsPage.tsx b/packages/ui/src/components/sections/commands/CommandsPage.tsx index d0d83ce7..3e73062d 100644 --- a/packages/ui/src/components/sections/commands/CommandsPage.tsx +++ b/packages/ui/src/components/sections/commands/CommandsPage.tsx @@ -3,21 +3,28 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { toast } from 'sonner'; -import { useCommandsStore, type CommandConfig } from '@/stores/useCommandsStore'; -import { RiCheckLine, RiInformationLine, RiSaveLine, RiTerminalBoxLine } from '@remixicon/react'; +import { useCommandsStore, type CommandConfig, type CommandScope } from '@/stores/useCommandsStore'; +import { RiCheckLine, RiInformationLine, RiSaveLine, RiTerminalBoxLine, RiUser3Line, RiFolderLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { ModelSelector } from '../agents/ModelSelector'; import { AgentSelector } from './AgentSelector'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from '@/components/ui/select'; export const CommandsPage: React.FC = () => { - const { selectedCommandName, getCommandByName, createCommand, updateCommand, commands } = useCommandsStore(); + const { selectedCommandName, getCommandByName, createCommand, updateCommand, commands, commandDraft, setCommandDraft } = useCommandsStore(); const selectedCommand = selectedCommandName ? getCommandByName(selectedCommandName) : null; - const isNewCommand = selectedCommandName && !selectedCommand; + const isNewCommand = Boolean(commandDraft && commandDraft.name === selectedCommandName && !selectedCommand); - const [name, setName] = React.useState(''); + const [draftName, setDraftName] = React.useState(''); + const [draftScope, setDraftScope] = React.useState('user'); const [description, setDescription] = React.useState(''); const [agent, setAgent] = React.useState(''); const [model, setModel] = React.useState(''); @@ -26,27 +33,28 @@ export const CommandsPage: React.FC = () => { const [isSaving, setIsSaving] = React.useState(false); React.useEffect(() => { - if (isNewCommand) { - - setName(selectedCommandName || ''); - setDescription(''); - setAgent(''); - setModel(''); - setTemplate(''); - setSubtask(false); + if (isNewCommand && commandDraft) { + // Prefill from draft (for new or duplicated commands) + setDraftName(commandDraft.name || ''); + setDraftScope(commandDraft.scope || 'user'); + setDescription(commandDraft.description || ''); + setAgent(commandDraft.agent || ''); + setModel(commandDraft.model || ''); + setTemplate(commandDraft.template || ''); + setSubtask(commandDraft.subtask || false); } else if (selectedCommand) { - - setName(selectedCommand.name); setDescription(selectedCommand.description || ''); setAgent(selectedCommand.agent || ''); setModel(selectedCommand.model || ''); setTemplate(selectedCommand.template || ''); setSubtask(selectedCommand.subtask || false); } - }, [selectedCommand, isNewCommand, selectedCommandName, commands]); + }, [selectedCommand, isNewCommand, selectedCommandName, commands, commandDraft]); const handleSave = async () => { - if (!name.trim()) { + const commandName = isNewCommand ? draftName.trim().replace(/\s+/g, '-') : selectedCommandName?.trim(); + + if (!commandName) { toast.error('Command name is required'); return; } @@ -56,6 +64,12 @@ export const CommandsPage: React.FC = () => { return; } + // Check for duplicate name when creating new command + if (isNewCommand && commands.some((cmd) => cmd.name === commandName)) { + toast.error('A command with this name already exists'); + return; + } + setIsSaving(true); try { @@ -63,19 +77,23 @@ export const CommandsPage: React.FC = () => { const trimmedModel = model.trim(); const trimmedTemplate = template.trim(); const config: CommandConfig = { - name: name.trim(), + name: commandName, description: description.trim() || undefined, agent: trimmedAgent === '' ? null : trimmedAgent, model: trimmedModel === '' ? null : trimmedModel, template: trimmedTemplate, subtask, + scope: isNewCommand ? draftScope : undefined, }; let success: boolean; if (isNewCommand) { success = await createCommand(config); + if (success) { + setCommandDraft(null); // Clear draft after successful creation + } } else { - success = await updateCommand(name, config); + success = await updateCommand(commandName, config); } if (success) { @@ -108,7 +126,7 @@ export const CommandsPage: React.FC = () => { {}

- {isNewCommand ? 'New Command' : name} + {isNewCommand ? 'New Command' : `/${selectedCommandName}`}

@@ -121,20 +139,54 @@ export const CommandsPage: React.FC = () => {

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

- Used with slash (/) prefix in chat -

-
+ {isNewCommand && ( +
+ +
+
+ / + setDraftName(e.target.value)} + placeholder="command-name" + className="flex-1 text-foreground placeholder:text-muted-foreground" + /> +
+ +
+
+ )}