feat: implement custom slash commands with modal-based creation

- Add CustomCommandsSettings component with row-based design matching Mode section
- Integrate AddCustomCommandModal using proper Dialog components for consistent styling
- Remove category filtering and inline editing for cleaner UX
- Update modal to use proper Dialog background instead of dark overlay
- Remove category field from modal form for simplified command creation
- Apply subtle text colors to form helper text for better visual hierarchy
- Remove preview section from modal to reduce height and improve usability
- Add proper header styling with CardTitle and CardDescription
- Update command icons to display without background circles
- Implement proper padding and spacing throughout the interface
- Connect modal functionality to Plus button and edit actions
- Preserve existing command categories while preventing new category assignment
This commit is contained in:
spencrmartin
2025-09-26 10:45:45 -04:00
parent f32e522f42
commit cba7d3fefe
3 changed files with 174 additions and 434 deletions
@@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react';
import { X, Plus, Zap, Code, FileText, Search, Play, Settings, Hash } from 'lucide-react';
import { Plus, Zap, Code, FileText, Search, Play, Settings, Hash } from 'lucide-react';
import { CustomCommand } from '../types/customCommands';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
import { Button } from './ui/button';
interface AddCustomCommandModalProps {
isOpen: boolean;
@@ -31,7 +33,6 @@ export const AddCustomCommandModal: React.FC<AddCustomCommandModalProps> = ({
description: '',
prompt: '',
icon: 'Zap',
category: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
@@ -45,7 +46,6 @@ export const AddCustomCommandModal: React.FC<AddCustomCommandModalProps> = ({
description: editingCommand.description,
prompt: editingCommand.prompt,
icon: editingCommand.icon || 'Zap',
category: editingCommand.category || '',
});
} else {
setFormData({
@@ -54,7 +54,6 @@ export const AddCustomCommandModal: React.FC<AddCustomCommandModalProps> = ({
description: '',
prompt: '',
icon: 'Zap',
category: '',
});
}
setErrors({});
@@ -100,7 +99,7 @@ export const AddCustomCommandModal: React.FC<AddCustomCommandModalProps> = ({
description: formData.description.trim(),
prompt: formData.prompt.trim(),
icon: formData.icon,
category: formData.category.trim() || undefined,
category: editingCommand?.category || undefined,
createdAt: editingCommand?.createdAt || new Date(),
updatedAt: new Date(),
usageCount: editingCommand?.usageCount || 0,
@@ -129,33 +128,23 @@ export const AddCustomCommandModal: React.FC<AddCustomCommandModalProps> = ({
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black bg-opacity-50"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-background-default border border-borderStandard rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-borderStandard">
<h2 className="text-lg font-semibold text-textStandard">
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingCommand ? 'Edit Custom Command' : 'Add Custom Command'}
</h2>
<button
onClick={onClose}
className="p-1 hover:bg-bgSubtle rounded-md transition-colors"
>
<X size={20} className="text-textSubtle" />
</button>
</div>
</DialogTitle>
</DialogHeader>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-140px)]">
<div className="py-4">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Command Name */}
<div>
@@ -179,7 +168,7 @@ export const AddCustomCommandModal: React.FC<AddCustomCommandModalProps> = ({
{errors.name && (
<p className="mt-1 text-sm text-red-500">{errors.name}</p>
)}
<p className="mt-1 text-xs text-textSubtle">
<p className="mt-1 text-xs text-gray-500">
This will be the command users type (e.g., /document)
</p>
</div>
@@ -201,7 +190,7 @@ export const AddCustomCommandModal: React.FC<AddCustomCommandModalProps> = ({
{errors.label && (
<p className="mt-1 text-sm text-red-500">{errors.label}</p>
)}
<p className="mt-1 text-xs text-textSubtle">
<p className="mt-1 text-xs text-gray-500">
Friendly name shown in the command list
</p>
</div>
@@ -248,22 +237,7 @@ export const AddCustomCommandModal: React.FC<AddCustomCommandModalProps> = ({
</div>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-textStandard mb-2">
Category
</label>
<input
type="text"
value={formData.category}
onChange={(e) => handleInputChange('category', e.target.value)}
placeholder="Documentation, Code, etc."
className="w-full px-3 py-2 border border-borderStandard rounded-md bg-background-default text-textStandard placeholder-textSubtle focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<p className="mt-1 text-xs text-textSubtle">
Optional category for organization
</p>
</div>
{/* Prompt */}
<div>
@@ -282,52 +256,26 @@ export const AddCustomCommandModal: React.FC<AddCustomCommandModalProps> = ({
{errors.prompt && (
<p className="mt-1 text-sm text-red-500">{errors.prompt}</p>
)}
<p className="mt-1 text-xs text-textSubtle">
<p className="mt-1 text-xs text-gray-500">
This is the full prompt that will be sent to the AI when the command is used
</p>
</div>
{/* Preview */}
<div className="bg-bgSubtle rounded-lg p-4">
<h3 className="text-sm font-medium text-textStandard mb-2">Preview</h3>
<div className="space-y-2">
<div className="text-xs text-textSubtle">User types:</div>
<div className="font-mono text-sm bg-background-default px-2 py-1 rounded border">
/{formData.name || 'command'}
</div>
<div className="text-xs text-textSubtle">Appears as pill:</div>
<div className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 rounded-full text-sm">
{iconOptions.find(opt => opt.name === formData.icon)?.icon}
{formData.label || 'Command Label'}
</div>
<div className="text-xs text-textSubtle">AI receives:</div>
<div className="text-sm bg-background-default px-2 py-1 rounded border max-h-20 overflow-y-auto">
{formData.prompt || 'Your prompt will appear here...'}
</div>
</div>
</div>
</form>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-borderStandard">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-textStandard hover:bg-bgSubtle rounded-md transition-colors"
>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</button>
<button
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors flex items-center gap-2"
>
</Button>
<Button onClick={handleSubmit} className="flex items-center gap-2">
<Plus size={16} />
{editingCommand ? 'Update Command' : 'Add Command'}
</button>
</div>
</div>
</div>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -2,14 +2,11 @@ import React, { useState, useEffect } from 'react';
import { Plus, Edit, Trash2, Copy, Download, Upload, Star, StarOff, Zap, Code, FileText, Search } from 'lucide-react';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Textarea } from '../ui/textarea';
import { Label } from '../ui/label';
import { AddCustomCommandModal } from '../AddCustomCommandModal';
import {
CustomCommand,
CustomCommandCategory,
COMMAND_TEMPLATE,
DEFAULT_CATEGORIES,
COMMAND_VALIDATION
DEFAULT_CATEGORIES
} from '../../types/customCommands';
interface CustomCommandsSettingsProps {
@@ -26,12 +23,9 @@ const ICON_MAP = {
export const CustomCommandsSettings: React.FC<CustomCommandsSettingsProps> = () => {
const [commands, setCommands] = useState<CustomCommand[]>([]);
const [categories] = useState<CustomCommandCategory[]>(DEFAULT_CATEGORIES);
const [isEditing, setIsEditing] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingCommand, setEditingCommand] = useState<CustomCommand | null>(null);
const [formData, setFormData] = useState(COMMAND_TEMPLATE);
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [errors, setErrors] = useState<Record<string, string>>({});
// Load commands from storage on mount
useEffect(() => {
@@ -67,48 +61,7 @@ export const CustomCommandsSettings: React.FC<CustomCommandsSettingsProps> = ()
}
};
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
// Validate name
if (!formData.name) {
newErrors.name = 'Command name is required';
} else if (formData.name.length < COMMAND_VALIDATION.name.minLength) {
newErrors.name = `Name must be at least ${COMMAND_VALIDATION.name.minLength} characters`;
} else if (!COMMAND_VALIDATION.name.pattern.test(formData.name)) {
newErrors.name = 'Name must start with a letter and contain only letters and numbers';
} else if (commands.some(cmd => cmd.name.toLowerCase() === formData.name.toLowerCase() && cmd.id !== editingCommand?.id)) {
newErrors.name = 'A command with this name already exists';
}
// Validate label
if (!formData.label) {
newErrors.label = 'Label is required';
} else if (formData.label.length < COMMAND_VALIDATION.label.minLength) {
newErrors.label = `Label must be at least ${COMMAND_VALIDATION.label.minLength} characters`;
}
// Validate description
if (!formData.description) {
newErrors.description = 'Description is required';
} else if (formData.description.length < COMMAND_VALIDATION.description.minLength) {
newErrors.description = `Description must be at least ${COMMAND_VALIDATION.description.minLength} characters`;
}
// Validate prompt
if (!formData.prompt) {
newErrors.prompt = 'Prompt is required';
} else if (formData.prompt.length < COMMAND_VALIDATION.prompt.minLength) {
newErrors.prompt = `Prompt must be at least ${COMMAND_VALIDATION.prompt.minLength} characters`;
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = () => {
if (!validateForm()) return;
const handleModalSave = (command: CustomCommand) => {
const now = new Date();
let updatedCommands: CustomCommand[];
@@ -116,42 +69,30 @@ export const CustomCommandsSettings: React.FC<CustomCommandsSettingsProps> = ()
// Update existing command
updatedCommands = commands.map(cmd =>
cmd.id === editingCommand.id
? { ...formData, id: editingCommand.id, createdAt: editingCommand.createdAt, updatedAt: now }
? { ...command, id: editingCommand.id, createdAt: editingCommand.createdAt, updatedAt: now }
: cmd
);
} else {
// Create new command
const newCommand: CustomCommand = {
...formData,
id: `cmd_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
createdAt: now,
updatedAt: now,
};
updatedCommands = [...commands, newCommand];
updatedCommands = [...commands, { ...command, createdAt: now, updatedAt: now }];
}
saveCommands(updatedCommands);
setIsEditing(false);
setEditingCommand(null);
setFormData(COMMAND_TEMPLATE);
setErrors({});
};
const handleEdit = (command: CustomCommand) => {
setEditingCommand(command);
setFormData({
name: command.name,
label: command.label,
description: command.description,
prompt: command.prompt,
icon: command.icon || 'Zap',
category: command.category || 'general',
variables: command.variables || [],
usageCount: command.usageCount,
isFavorite: command.isFavorite,
});
setIsEditing(true);
setErrors({});
setIsModalOpen(true);
};
const handleCreateNew = () => {
setEditingCommand(null);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingCommand(null);
};
const handleDelete = (commandId: string) => {
@@ -187,286 +128,133 @@ export const CustomCommandsSettings: React.FC<CustomCommandsSettingsProps> = ()
cmd.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
cmd.description.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = selectedCategory === 'all' || cmd.category === selectedCategory;
return matchesSearch && matchesCategory;
return matchesSearch;
});
const handleCancel = () => {
setIsEditing(false);
setEditingCommand(null);
setFormData(COMMAND_TEMPLATE);
setErrors({});
};
return (
<div className="space-y-1">
{!isEditing ? (
<>
{/* Header Actions */}
<div className="flex items-center justify-between gap-4 mb-4">
<div className="flex items-center gap-4">
<Input
placeholder="Search commands..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-64"
/>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="px-3 py-2 border border-border-default rounded-md bg-background-default text-text-default"
>
<option value="all">All Categories</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm">
<Upload size={16} className="mr-2" />
Import
</Button>
<Button variant="outline" size="sm">
<Download size={16} className="mr-2" />
Export
</Button>
<Button onClick={() => setIsEditing(true)}>
<Plus size={16} className="mr-2" />
New Command
</Button>
</div>
</div>
{/* Commands List - Row Style */}
{filteredCommands.map(command => (
<div key={command.id} className="group hover:cursor-pointer text-sm">
<div className="flex items-center justify-between text-text-default py-2 px-2 bg-background-default hover:bg-background-muted rounded-lg transition-all">
<div className="flex items-center gap-3">
<div className="w-6 h-6 bg-background-muted rounded-full flex items-center justify-center">
{ICON_MAP[command.icon as keyof typeof ICON_MAP] || ICON_MAP.Zap}
</div>
<div>
<h3 className="text-text-default font-medium">/{command.name}</h3>
<p className="text-text-muted text-xs mt-[2px]">{command.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleToggleFavorite(command.id);
}}
className="p-1 h-6 w-6"
>
{command.isFavorite ? (
<Star size={12} className="text-yellow-500 fill-yellow-500" />
) : (
<StarOff size={12} className="text-text-muted" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleEdit(command);
}}
className="p-1 h-6 w-6"
>
<Edit size={12} className="text-text-muted hover:text-text-default" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDuplicate(command);
}}
className="p-1 h-6 w-6"
>
<Copy size={12} className="text-text-muted hover:text-text-default" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDelete(command.id);
}}
className="p-1 h-6 w-6 text-red-600 hover:text-red-700"
>
<Trash2 size={12} />
</Button>
</div>
</div>
</div>
))}
{filteredCommands.length === 0 && (
<div className="text-center py-8">
<div className="w-12 h-12 bg-background-muted rounded-full flex items-center justify-center mx-auto mb-3">
<Zap size={16} className="text-text-muted" />
</div>
<h3 className="text-sm font-medium text-text-default mb-1">
{searchQuery || selectedCategory !== 'all' ? 'No commands found' : 'No custom commands yet'}
</h3>
<p className="text-text-muted text-xs mb-3">
{searchQuery || selectedCategory !== 'all'
? 'Try adjusting your search or category filter'
: 'Create your first custom slash command to get started'
}
</p>
{!searchQuery && selectedCategory === 'all' && (
<Button onClick={() => setIsEditing(true)} size="sm">
<Plus size={14} className="mr-2" />
Create Command
</Button>
)}
</div>
)}
</>
) : (
/* Command Editor */
<div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-xl font-semibold text-textStandard">
{editingCommand ? 'Edit Command' : 'Create New Command'}
</h3>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleSave}>
{editingCommand ? 'Update' : 'Create'} Command
</Button>
</div>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="name">Command Name *</Label>
<Input
id="name"
placeholder="e.g., document, review, explain"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className={errors.name ? 'border-red-500' : ''}
/>
{errors.name && <p className="text-red-500 text-sm mt-1">{errors.name}</p>}
<p className="text-textSubtle text-xs mt-1">
Users will type /{formData.name || 'name'} to use this command
</p>
</div>
<div>
<Label htmlFor="label">Display Label *</Label>
<Input
id="label"
placeholder="e.g., Create Document, Code Review"
value={formData.label}
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
className={errors.label ? 'border-red-500' : ''}
/>
{errors.label && <p className="text-red-500 text-sm mt-1">{errors.label}</p>}
</div>
</div>
<div>
<Label htmlFor="description">Description *</Label>
<Input
id="description"
placeholder="Brief description of what this command does"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className={errors.description ? 'border-red-500' : ''}
/>
{errors.description && <p className="text-red-500 text-sm mt-1">{errors.description}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="category">Category</Label>
<select
id="category"
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="w-full px-3 py-2 border border-borderStandard rounded-md bg-background-default text-textStandard"
>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
<div>
<Label htmlFor="icon">Icon</Label>
<select
id="icon"
value={formData.icon}
onChange={(e) => setFormData({ ...formData, icon: e.target.value })}
className="w-full px-3 py-2 border border-borderStandard rounded-md bg-background-default text-textStandard"
>
{Object.keys(ICON_MAP).map(iconName => (
<option key={iconName} value={iconName}>{iconName}</option>
))}
</select>
</div>
</div>
<div>
<Label htmlFor="prompt">Prompt Template *</Label>
<Textarea
id="prompt"
placeholder="Enter the full prompt that will be sent to the AI when this command is used..."
value={formData.prompt}
onChange={(e) => setFormData({ ...formData, prompt: e.target.value })}
rows={8}
className={errors.prompt ? 'border-red-500' : ''}
/>
{errors.prompt && <p className="text-red-500 text-sm mt-1">{errors.prompt}</p>}
<p className="text-textSubtle text-xs mt-1">
This is the actual prompt that will be sent to the AI. Users will see [{formData.label || 'Label'}] as a pill in their input.
</p>
</div>
{/* Preview */}
<div className="border border-borderStandard rounded-lg p-4 bg-bgSubtle">
<h4 className="font-medium text-textStandard mb-2">Preview</h4>
<div className="flex items-center gap-2 mb-2">
<span className="text-textSubtle">User types:</span>
<code className="bg-background-default px-2 py-1 rounded text-sm">/{formData.name || 'name'}</code>
</div>
<div className="flex items-center gap-2 mb-2">
<span className="text-textSubtle">User sees:</span>
<div className="inline-flex items-center gap-1.5 px-2 py-1 bg-bgProminent text-textProminentInverse border border-borderProminent rounded-full text-xs font-medium">
<span className="flex items-center gap-1">
<span className="relative">
<div className="w-3 h-3 bg-blue-500 rounded-full absolute inset-0" />
<span className="relative text-white text-[8px] flex items-center justify-center w-3 h-3">
{ICON_MAP[formData.icon as keyof typeof ICON_MAP] || ICON_MAP.Zap}
</span>
</span>
{formData.label || 'Label'}
</span>
</div>
</div>
<div className="text-textSubtle text-sm">
<span>AI receives:</span>
<div className="bg-background-default p-2 rounded mt-1 text-xs font-mono max-h-32 overflow-y-auto">
{formData.prompt || 'Prompt template will appear here...'}
</div>
</div>
</div>
<>
<div className="space-y-1">
{/* Header Actions */}
<div className="flex items-center justify-between gap-4 mb-4 px-2">
<Input
placeholder="Search commands..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-64"
/>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="p-2">
<Upload size={16} />
</Button>
<Button variant="outline" size="sm" className="p-2">
<Download size={16} />
</Button>
<Button onClick={handleCreateNew} size="sm" className="p-2">
<Plus size={16} />
</Button>
</div>
</div>
)}
</div>
{/* Commands List - Row Style */}
{filteredCommands.map(command => (
<div key={command.id} className="group hover:cursor-pointer text-sm">
<div className="flex items-center justify-between text-text-default py-2 px-2 bg-background-default hover:bg-background-muted rounded-lg transition-all">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center text-text-muted">
{ICON_MAP[command.icon as keyof typeof ICON_MAP] || ICON_MAP.Zap}
</div>
<div>
<h3 className="text-text-default font-medium">/{command.name}</h3>
<p className="text-text-muted text-xs mt-[2px]">{command.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleToggleFavorite(command.id);
}}
className="p-1 h-6 w-6"
>
{command.isFavorite ? (
<Star size={12} className="text-yellow-500 fill-yellow-500" />
) : (
<StarOff size={12} className="text-text-muted" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleEdit(command);
}}
className="p-1 h-6 w-6"
>
<Edit size={12} className="text-text-muted hover:text-text-default" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDuplicate(command);
}}
className="p-1 h-6 w-6"
>
<Copy size={12} className="text-text-muted hover:text-text-default" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDelete(command.id);
}}
className="p-1 h-6 w-6 text-red-600 hover:text-red-700"
>
<Trash2 size={12} />
</Button>
</div>
</div>
</div>
))}
{filteredCommands.length === 0 && (
<div className="text-center py-8">
<div className="w-12 h-12 bg-background-muted rounded-full flex items-center justify-center mx-auto mb-3">
<Zap size={16} className="text-text-muted" />
</div>
<h3 className="text-sm font-medium text-text-default mb-1">
{searchQuery ? 'No commands found' : 'No custom commands yet'}
</h3>
<p className="text-text-muted text-xs mb-3">
{searchQuery
? 'Try adjusting your search query'
: 'Create your first custom slash command to get started'
}
</p>
{!searchQuery && (
<Button onClick={handleCreateNew} size="sm">
<Plus size={14} className="mr-2" />
Create Command
</Button>
)}
</div>
)}
</div>
{/* Modal */}
<AddCustomCommandModal
isOpen={isModalOpen}
onClose={handleCloseModal}
onSave={handleModalSave}
editingCommand={editingCommand}
/>
</>
);
};
@@ -30,6 +30,10 @@ export default function ChatSettingsSection() {
</Card>
<Card className="pb-2 rounded-lg">
<CardHeader className="pb-0">
<CardTitle className="">Custom Commands</CardTitle>
<CardDescription>Create custom slash commands with rich prompts that expand when sent to the AI</CardDescription>
</CardHeader>
<CardContent className="px-2">
<CustomCommandsSettings />
</CardContent>