mirror of
https://github.com/getAsterisk/claudia.git
synced 2025-07-07 18:15:00 +00:00
feat: implement custom slash commands system
Adds a comprehensive slash command system that allows users to create and manage custom commands: - Backend implementation in Rust for discovering, loading, and managing slash commands - Support for both user-level (~/.claude/commands/) and project-level (.claude/commands/) commands - YAML frontmatter support for command metadata (description, allowed-tools) - Command namespacing with directory structure (e.g., /namespace:command) - Detection of special features: bash commands (\!), file references (@), and arguments ($ARGUMENTS) Frontend enhancements: - SlashCommandPicker component with autocomplete UI and keyboard navigation - SlashCommandsManager component for CRUD operations on commands - Integration with FloatingPromptInput to trigger picker on "/" input - Visual indicators for command features (bash, files, arguments) - Grouped display by namespace with search functionality API additions: - slash_commands_list: Discover all available commands - slash_command_get: Retrieve specific command by ID - slash_command_save: Create or update commands - slash_command_delete: Remove commands This implementation provides a foundation for users to create reusable command templates and workflows. Commands are stored as markdown files with optional YAML frontmatter for metadata. Addresses #127 and #134
This commit is contained in:
parent
985de02404
commit
8af922944b
12 changed files with 1753 additions and 4 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -32,3 +32,6 @@ temp_lib/
|
|||
AGENTS.md
|
||||
CLAUDE.md
|
||||
*_TASK.md
|
||||
|
||||
# Claude project-specific files
|
||||
.claude/
|
||||
|
|
20
src-tauri/Cargo.lock
generated
20
src-tauri/Cargo.lock
generated
|
@ -635,6 +635,7 @@ dependencies = [
|
|||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
|
@ -4243,6 +4244,19 @@ dependencies = [
|
|||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.9.0",
|
||||
"itoa 1.0.15",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialize-to-javascript"
|
||||
version = "0.1.1"
|
||||
|
@ -5480,6 +5494,12 @@ version = "1.12.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
|
|
@ -48,6 +48,7 @@ sha2 = "0.10"
|
|||
zstd = "0.13"
|
||||
uuid = { version = "1.6", features = ["v4", "serde"] }
|
||||
walkdir = "2"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
|
|
|
@ -3,3 +3,4 @@ pub mod claude;
|
|||
pub mod mcp;
|
||||
pub mod usage;
|
||||
pub mod storage;
|
||||
pub mod slash_commands;
|
||||
|
|
405
src-tauri/src/commands/slash_commands.rs
Normal file
405
src-tauri/src/commands/slash_commands.rs
Normal file
|
@ -0,0 +1,405 @@
|
|||
use anyhow::{Context, Result};
|
||||
use dirs;
|
||||
use log::{debug, error, info};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Represents a custom slash command
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlashCommand {
|
||||
/// Unique identifier for the command (derived from file path)
|
||||
pub id: String,
|
||||
/// Command name (without prefix)
|
||||
pub name: String,
|
||||
/// Full command with prefix (e.g., "/project:optimize")
|
||||
pub full_command: String,
|
||||
/// Command scope: "project" or "user"
|
||||
pub scope: String,
|
||||
/// Optional namespace (e.g., "frontend" in "/project:frontend:component")
|
||||
pub namespace: Option<String>,
|
||||
/// Path to the markdown file
|
||||
pub file_path: String,
|
||||
/// Command content (markdown body)
|
||||
pub content: String,
|
||||
/// Optional description from frontmatter
|
||||
pub description: Option<String>,
|
||||
/// Allowed tools from frontmatter
|
||||
pub allowed_tools: Vec<String>,
|
||||
/// Whether the command has bash commands (!)
|
||||
pub has_bash_commands: bool,
|
||||
/// Whether the command has file references (@)
|
||||
pub has_file_references: bool,
|
||||
/// Whether the command uses $ARGUMENTS placeholder
|
||||
pub accepts_arguments: bool,
|
||||
}
|
||||
|
||||
/// YAML frontmatter structure
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CommandFrontmatter {
|
||||
#[serde(rename = "allowed-tools")]
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a markdown file with optional YAML frontmatter
|
||||
fn parse_markdown_with_frontmatter(content: &str) -> Result<(Option<CommandFrontmatter>, String)> {
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
|
||||
// Check if the file starts with YAML frontmatter
|
||||
if lines.is_empty() || lines[0] != "---" {
|
||||
// No frontmatter
|
||||
return Ok((None, content.to_string()));
|
||||
}
|
||||
|
||||
// Find the end of frontmatter
|
||||
let mut frontmatter_end = None;
|
||||
for (i, line) in lines.iter().enumerate().skip(1) {
|
||||
if *line == "---" {
|
||||
frontmatter_end = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(end) = frontmatter_end {
|
||||
// Extract frontmatter
|
||||
let frontmatter_content = lines[1..end].join("\n");
|
||||
let body_content = lines[(end + 1)..].join("\n");
|
||||
|
||||
// Parse YAML
|
||||
match serde_yaml::from_str::<CommandFrontmatter>(&frontmatter_content) {
|
||||
Ok(frontmatter) => Ok((Some(frontmatter), body_content)),
|
||||
Err(e) => {
|
||||
debug!("Failed to parse frontmatter: {}", e);
|
||||
// Return full content if frontmatter parsing fails
|
||||
Ok((None, content.to_string()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Malformed frontmatter, treat as regular content
|
||||
Ok((None, content.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract command name and namespace from file path
|
||||
fn extract_command_info(file_path: &Path, base_path: &Path) -> Result<(String, Option<String>)> {
|
||||
let relative_path = file_path
|
||||
.strip_prefix(base_path)
|
||||
.context("Failed to get relative path")?;
|
||||
|
||||
// Remove .md extension
|
||||
let path_without_ext = relative_path
|
||||
.with_extension("")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
// Split into components
|
||||
let components: Vec<&str> = path_without_ext.split('/').collect();
|
||||
|
||||
if components.is_empty() {
|
||||
return Err(anyhow::anyhow!("Invalid command path"));
|
||||
}
|
||||
|
||||
if components.len() == 1 {
|
||||
// No namespace
|
||||
Ok((components[0].to_string(), None))
|
||||
} else {
|
||||
// Last component is the command name, rest is namespace
|
||||
let command_name = components.last().unwrap().to_string();
|
||||
let namespace = components[..components.len() - 1].join(":");
|
||||
Ok((command_name, Some(namespace)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a single command from a markdown file
|
||||
fn load_command_from_file(
|
||||
file_path: &Path,
|
||||
base_path: &Path,
|
||||
scope: &str,
|
||||
) -> Result<SlashCommand> {
|
||||
debug!("Loading command from: {:?}", file_path);
|
||||
|
||||
// Read file content
|
||||
let content = fs::read_to_string(file_path)
|
||||
.context("Failed to read command file")?;
|
||||
|
||||
// Parse frontmatter
|
||||
let (frontmatter, body) = parse_markdown_with_frontmatter(&content)?;
|
||||
|
||||
// Extract command info
|
||||
let (name, namespace) = extract_command_info(file_path, base_path)?;
|
||||
|
||||
// Build full command (no scope prefix, just /command or /namespace:command)
|
||||
let full_command = match &namespace {
|
||||
Some(ns) => format!("/{ns}:{name}"),
|
||||
None => format!("/{name}"),
|
||||
};
|
||||
|
||||
// Generate unique ID
|
||||
let id = format!("{}-{}", scope, file_path.to_string_lossy().replace('/', "-"));
|
||||
|
||||
// Check for special content
|
||||
let has_bash_commands = body.contains("!`");
|
||||
let has_file_references = body.contains('@');
|
||||
let accepts_arguments = body.contains("$ARGUMENTS");
|
||||
|
||||
// Extract metadata from frontmatter
|
||||
let (description, allowed_tools) = if let Some(fm) = frontmatter {
|
||||
(fm.description, fm.allowed_tools.unwrap_or_default())
|
||||
} else {
|
||||
(None, Vec::new())
|
||||
};
|
||||
|
||||
Ok(SlashCommand {
|
||||
id,
|
||||
name,
|
||||
full_command,
|
||||
scope: scope.to_string(),
|
||||
namespace,
|
||||
file_path: file_path.to_string_lossy().to_string(),
|
||||
content: body,
|
||||
description,
|
||||
allowed_tools,
|
||||
has_bash_commands,
|
||||
has_file_references,
|
||||
accepts_arguments,
|
||||
})
|
||||
}
|
||||
|
||||
/// Recursively find all markdown files in a directory
|
||||
fn find_markdown_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
// Skip hidden files/directories
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if path.is_dir() {
|
||||
find_markdown_files(&path, files)?;
|
||||
} else if path.is_file() {
|
||||
if let Some(ext) = path.extension() {
|
||||
if ext == "md" {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discover all custom slash commands
|
||||
#[tauri::command]
|
||||
pub async fn slash_commands_list(
|
||||
project_path: Option<String>,
|
||||
) -> Result<Vec<SlashCommand>, String> {
|
||||
info!("Discovering slash commands");
|
||||
let mut commands = Vec::new();
|
||||
|
||||
// Load project commands if project path is provided
|
||||
if let Some(proj_path) = project_path {
|
||||
let project_commands_dir = PathBuf::from(&proj_path).join(".claude").join("commands");
|
||||
if project_commands_dir.exists() {
|
||||
debug!("Scanning project commands at: {:?}", project_commands_dir);
|
||||
|
||||
let mut md_files = Vec::new();
|
||||
if let Err(e) = find_markdown_files(&project_commands_dir, &mut md_files) {
|
||||
error!("Failed to find project command files: {}", e);
|
||||
} else {
|
||||
for file_path in md_files {
|
||||
match load_command_from_file(&file_path, &project_commands_dir, "project") {
|
||||
Ok(cmd) => {
|
||||
debug!("Loaded project command: {}", cmd.full_command);
|
||||
commands.push(cmd);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to load command from {:?}: {}", file_path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load user commands
|
||||
if let Some(home_dir) = dirs::home_dir() {
|
||||
let user_commands_dir = home_dir.join(".claude").join("commands");
|
||||
if user_commands_dir.exists() {
|
||||
debug!("Scanning user commands at: {:?}", user_commands_dir);
|
||||
|
||||
let mut md_files = Vec::new();
|
||||
if let Err(e) = find_markdown_files(&user_commands_dir, &mut md_files) {
|
||||
error!("Failed to find user command files: {}", e);
|
||||
} else {
|
||||
for file_path in md_files {
|
||||
match load_command_from_file(&file_path, &user_commands_dir, "user") {
|
||||
Ok(cmd) => {
|
||||
debug!("Loaded user command: {}", cmd.full_command);
|
||||
commands.push(cmd);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to load command from {:?}: {}", file_path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Found {} slash commands", commands.len());
|
||||
Ok(commands)
|
||||
}
|
||||
|
||||
/// Get a single slash command by ID
|
||||
#[tauri::command]
|
||||
pub async fn slash_command_get(command_id: String) -> Result<SlashCommand, String> {
|
||||
debug!("Getting slash command: {}", command_id);
|
||||
|
||||
// Parse the ID to determine scope and reconstruct file path
|
||||
let parts: Vec<&str> = command_id.split('-').collect();
|
||||
if parts.len() < 2 {
|
||||
return Err("Invalid command ID".to_string());
|
||||
}
|
||||
|
||||
// The actual implementation would need to reconstruct the path and reload the command
|
||||
// For now, we'll list all commands and find the matching one
|
||||
let commands = slash_commands_list(None).await?;
|
||||
|
||||
commands
|
||||
.into_iter()
|
||||
.find(|cmd| cmd.id == command_id)
|
||||
.ok_or_else(|| format!("Command not found: {}", command_id))
|
||||
}
|
||||
|
||||
/// Create or update a slash command
|
||||
#[tauri::command]
|
||||
pub async fn slash_command_save(
|
||||
scope: String,
|
||||
name: String,
|
||||
namespace: Option<String>,
|
||||
content: String,
|
||||
description: Option<String>,
|
||||
allowed_tools: Vec<String>,
|
||||
project_path: Option<String>,
|
||||
) -> Result<SlashCommand, String> {
|
||||
info!("Saving slash command: {} in scope: {}", name, scope);
|
||||
|
||||
// Validate inputs
|
||||
if name.is_empty() {
|
||||
return Err("Command name cannot be empty".to_string());
|
||||
}
|
||||
|
||||
if !["project", "user"].contains(&scope.as_str()) {
|
||||
return Err("Invalid scope. Must be 'project' or 'user'".to_string());
|
||||
}
|
||||
|
||||
// Determine base directory
|
||||
let base_dir = if scope == "project" {
|
||||
if let Some(proj_path) = project_path {
|
||||
PathBuf::from(proj_path).join(".claude").join("commands")
|
||||
} else {
|
||||
return Err("Project path required for project scope".to_string());
|
||||
}
|
||||
} else {
|
||||
dirs::home_dir()
|
||||
.ok_or_else(|| "Could not find home directory".to_string())?
|
||||
.join(".claude")
|
||||
.join("commands")
|
||||
};
|
||||
|
||||
// Build file path
|
||||
let mut file_path = base_dir.clone();
|
||||
if let Some(ns) = &namespace {
|
||||
for component in ns.split(':') {
|
||||
file_path = file_path.join(component);
|
||||
}
|
||||
}
|
||||
|
||||
// Create directories if needed
|
||||
fs::create_dir_all(&file_path)
|
||||
.map_err(|e| format!("Failed to create directories: {}", e))?;
|
||||
|
||||
// Add filename
|
||||
file_path = file_path.join(format!("{}.md", name));
|
||||
|
||||
// Build content with frontmatter
|
||||
let mut full_content = String::new();
|
||||
|
||||
// Add frontmatter if we have metadata
|
||||
if description.is_some() || !allowed_tools.is_empty() {
|
||||
full_content.push_str("---\n");
|
||||
|
||||
if let Some(desc) = &description {
|
||||
full_content.push_str(&format!("description: {}\n", desc));
|
||||
}
|
||||
|
||||
if !allowed_tools.is_empty() {
|
||||
full_content.push_str("allowed-tools:\n");
|
||||
for tool in &allowed_tools {
|
||||
full_content.push_str(&format!(" - {}\n", tool));
|
||||
}
|
||||
}
|
||||
|
||||
full_content.push_str("---\n\n");
|
||||
}
|
||||
|
||||
full_content.push_str(&content);
|
||||
|
||||
// Write file
|
||||
fs::write(&file_path, &full_content)
|
||||
.map_err(|e| format!("Failed to write command file: {}", e))?;
|
||||
|
||||
// Load and return the saved command
|
||||
load_command_from_file(&file_path, &base_dir, &scope)
|
||||
.map_err(|e| format!("Failed to load saved command: {}", e))
|
||||
}
|
||||
|
||||
/// Delete a slash command
|
||||
#[tauri::command]
|
||||
pub async fn slash_command_delete(command_id: String) -> Result<String, String> {
|
||||
info!("Deleting slash command: {}", command_id);
|
||||
|
||||
// Get the command to find its file path
|
||||
let command = slash_command_get(command_id.clone()).await?;
|
||||
|
||||
// Delete the file
|
||||
fs::remove_file(&command.file_path)
|
||||
.map_err(|e| format!("Failed to delete command file: {}", e))?;
|
||||
|
||||
// Clean up empty directories
|
||||
if let Some(parent) = Path::new(&command.file_path).parent() {
|
||||
let _ = remove_empty_dirs(parent);
|
||||
}
|
||||
|
||||
Ok(format!("Deleted command: {}", command.full_command))
|
||||
}
|
||||
|
||||
/// Remove empty directories recursively
|
||||
fn remove_empty_dirs(dir: &Path) -> Result<()> {
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if directory is empty
|
||||
let is_empty = fs::read_dir(dir)?.next().is_none();
|
||||
|
||||
if is_empty {
|
||||
fs::remove_dir(dir)?;
|
||||
|
||||
// Try to remove parent if it's also empty
|
||||
if let Some(parent) = dir.parent() {
|
||||
let _ = remove_empty_dirs(parent);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -189,6 +189,12 @@ fn main() {
|
|||
storage_insert_row,
|
||||
storage_execute_sql,
|
||||
storage_reset_database,
|
||||
|
||||
// Slash Commands
|
||||
commands::slash_commands::slash_commands_list,
|
||||
commands::slash_commands::slash_command_get,
|
||||
commands::slash_commands::slash_command_save,
|
||||
commands::slash_commands::slash_command_delete,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
|
@ -16,8 +16,9 @@ import { Popover } from "@/components/ui/popover";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { FilePicker } from "./FilePicker";
|
||||
import { SlashCommandPicker } from "./SlashCommandPicker";
|
||||
import { ImagePreview } from "./ImagePreview";
|
||||
import { type FileEntry } from "@/lib/api";
|
||||
import { type FileEntry, type SlashCommand } from "@/lib/api";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
|
||||
interface FloatingPromptInputProps {
|
||||
|
@ -180,6 +181,8 @@ const FloatingPromptInputInner = (
|
|||
const [thinkingModePickerOpen, setThinkingModePickerOpen] = useState(false);
|
||||
const [showFilePicker, setShowFilePicker] = useState(false);
|
||||
const [filePickerQuery, setFilePickerQuery] = useState("");
|
||||
const [showSlashCommandPicker, setShowSlashCommandPicker] = useState(false);
|
||||
const [slashCommandQuery, setSlashCommandQuery] = useState("");
|
||||
const [cursorPosition, setCursorPosition] = useState(0);
|
||||
const [embeddedImages, setEmbeddedImages] = useState<string[]>([]);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
|
@ -400,6 +403,20 @@ const FloatingPromptInputInner = (
|
|||
const newValue = e.target.value;
|
||||
const newCursorPosition = e.target.selectionStart || 0;
|
||||
|
||||
// Check if / was just typed at the beginning of input or after whitespace
|
||||
if (newValue.length > prompt.length && newValue[newCursorPosition - 1] === '/') {
|
||||
// Check if it's at the start or after whitespace
|
||||
const isStartOfCommand = newCursorPosition === 1 ||
|
||||
(newCursorPosition > 1 && /\s/.test(newValue[newCursorPosition - 2]));
|
||||
|
||||
if (isStartOfCommand) {
|
||||
console.log('[FloatingPromptInput] / detected for slash command');
|
||||
setShowSlashCommandPicker(true);
|
||||
setSlashCommandQuery("");
|
||||
setCursorPosition(newCursorPosition);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if @ was just typed
|
||||
if (projectPath?.trim() && newValue.length > prompt.length && newValue[newCursorPosition - 1] === '@') {
|
||||
console.log('[FloatingPromptInput] @ detected, projectPath:', projectPath);
|
||||
|
@ -408,6 +425,31 @@ const FloatingPromptInputInner = (
|
|||
setCursorPosition(newCursorPosition);
|
||||
}
|
||||
|
||||
// Check if we're typing after / (for slash command search)
|
||||
if (showSlashCommandPicker && newCursorPosition >= cursorPosition) {
|
||||
// Find the / position before cursor
|
||||
let slashPosition = -1;
|
||||
for (let i = newCursorPosition - 1; i >= 0; i--) {
|
||||
if (newValue[i] === '/') {
|
||||
slashPosition = i;
|
||||
break;
|
||||
}
|
||||
// Stop if we hit whitespace (new word)
|
||||
if (newValue[i] === ' ' || newValue[i] === '\n') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (slashPosition !== -1) {
|
||||
const query = newValue.substring(slashPosition + 1, newCursorPosition);
|
||||
setSlashCommandQuery(query);
|
||||
} else {
|
||||
// / was removed or cursor moved away
|
||||
setShowSlashCommandPicker(false);
|
||||
setSlashCommandQuery("");
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we're typing after @ (for search query)
|
||||
if (showFilePicker && newCursorPosition >= cursorPosition) {
|
||||
// Find the @ position before cursor
|
||||
|
@ -489,6 +531,71 @@ const FloatingPromptInputInner = (
|
|||
}, 0);
|
||||
};
|
||||
|
||||
const handleSlashCommandSelect = (command: SlashCommand) => {
|
||||
const textarea = isExpanded ? expandedTextareaRef.current : textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
// Find the / position before cursor
|
||||
let slashPosition = -1;
|
||||
for (let i = cursorPosition - 1; i >= 0; i--) {
|
||||
if (prompt[i] === '/') {
|
||||
slashPosition = i;
|
||||
break;
|
||||
}
|
||||
// Stop if we hit whitespace (new word)
|
||||
if (prompt[i] === ' ' || prompt[i] === '\n') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (slashPosition === -1) {
|
||||
console.error('[FloatingPromptInput] / position not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Simply insert the command syntax
|
||||
const beforeSlash = prompt.substring(0, slashPosition);
|
||||
const afterCursor = prompt.substring(cursorPosition);
|
||||
|
||||
if (command.accepts_arguments) {
|
||||
// Insert command with placeholder for arguments
|
||||
const newPrompt = `${beforeSlash}${command.full_command} `;
|
||||
setPrompt(newPrompt);
|
||||
setShowSlashCommandPicker(false);
|
||||
setSlashCommandQuery("");
|
||||
|
||||
// Focus and position cursor after the command
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
const newCursorPos = beforeSlash.length + command.full_command.length + 1;
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
}, 0);
|
||||
} else {
|
||||
// Insert command and close picker
|
||||
const newPrompt = `${beforeSlash}${command.full_command} ${afterCursor}`;
|
||||
setPrompt(newPrompt);
|
||||
setShowSlashCommandPicker(false);
|
||||
setSlashCommandQuery("");
|
||||
|
||||
// Focus and position cursor after the command
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
const newCursorPos = beforeSlash.length + command.full_command.length + 1;
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSlashCommandPickerClose = () => {
|
||||
setShowSlashCommandPicker(false);
|
||||
setSlashCommandQuery("");
|
||||
// Return focus to textarea
|
||||
setTimeout(() => {
|
||||
const textarea = isExpanded ? expandedTextareaRef.current : textareaRef.current;
|
||||
textarea?.focus();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (showFilePicker && e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
|
@ -497,7 +604,14 @@ const FloatingPromptInputInner = (
|
|||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey && !isExpanded && !showFilePicker) {
|
||||
if (showSlashCommandPicker && e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowSlashCommandPicker(false);
|
||||
setSlashCommandQuery("");
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey && !isExpanded && !showFilePicker && !showSlashCommandPicker) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
|
@ -917,6 +1031,18 @@ const FloatingPromptInputInner = (
|
|||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Slash Command Picker */}
|
||||
<AnimatePresence>
|
||||
{showSlashCommandPicker && (
|
||||
<SlashCommandPicker
|
||||
projectPath={projectPath}
|
||||
onSelect={handleSlashCommandSelect}
|
||||
onClose={handleSlashCommandPickerClose}
|
||||
initialQuery={slashCommandQuery}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Send/Stop Button */}
|
||||
|
@ -939,7 +1065,7 @@ const FloatingPromptInputInner = (
|
|||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Press Enter to send, Shift+Enter for new line{projectPath?.trim() && ", @ to mention files, drag & drop or paste images"}
|
||||
Press Enter to send, Shift+Enter for new line{projectPath?.trim() && ", @ to mention files, / for commands, drag & drop or paste images"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -24,6 +24,7 @@ import { Toast, ToastContainer } from "@/components/ui/toast";
|
|||
import { ClaudeVersionSelector } from "./ClaudeVersionSelector";
|
||||
import { StorageTab } from "./StorageTab";
|
||||
import { HooksEditor } from "./HooksEditor";
|
||||
import { SlashCommandsManager } from "./SlashCommandsManager";
|
||||
|
||||
interface SettingsProps {
|
||||
/**
|
||||
|
@ -357,12 +358,13 @@ export const Settings: React.FC<SettingsProps> = ({
|
|||
) : (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid grid-cols-6">
|
||||
<TabsList className="grid grid-cols-7 w-full">
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="permissions">Permissions</TabsTrigger>
|
||||
<TabsTrigger value="environment">Environment</TabsTrigger>
|
||||
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||
<TabsTrigger value="hooks">Hooks</TabsTrigger>
|
||||
<TabsTrigger value="commands">Commands</TabsTrigger>
|
||||
<TabsTrigger value="storage">Storage</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
@ -705,6 +707,13 @@ export const Settings: React.FC<SettingsProps> = ({
|
|||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Commands Tab */}
|
||||
<TabsContent value="commands">
|
||||
<Card className="p-6">
|
||||
<SlashCommandsManager className="p-0" />
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Storage Tab */}
|
||||
<TabsContent value="storage">
|
||||
<StorageTab />
|
||||
|
|
442
src/components/SlashCommandPicker.tsx
Normal file
442
src/components/SlashCommandPicker.tsx
Normal file
|
@ -0,0 +1,442 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api";
|
||||
import {
|
||||
X,
|
||||
Command,
|
||||
Search,
|
||||
Globe,
|
||||
FolderOpen,
|
||||
Zap,
|
||||
FileCode,
|
||||
Terminal,
|
||||
AlertCircle
|
||||
} from "lucide-react";
|
||||
import type { SlashCommand } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SlashCommandPickerProps {
|
||||
/**
|
||||
* The project path for loading project-specific commands
|
||||
*/
|
||||
projectPath?: string;
|
||||
/**
|
||||
* Callback when a command is selected
|
||||
*/
|
||||
onSelect: (command: SlashCommand) => void;
|
||||
/**
|
||||
* Callback to close the picker
|
||||
*/
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Initial search query (text after /)
|
||||
*/
|
||||
initialQuery?: string;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Get icon for command based on its properties
|
||||
const getCommandIcon = (command: SlashCommand) => {
|
||||
// If it has bash commands, show terminal icon
|
||||
if (command.has_bash_commands) return Terminal;
|
||||
|
||||
// If it has file references, show file icon
|
||||
if (command.has_file_references) return FileCode;
|
||||
|
||||
// If it accepts arguments, show zap icon
|
||||
if (command.accepts_arguments) return Zap;
|
||||
|
||||
// Based on scope
|
||||
if (command.scope === "project") return FolderOpen;
|
||||
if (command.scope === "user") return Globe;
|
||||
|
||||
// Default
|
||||
return Command;
|
||||
};
|
||||
|
||||
/**
|
||||
* SlashCommandPicker component - Autocomplete UI for slash commands
|
||||
*
|
||||
* @example
|
||||
* <SlashCommandPicker
|
||||
* projectPath="/Users/example/project"
|
||||
* onSelect={(command) => console.log('Selected:', command)}
|
||||
* onClose={() => setShowPicker(false)}
|
||||
* />
|
||||
*/
|
||||
export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
|
||||
projectPath,
|
||||
onSelect,
|
||||
onClose,
|
||||
initialQuery = "",
|
||||
className,
|
||||
}) => {
|
||||
const [commands, setCommands] = useState<SlashCommand[]>([]);
|
||||
const [filteredCommands, setFilteredCommands] = useState<SlashCommand[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState(initialQuery);
|
||||
|
||||
const commandListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load commands on mount or when project path changes
|
||||
useEffect(() => {
|
||||
loadCommands();
|
||||
}, [projectPath]);
|
||||
|
||||
// Filter commands based on search query
|
||||
useEffect(() => {
|
||||
if (!commands.length) {
|
||||
setFilteredCommands([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
|
||||
if (!query) {
|
||||
setFilteredCommands(commands);
|
||||
} else {
|
||||
const filtered = commands.filter(cmd => {
|
||||
// Match against command name
|
||||
if (cmd.name.toLowerCase().includes(query)) return true;
|
||||
|
||||
// Match against full command
|
||||
if (cmd.full_command.toLowerCase().includes(query)) return true;
|
||||
|
||||
// Match against namespace
|
||||
if (cmd.namespace && cmd.namespace.toLowerCase().includes(query)) return true;
|
||||
|
||||
// Match against description
|
||||
if (cmd.description && cmd.description.toLowerCase().includes(query)) return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// Sort by relevance
|
||||
filtered.sort((a, b) => {
|
||||
// Exact name match first
|
||||
const aExact = a.name.toLowerCase() === query;
|
||||
const bExact = b.name.toLowerCase() === query;
|
||||
if (aExact && !bExact) return -1;
|
||||
if (!aExact && bExact) return 1;
|
||||
|
||||
// Then by name starts with
|
||||
const aStarts = a.name.toLowerCase().startsWith(query);
|
||||
const bStarts = b.name.toLowerCase().startsWith(query);
|
||||
if (aStarts && !bStarts) return -1;
|
||||
if (!aStarts && bStarts) return 1;
|
||||
|
||||
// Then alphabetically
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
setFilteredCommands(filtered);
|
||||
}
|
||||
|
||||
// Reset selected index when filtered list changes
|
||||
setSelectedIndex(0);
|
||||
}, [searchQuery, commands]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (filteredCommands.length > 0 && selectedIndex < filteredCommands.length) {
|
||||
onSelect(filteredCommands[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.max(0, prev - 1));
|
||||
break;
|
||||
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.min(filteredCommands.length - 1, prev + 1));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [filteredCommands, selectedIndex, onSelect, onClose]);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (commandListRef.current) {
|
||||
const selectedElement = commandListRef.current.querySelector(`[data-index="${selectedIndex}"]`);
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
const loadCommands = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Always load fresh commands from filesystem
|
||||
const loadedCommands = await api.slashCommandsList(projectPath);
|
||||
setCommands(loadedCommands);
|
||||
} catch (err) {
|
||||
console.error("Failed to load slash commands:", err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load commands');
|
||||
setCommands([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommandClick = (command: SlashCommand) => {
|
||||
onSelect(command);
|
||||
};
|
||||
|
||||
// Group commands by namespace (or "Commands" if no namespace)
|
||||
const groupedCommands = filteredCommands.reduce((acc, cmd) => {
|
||||
const key = cmd.namespace || "Commands";
|
||||
if (!acc[key]) {
|
||||
acc[key] = [];
|
||||
}
|
||||
acc[key].push(cmd);
|
||||
return acc;
|
||||
}, {} as Record<string, SlashCommand[]>);
|
||||
|
||||
// Update search query from parent
|
||||
useEffect(() => {
|
||||
setSearchQuery(initialQuery);
|
||||
}, [initialQuery]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className={cn(
|
||||
"absolute bottom-full mb-2 left-0 z-50",
|
||||
"w-[600px] h-[400px]",
|
||||
"bg-background border border-border rounded-lg shadow-lg",
|
||||
"flex flex-col overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="border-b border-border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Command className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Slash Commands</span>
|
||||
{searchQuery && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Searching: "{searchQuery}"
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Command List */}
|
||||
<div className="flex-1 overflow-y-auto relative">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-sm text-muted-foreground">Loading commands...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center h-full p-4">
|
||||
<AlertCircle className="h-8 w-8 text-destructive mb-2" />
|
||||
<span className="text-sm text-destructive text-center">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && filteredCommands.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<Search className="h-8 w-8 text-muted-foreground mb-2" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{searchQuery ? 'No commands found' : 'No commands available'}
|
||||
</span>
|
||||
{!searchQuery && (
|
||||
<p className="text-xs text-muted-foreground mt-2 text-center px-4">
|
||||
Create commands in <code className="px-1">.claude/commands/</code> or <code className="px-1">~/.claude/commands/</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && filteredCommands.length > 0 && (
|
||||
<div className="p-2" ref={commandListRef}>
|
||||
{/* If no grouping needed, show flat list */}
|
||||
{Object.keys(groupedCommands).length === 1 ? (
|
||||
<div className="space-y-0.5">
|
||||
{filteredCommands.map((command, index) => {
|
||||
const Icon = getCommandIcon(command);
|
||||
const isSelected = index === selectedIndex;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={command.id}
|
||||
data-index={index}
|
||||
onClick={() => handleCommandClick(command)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={cn(
|
||||
"w-full flex items-start gap-3 px-3 py-2 rounded-md",
|
||||
"hover:bg-accent transition-colors",
|
||||
"text-left",
|
||||
isSelected && "bg-accent"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 mt-0.5 flex-shrink-0 text-muted-foreground" />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-mono text-sm text-primary">
|
||||
{command.full_command}
|
||||
</span>
|
||||
{command.accepts_arguments && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
[args]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{command.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{command.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{command.allowed_tools.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{command.allowed_tools.length} tool{command.allowed_tools.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{command.has_bash_commands && (
|
||||
<span className="text-xs text-blue-600 dark:text-blue-400">
|
||||
Bash
|
||||
</span>
|
||||
)}
|
||||
|
||||
{command.has_file_references && (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">
|
||||
Files
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// Show grouped by scope/namespace
|
||||
<div className="space-y-4">
|
||||
{Object.entries(groupedCommands).map(([groupKey, groupCommands]) => (
|
||||
<div key={groupKey}>
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-3 mb-1">
|
||||
{groupKey}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{groupCommands.map((command) => {
|
||||
const Icon = getCommandIcon(command);
|
||||
const globalIndex = filteredCommands.indexOf(command);
|
||||
const isSelected = globalIndex === selectedIndex;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={command.id}
|
||||
data-index={globalIndex}
|
||||
onClick={() => handleCommandClick(command)}
|
||||
onMouseEnter={() => setSelectedIndex(globalIndex)}
|
||||
className={cn(
|
||||
"w-full flex items-start gap-3 px-3 py-2 rounded-md",
|
||||
"hover:bg-accent transition-colors",
|
||||
"text-left",
|
||||
isSelected && "bg-accent"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 mt-0.5 flex-shrink-0 text-muted-foreground" />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-mono text-sm text-primary">
|
||||
{command.full_command}
|
||||
</span>
|
||||
{command.accepts_arguments && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
[args]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{command.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{command.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{command.allowed_tools.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{command.allowed_tools.length} tool{command.allowed_tools.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{command.has_bash_commands && (
|
||||
<span className="text-xs text-blue-600 dark:text-blue-400">
|
||||
Bash
|
||||
</span>
|
||||
)}
|
||||
|
||||
{command.has_file_references && (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">
|
||||
Files
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border p-2">
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
↑↓ Navigate • Enter Select • Esc Close
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
624
src/components/SlashCommandsManager.tsx
Normal file
624
src/components/SlashCommandsManager.tsx
Normal file
|
@ -0,0 +1,624 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Edit,
|
||||
Save,
|
||||
Command,
|
||||
Globe,
|
||||
FolderOpen,
|
||||
Terminal,
|
||||
FileCode,
|
||||
Zap,
|
||||
Code,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Search,
|
||||
ChevronDown,
|
||||
ChevronRight
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { api, type SlashCommand } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { COMMON_TOOL_MATCHERS } from "@/types/hooks";
|
||||
|
||||
interface SlashCommandsManagerProps {
|
||||
projectPath?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CommandForm {
|
||||
name: string;
|
||||
namespace: string;
|
||||
content: string;
|
||||
description: string;
|
||||
allowedTools: string[];
|
||||
scope: 'project' | 'user';
|
||||
}
|
||||
|
||||
const EXAMPLE_COMMANDS = [
|
||||
{
|
||||
name: "review",
|
||||
description: "Review code for best practices",
|
||||
content: "Review the following code for best practices, potential issues, and improvements:\n\n@$ARGUMENTS",
|
||||
allowedTools: ["Read", "Grep"]
|
||||
},
|
||||
{
|
||||
name: "explain",
|
||||
description: "Explain how something works",
|
||||
content: "Explain how $ARGUMENTS works in detail, including its purpose, implementation, and usage examples.",
|
||||
allowedTools: ["Read", "Grep", "WebSearch"]
|
||||
},
|
||||
{
|
||||
name: "fix-issue",
|
||||
description: "Fix a specific issue",
|
||||
content: "Fix issue #$ARGUMENTS following our coding standards and best practices.",
|
||||
allowedTools: ["Read", "Edit", "MultiEdit", "Write"]
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
description: "Write tests for code",
|
||||
content: "Write comprehensive tests for:\n\n@$ARGUMENTS\n\nInclude unit tests, edge cases, and integration tests where appropriate.",
|
||||
allowedTools: ["Read", "Write", "Edit"]
|
||||
}
|
||||
];
|
||||
|
||||
// Get icon for command based on its properties
|
||||
const getCommandIcon = (command: SlashCommand) => {
|
||||
if (command.has_bash_commands) return Terminal;
|
||||
if (command.has_file_references) return FileCode;
|
||||
if (command.accepts_arguments) return Zap;
|
||||
if (command.scope === "project") return FolderOpen;
|
||||
if (command.scope === "user") return Globe;
|
||||
return Command;
|
||||
};
|
||||
|
||||
/**
|
||||
* SlashCommandsManager component for managing custom slash commands
|
||||
* Provides a no-code interface for creating, editing, and deleting commands
|
||||
*/
|
||||
export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
|
||||
projectPath,
|
||||
className,
|
||||
}) => {
|
||||
const [commands, setCommands] = useState<SlashCommand[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedScope, setSelectedScope] = useState<'all' | 'project' | 'user'>('all');
|
||||
const [expandedCommands, setExpandedCommands] = useState<Set<string>>(new Set());
|
||||
|
||||
// Edit dialog state
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [editingCommand, setEditingCommand] = useState<SlashCommand | null>(null);
|
||||
const [commandForm, setCommandForm] = useState<CommandForm>({
|
||||
name: "",
|
||||
namespace: "",
|
||||
content: "",
|
||||
description: "",
|
||||
allowedTools: [],
|
||||
scope: 'user'
|
||||
});
|
||||
|
||||
// Load commands on mount
|
||||
useEffect(() => {
|
||||
loadCommands();
|
||||
}, [projectPath]);
|
||||
|
||||
const loadCommands = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const loadedCommands = await api.slashCommandsList(projectPath);
|
||||
setCommands(loadedCommands);
|
||||
} catch (err) {
|
||||
console.error("Failed to load slash commands:", err);
|
||||
setError("Failed to load commands");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setEditingCommand(null);
|
||||
setCommandForm({
|
||||
name: "",
|
||||
namespace: "",
|
||||
content: "",
|
||||
description: "",
|
||||
allowedTools: [],
|
||||
scope: projectPath ? 'project' : 'user'
|
||||
});
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (command: SlashCommand) => {
|
||||
setEditingCommand(command);
|
||||
setCommandForm({
|
||||
name: command.name,
|
||||
namespace: command.namespace || "",
|
||||
content: command.content,
|
||||
description: command.description || "",
|
||||
allowedTools: command.allowed_tools,
|
||||
scope: command.scope as 'project' | 'user'
|
||||
});
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
await api.slashCommandSave(
|
||||
commandForm.scope,
|
||||
commandForm.name,
|
||||
commandForm.namespace || undefined,
|
||||
commandForm.content,
|
||||
commandForm.description || undefined,
|
||||
commandForm.allowedTools,
|
||||
commandForm.scope === 'project' ? projectPath : undefined
|
||||
);
|
||||
|
||||
setEditDialogOpen(false);
|
||||
await loadCommands();
|
||||
} catch (err) {
|
||||
console.error("Failed to save command:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to save command");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (command: SlashCommand) => {
|
||||
if (!confirm(`Delete command "${command.full_command}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.slashCommandDelete(command.id);
|
||||
await loadCommands();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete command:", err);
|
||||
setError("Failed to delete command");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExpanded = (commandId: string) => {
|
||||
setExpandedCommands(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(commandId)) {
|
||||
next.delete(commandId);
|
||||
} else {
|
||||
next.add(commandId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToolToggle = (tool: string) => {
|
||||
setCommandForm(prev => ({
|
||||
...prev,
|
||||
allowedTools: prev.allowedTools.includes(tool)
|
||||
? prev.allowedTools.filter(t => t !== tool)
|
||||
: [...prev.allowedTools, tool]
|
||||
}));
|
||||
};
|
||||
|
||||
const applyExample = (example: typeof EXAMPLE_COMMANDS[0]) => {
|
||||
setCommandForm(prev => ({
|
||||
...prev,
|
||||
name: example.name,
|
||||
description: example.description,
|
||||
content: example.content,
|
||||
allowedTools: example.allowedTools
|
||||
}));
|
||||
};
|
||||
|
||||
// Filter commands
|
||||
const filteredCommands = commands.filter(cmd => {
|
||||
// Scope filter
|
||||
if (selectedScope !== 'all' && cmd.scope !== selectedScope) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
cmd.name.toLowerCase().includes(query) ||
|
||||
cmd.full_command.toLowerCase().includes(query) ||
|
||||
(cmd.description && cmd.description.toLowerCase().includes(query)) ||
|
||||
(cmd.namespace && cmd.namespace.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Group commands by namespace and scope
|
||||
const groupedCommands = filteredCommands.reduce((acc, cmd) => {
|
||||
const key = cmd.namespace
|
||||
? `${cmd.namespace} (${cmd.scope})`
|
||||
: `${cmd.scope === 'project' ? 'Project' : 'User'} Commands`;
|
||||
if (!acc[key]) {
|
||||
acc[key] = [];
|
||||
}
|
||||
acc[key].push(cmd);
|
||||
return acc;
|
||||
}, {} as Record<string, SlashCommand[]>);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Slash Commands</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Create custom commands to streamline your workflow
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleCreateNew} size="sm" className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
New Command
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search commands..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={selectedScope} onValueChange={(value: any) => setSelectedScope(value)}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Commands</SelectItem>
|
||||
<SelectItem value="project">Project</SelectItem>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-destructive/10 text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Commands List */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredCommands.length === 0 ? (
|
||||
<Card className="p-8">
|
||||
<div className="text-center">
|
||||
<Command className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{searchQuery ? "No commands found" : "No commands created yet"}
|
||||
</p>
|
||||
{!searchQuery && (
|
||||
<Button onClick={handleCreateNew} variant="outline" size="sm" className="mt-4">
|
||||
Create your first command
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(groupedCommands).map(([groupKey, groupCommands]) => (
|
||||
<Card key={groupKey} className="overflow-hidden">
|
||||
<div className="p-4 bg-muted/50 border-b">
|
||||
<h4 className="text-sm font-medium">
|
||||
{groupKey}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="divide-y">
|
||||
{groupCommands.map((command) => {
|
||||
const Icon = getCommandIcon(command);
|
||||
const isExpanded = expandedCommands.has(command.id);
|
||||
|
||||
return (
|
||||
<div key={command.id}>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Icon className="h-5 w-5 mt-0.5 text-muted-foreground flex-shrink-0" />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<code className="text-sm font-mono text-primary">
|
||||
{command.full_command}
|
||||
</code>
|
||||
{command.accepts_arguments && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Arguments
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{command.description && (
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{command.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
{command.allowed_tools.length > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
{command.allowed_tools.length} tool{command.allowed_tools.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{command.has_bash_commands && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Bash
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{command.has_file_references && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Files
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => toggleExpanded(command.id)}
|
||||
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
Hide content
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
Show content
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(command)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(command)}
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-4 p-3 bg-muted/50 rounded-md">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap">
|
||||
{command.content}
|
||||
</pre>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingCommand ? "Edit Command" : "Create New Command"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Scope */}
|
||||
<div className="space-y-2">
|
||||
<Label>Scope</Label>
|
||||
<Select
|
||||
value={commandForm.scope}
|
||||
onValueChange={(value: 'project' | 'user') => setCommandForm(prev => ({ ...prev, scope: value }))}
|
||||
disabled={!projectPath && commandForm.scope === 'project'}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
User (Global)
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="project" disabled={!projectPath}>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Project
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{commandForm.scope === 'user'
|
||||
? "Available across all projects"
|
||||
: "Only available in this project"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Name and Namespace */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Command Name*</Label>
|
||||
<Input
|
||||
placeholder="e.g., review, fix-issue"
|
||||
value={commandForm.name}
|
||||
onChange={(e) => setCommandForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Namespace (Optional)</Label>
|
||||
<Input
|
||||
placeholder="e.g., frontend, backend"
|
||||
value={commandForm.namespace}
|
||||
onChange={(e) => setCommandForm(prev => ({ ...prev, namespace: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label>Description (Optional)</Label>
|
||||
<Input
|
||||
placeholder="Brief description of what this command does"
|
||||
value={commandForm.description}
|
||||
onChange={(e) => setCommandForm(prev => ({ ...prev, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-2">
|
||||
<Label>Command Content*</Label>
|
||||
<Textarea
|
||||
placeholder="Enter the prompt content. Use $ARGUMENTS for dynamic values."
|
||||
value={commandForm.content}
|
||||
onChange={(e) => setCommandForm(prev => ({ ...prev, content: e.target.value }))}
|
||||
className="min-h-[150px] font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use <code>$ARGUMENTS</code> for user input, <code>@filename</code> for files,
|
||||
and <code>!`command`</code> for bash commands
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Allowed Tools */}
|
||||
<div className="space-y-2">
|
||||
<Label>Allowed Tools</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COMMON_TOOL_MATCHERS.map((tool) => (
|
||||
<Button
|
||||
key={tool}
|
||||
variant={commandForm.allowedTools.includes(tool) ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleToolToggle(tool)}
|
||||
type="button"
|
||||
>
|
||||
{tool}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select which tools Claude can use with this command
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Examples */}
|
||||
{!editingCommand && (
|
||||
<div className="space-y-2">
|
||||
<Label>Examples</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{EXAMPLE_COMMANDS.map((example) => (
|
||||
<Button
|
||||
key={example.name}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => applyExample(example)}
|
||||
className="justify-start"
|
||||
>
|
||||
<Code className="h-4 w-4 mr-2" />
|
||||
{example.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{commandForm.name && (
|
||||
<div className="space-y-2">
|
||||
<Label>Preview</Label>
|
||||
<div className="p-3 bg-muted rounded-md">
|
||||
<code className="text-sm">
|
||||
/
|
||||
{commandForm.namespace && `${commandForm.namespace}:`}
|
||||
{commandForm.name}
|
||||
{commandForm.content.includes('$ARGUMENTS') && ' [arguments]'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!commandForm.name || !commandForm.content || saving}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -24,6 +24,8 @@ export * from "./ui/tabs";
|
|||
export * from "./ui/textarea";
|
||||
export * from "./ui/toast";
|
||||
export * from "./ui/tooltip";
|
||||
export * from "./SlashCommandPicker";
|
||||
export * from "./SlashCommandsManager";
|
||||
export * from "./ui/popover";
|
||||
export * from "./ui/pagination";
|
||||
export * from "./ui/split-pane";
|
||||
|
|
110
src/lib/api.ts
110
src/lib/api.ts
|
@ -383,6 +383,36 @@ export interface MCPServerConfig {
|
|||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a custom slash command
|
||||
*/
|
||||
export interface SlashCommand {
|
||||
/** Unique identifier for the command */
|
||||
id: string;
|
||||
/** Command name (without prefix) */
|
||||
name: string;
|
||||
/** Full command with prefix (e.g., "/project:optimize") */
|
||||
full_command: string;
|
||||
/** Command scope: "project" or "user" */
|
||||
scope: string;
|
||||
/** Optional namespace (e.g., "frontend" in "/project:frontend:component") */
|
||||
namespace?: string;
|
||||
/** Path to the markdown file */
|
||||
file_path: string;
|
||||
/** Command content (markdown body) */
|
||||
content: string;
|
||||
/** Optional description from frontmatter */
|
||||
description?: string;
|
||||
/** Allowed tools from frontmatter */
|
||||
allowed_tools: string[];
|
||||
/** Whether the command has bash commands (!) */
|
||||
has_bash_commands: boolean;
|
||||
/** Whether the command has file references (@) */
|
||||
has_file_references: boolean;
|
||||
/** Whether the command uses $ARGUMENTS placeholder */
|
||||
accepts_arguments: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of adding a server
|
||||
*/
|
||||
|
@ -1724,5 +1754,85 @@ export const api = {
|
|||
console.error("Failed to get merged hooks config:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Slash Commands API methods
|
||||
|
||||
/**
|
||||
* Lists all available slash commands
|
||||
* @param projectPath - Optional project path to include project-specific commands
|
||||
* @returns Promise resolving to array of slash commands
|
||||
*/
|
||||
async slashCommandsList(projectPath?: string): Promise<SlashCommand[]> {
|
||||
try {
|
||||
return await invoke<SlashCommand[]>("slash_commands_list", { projectPath });
|
||||
} catch (error) {
|
||||
console.error("Failed to list slash commands:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets a single slash command by ID
|
||||
* @param commandId - Unique identifier of the command
|
||||
* @returns Promise resolving to the slash command
|
||||
*/
|
||||
async slashCommandGet(commandId: string): Promise<SlashCommand> {
|
||||
try {
|
||||
return await invoke<SlashCommand>("slash_command_get", { commandId });
|
||||
} catch (error) {
|
||||
console.error("Failed to get slash command:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates or updates a slash command
|
||||
* @param scope - Command scope: "project" or "user"
|
||||
* @param name - Command name (without prefix)
|
||||
* @param namespace - Optional namespace for organization
|
||||
* @param content - Markdown content of the command
|
||||
* @param description - Optional description
|
||||
* @param allowedTools - List of allowed tools for this command
|
||||
* @param projectPath - Required for project scope commands
|
||||
* @returns Promise resolving to the saved command
|
||||
*/
|
||||
async slashCommandSave(
|
||||
scope: string,
|
||||
name: string,
|
||||
namespace: string | undefined,
|
||||
content: string,
|
||||
description: string | undefined,
|
||||
allowedTools: string[],
|
||||
projectPath?: string
|
||||
): Promise<SlashCommand> {
|
||||
try {
|
||||
return await invoke<SlashCommand>("slash_command_save", {
|
||||
scope,
|
||||
name,
|
||||
namespace,
|
||||
content,
|
||||
description,
|
||||
allowedTools,
|
||||
projectPath
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to save slash command:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Deletes a slash command
|
||||
* @param commandId - Unique identifier of the command to delete
|
||||
* @returns Promise resolving to deletion message
|
||||
*/
|
||||
async slashCommandDelete(commandId: string): Promise<string> {
|
||||
try {
|
||||
return await invoke<string>("slash_command_delete", { commandId });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete slash command:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue