feat: add tabs to SlashCommandPicker for better command organization

- Add Default and Custom tabs to separate built-in vs user/project commands
- Enhance command grouping with User/Project scope indicators and icons
- Improve filtering logic to work with tab selection
- Add visual distinction between user and project commands with icons
- Maintain backward compatibility with existing command display logic
This commit is contained in:
Vivek R
2025-07-07 23:10:16 +05:30
parent 8af922944b
commit a3d7011c43

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { import {
X, X,
@@ -11,7 +12,9 @@ import {
Zap, Zap,
FileCode, FileCode,
Terminal, Terminal,
AlertCircle AlertCircle,
User,
Building2
} from "lucide-react"; } from "lucide-react";
import type { SlashCommand } from "@/lib/api"; import type { SlashCommand } from "@/lib/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -81,6 +84,7 @@ export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const [searchQuery, setSearchQuery] = useState(initialQuery); const [searchQuery, setSearchQuery] = useState(initialQuery);
const [activeTab, setActiveTab] = useState<string>("custom");
const commandListRef = useRef<HTMLDivElement>(null); const commandListRef = useRef<HTMLDivElement>(null);
@@ -89,7 +93,7 @@ export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
loadCommands(); loadCommands();
}, [projectPath]); }, [projectPath]);
// Filter commands based on search query // Filter commands based on search query and active tab
useEffect(() => { useEffect(() => {
if (!commands.length) { if (!commands.length) {
setFilteredCommands([]); setFilteredCommands([]);
@@ -97,11 +101,23 @@ export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
} }
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
let filteredByTab: SlashCommand[];
if (!query) { // Filter by active tab
setFilteredCommands(commands); if (activeTab === "default") {
// No default/built-in commands yet
filteredByTab = [];
} else { } else {
const filtered = commands.filter(cmd => { // Show all custom commands (both user and project)
filteredByTab = commands;
}
// Then filter by search query
let filtered: SlashCommand[];
if (!query) {
filtered = filteredByTab;
} else {
filtered = filteredByTab.filter(cmd => {
// Match against command name // Match against command name
if (cmd.name.toLowerCase().includes(query)) return true; if (cmd.name.toLowerCase().includes(query)) return true;
@@ -134,13 +150,13 @@ export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
// Then alphabetically // Then alphabetically
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}); });
setFilteredCommands(filtered);
} }
setFilteredCommands(filtered);
// Reset selected index when filtered list changes // Reset selected index when filtered list changes
setSelectedIndex(0); setSelectedIndex(0);
}, [searchQuery, commands]); }, [searchQuery, commands, activeTab]);
// Keyboard navigation // Keyboard navigation
useEffect(() => { useEffect(() => {
@@ -205,9 +221,17 @@ export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
onSelect(command); onSelect(command);
}; };
// Group commands by namespace (or "Commands" if no namespace) // Group commands by scope and namespace for the Custom tab
const groupedCommands = filteredCommands.reduce((acc, cmd) => { const groupedCommands = filteredCommands.reduce((acc, cmd) => {
const key = cmd.namespace || "Commands"; let key: string;
if (cmd.scope === "user") {
key = cmd.namespace ? `User Commands: ${cmd.namespace}` : "User Commands";
} else if (cmd.scope === "project") {
key = cmd.namespace ? `Project Commands: ${cmd.namespace}` : "Project Commands";
} else {
key = cmd.namespace || "Commands";
}
if (!acc[key]) { if (!acc[key]) {
acc[key] = []; acc[key] = [];
} }
@@ -254,6 +278,16 @@ export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
{/* Tabs */}
<div className="mt-3">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="default">Default</TabsTrigger>
<TabsTrigger value="custom">Custom</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div> </div>
{/* Command List */} {/* Command List */}
@@ -271,163 +305,187 @@ export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
</div> </div>
)} )}
{!isLoading && !error && filteredCommands.length === 0 && ( {!isLoading && !error && (
<div className="flex flex-col items-center justify-center h-full"> <>
<Search className="h-8 w-8 text-muted-foreground mb-2" /> {/* Default Tab Content */}
<span className="text-sm text-muted-foreground"> {activeTab === "default" && (
{searchQuery ? 'No commands found' : 'No commands available'} <div className="flex flex-col items-center justify-center h-full">
</span> <Command className="h-8 w-8 text-muted-foreground mb-2" />
{!searchQuery && ( <span className="text-sm text-muted-foreground">
<p className="text-xs text-muted-foreground mt-2 text-center px-4"> No default commands available
Create commands in <code className="px-1">.claude/commands/</code> or <code className="px-1">~/.claude/commands/</code> </span>
</p> <p className="text-xs text-muted-foreground mt-2 text-center px-4">
)} Default commands are built-in system commands
</div> </p>
)}
{!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> </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"> {/* Custom Tab Content */}
{groupCommands.map((command) => { {activeTab === "custom" && (
const Icon = getCommandIcon(command); <>
const globalIndex = filteredCommands.indexOf(command); {filteredCommands.length === 0 && (
const isSelected = globalIndex === selectedIndex; <div className="flex flex-col items-center justify-center h-full">
<Search className="h-8 w-8 text-muted-foreground mb-2" />
return ( <span className="text-sm text-muted-foreground">
<button {searchQuery ? 'No commands found' : 'No custom commands available'}
key={command.id} </span>
data-index={globalIndex} {!searchQuery && (
onClick={() => handleCommandClick(command)} <p className="text-xs text-muted-foreground mt-2 text-center px-4">
onMouseEnter={() => setSelectedIndex(globalIndex)} Create commands in <code className="px-1">.claude/commands/</code> or <code className="px-1">~/.claude/commands/</code>
className={cn( </p>
"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>
{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 flex items-center gap-2">
{groupKey.startsWith("User Commands") && <User className="h-3 w-3" />}
{groupKey.startsWith("Project Commands") && <Building2 className="h-3 w-3" />}
{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> </>
)} )}
</div> </div>