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

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