feat: implement custom slash commands system
Some checks are pending
Build Test / Build Test (Linux) (push) Waiting to run
Build Test / Build Test (Windows) (push) Waiting to run
Build Test / Build Test (macOS) (push) Waiting to run
Build Test / Build Test Summary (push) Blocked by required conditions

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:
Mufeed VH 2025-07-06 22:51:08 +05:30
parent 985de02404
commit 8af922944b
12 changed files with 1753 additions and 4 deletions

3
.gitignore vendored
View file

@ -32,3 +32,6 @@ temp_lib/
AGENTS.md
CLAUDE.md
*_TASK.md
# Claude project-specific files
.claude/

20
src-tauri/Cargo.lock generated
View file

@ -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"

View file

@ -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]

View file

@ -3,3 +3,4 @@ pub mod claude;
pub mod mcp;
pub mod usage;
pub mod storage;
pub mod slash_commands;

View 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(())
}

View file

@ -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");

View file

@ -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>

View file

@ -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 />

View 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>
);
};

View 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>
);
};

View file

@ -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";

View file

@ -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;
}
}
};