diff --git a/.gitignore b/.gitignore index 49b3720..6bb5690 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ temp_lib/ AGENTS.md CLAUDE.md *_TASK.md + +# Claude project-specific files +.claude/ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 41820dc..9e7531c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a175e43..d3b356e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 30e446b..dec0c08 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -3,3 +3,4 @@ pub mod claude; pub mod mcp; pub mod usage; pub mod storage; +pub mod slash_commands; diff --git a/src-tauri/src/commands/slash_commands.rs b/src-tauri/src/commands/slash_commands.rs new file mode 100644 index 0000000..f25c140 --- /dev/null +++ b/src-tauri/src/commands/slash_commands.rs @@ -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, + /// Path to the markdown file + pub file_path: String, + /// Command content (markdown body) + pub content: String, + /// Optional description from frontmatter + pub description: Option, + /// Allowed tools from frontmatter + pub allowed_tools: Vec, + /// 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>, + description: Option, +} + +/// Parse a markdown file with optional YAML frontmatter +fn parse_markdown_with_frontmatter(content: &str) -> Result<(Option, 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::(&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)> { + 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 { + 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) -> 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, +) -> Result, 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 { + 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, + content: String, + description: Option, + allowed_tools: Vec, + project_path: Option, +) -> Result { + 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 { + 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(()) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c9f9cd1..1ee9960 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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"); diff --git a/src/components/FloatingPromptInput.tsx b/src/components/FloatingPromptInput.tsx index a675275..be3507e 100644 --- a/src/components/FloatingPromptInput.tsx +++ b/src/components/FloatingPromptInput.tsx @@ -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([]); 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 = ( /> )} + + {/* Slash Command Picker */} + + {showSlashCommandPicker && ( + + )} + {/* Send/Stop Button */} @@ -939,7 +1065,7 @@ const FloatingPromptInputInner = (
- 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"}
diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 5304013..638feb2 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -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 = ({ ) : (
- + General Permissions Environment Advanced Hooks + Commands Storage @@ -705,6 +707,13 @@ export const Settings: React.FC = ({ + {/* Commands Tab */} + + + + + + {/* Storage Tab */} diff --git a/src/components/SlashCommandPicker.tsx b/src/components/SlashCommandPicker.tsx new file mode 100644 index 0000000..b1efec7 --- /dev/null +++ b/src/components/SlashCommandPicker.tsx @@ -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 + * console.log('Selected:', command)} + * onClose={() => setShowPicker(false)} + * /> + */ +export const SlashCommandPicker: React.FC = ({ + projectPath, + onSelect, + onClose, + initialQuery = "", + className, +}) => { + const [commands, setCommands] = useState([]); + const [filteredCommands, setFilteredCommands] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedIndex, setSelectedIndex] = useState(0); + const [searchQuery, setSearchQuery] = useState(initialQuery); + + const commandListRef = useRef(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); + + // Update search query from parent + useEffect(() => { + setSearchQuery(initialQuery); + }, [initialQuery]); + + return ( + + {/* Header */} +
+
+
+ + Slash Commands + {searchQuery && ( + + Searching: "{searchQuery}" + + )} +
+ +
+
+ + {/* Command List */} +
+ {isLoading && ( +
+ Loading commands... +
+ )} + + {error && ( +
+ + {error} +
+ )} + + {!isLoading && !error && filteredCommands.length === 0 && ( +
+ + + {searchQuery ? 'No commands found' : 'No commands available'} + + {!searchQuery && ( +

+ Create commands in .claude/commands/ or ~/.claude/commands/ +

+ )} +
+ )} + + {!isLoading && !error && filteredCommands.length > 0 && ( +
+ {/* If no grouping needed, show flat list */} + {Object.keys(groupedCommands).length === 1 ? ( +
+ {filteredCommands.map((command, index) => { + const Icon = getCommandIcon(command); + const isSelected = index === selectedIndex; + + return ( + + ); + })} +
+ ) : ( + // Show grouped by scope/namespace +
+ {Object.entries(groupedCommands).map(([groupKey, groupCommands]) => ( +
+

+ {groupKey} +

+ +
+ {groupCommands.map((command) => { + const Icon = getCommandIcon(command); + const globalIndex = filteredCommands.indexOf(command); + const isSelected = globalIndex === selectedIndex; + + return ( + + ); + })} +
+
+ ))} +
+ )} +
+ )} +
+ + {/* Footer */} +
+

+ ↑↓ Navigate • Enter Select • Esc Close +

+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/SlashCommandsManager.tsx b/src/components/SlashCommandsManager.tsx new file mode 100644 index 0000000..62e1646 --- /dev/null +++ b/src/components/SlashCommandsManager.tsx @@ -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 = ({ + projectPath, + className, +}) => { + const [commands, setCommands] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedScope, setSelectedScope] = useState<'all' | 'project' | 'user'>('all'); + const [expandedCommands, setExpandedCommands] = useState>(new Set()); + + // Edit dialog state + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [editingCommand, setEditingCommand] = useState(null); + const [commandForm, setCommandForm] = useState({ + 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); + + return ( +
+ {/* Header */} +
+
+

Slash Commands

+

+ Create custom commands to streamline your workflow +

+
+ +
+ + {/* Filters */} +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+
+ +
+ + {/* Error Message */} + {error && ( +
+ + {error} +
+ )} + + {/* Commands List */} + {loading ? ( +
+ +
+ ) : filteredCommands.length === 0 ? ( + +
+ +

+ {searchQuery ? "No commands found" : "No commands created yet"} +

+ {!searchQuery && ( + + )} +
+
+ ) : ( +
+ {Object.entries(groupedCommands).map(([groupKey, groupCommands]) => ( + +
+

+ {groupKey} +

+
+ +
+ {groupCommands.map((command) => { + const Icon = getCommandIcon(command); + const isExpanded = expandedCommands.has(command.id); + + return ( +
+
+
+ + +
+
+ + {command.full_command} + + {command.accepts_arguments && ( + + Arguments + + )} +
+ + {command.description && ( +

+ {command.description} +

+ )} + +
+ {command.allowed_tools.length > 0 && ( + + {command.allowed_tools.length} tool{command.allowed_tools.length === 1 ? '' : 's'} + + )} + + {command.has_bash_commands && ( + + Bash + + )} + + {command.has_file_references && ( + + Files + + )} + + +
+
+ +
+ + +
+
+ + + {isExpanded && ( + +
+
+                                  {command.content}
+                                
+
+
+ )} +
+
+
+ ); + })} +
+
+ ))} +
+ )} + + {/* Edit Dialog */} + + + + + {editingCommand ? "Edit Command" : "Create New Command"} + + + +
+ {/* Scope */} +
+ + +

+ {commandForm.scope === 'user' + ? "Available across all projects" + : "Only available in this project"} +

+
+ + {/* Name and Namespace */} +
+
+ + setCommandForm(prev => ({ ...prev, name: e.target.value }))} + /> +
+ +
+ + setCommandForm(prev => ({ ...prev, namespace: e.target.value }))} + /> +
+
+ + {/* Description */} +
+ + setCommandForm(prev => ({ ...prev, description: e.target.value }))} + /> +
+ + {/* Content */} +
+ +