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 { motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import {
X,
@@ -11,7 +12,9 @@ import {
Zap,
FileCode,
Terminal,
AlertCircle
AlertCircle,
User,
Building2
} from "lucide-react";
import type { SlashCommand } from "@/lib/api";
import { cn } from "@/lib/utils";
@@ -81,6 +84,7 @@ export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
const [error, setError] = useState<string | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const [searchQuery, setSearchQuery] = useState(initialQuery);
const [activeTab, setActiveTab] = useState<string>("custom");
const commandListRef = useRef<HTMLDivElement>(null);
@@ -89,7 +93,7 @@ export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
loadCommands();
}, [projectPath]);
// Filter commands based on search query
// Filter commands based on search query and active tab
useEffect(() => {
if (!commands.length) {
setFilteredCommands([]);
@@ -97,11 +101,23 @@ export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
}
const query = searchQuery.toLowerCase();
let filteredByTab: SlashCommand[];
if (!query) {
setFilteredCommands(commands);
// Filter by active tab
if (activeTab === "default") {
// No default/built-in commands yet
filteredByTab = [];
} 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
if (cmd.name.toLowerCase().includes(query)) return true;
@@ -134,13 +150,13 @@ export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
// Then alphabetically
return a.name.localeCompare(b.name);
});
setFilteredCommands(filtered);
}
setFilteredCommands(filtered);
// Reset selected index when filtered list changes
setSelectedIndex(0);
}, [searchQuery, commands]);
}, [searchQuery, commands, activeTab]);
// Keyboard navigation
useEffect(() => {
@@ -205,9 +221,17 @@ export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
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 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]) {
acc[key] = [];
}
@@ -254,6 +278,16 @@ export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
<X className="h-4 w-4" />
</Button>
</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>
{/* Command List */}
@@ -271,163 +305,187 @@ export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
</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>
);
})}
{!isLoading && !error && (
<>
{/* Default Tab Content */}
{activeTab === "default" && (
<div className="flex flex-col items-center justify-center h-full">
<Command className="h-8 w-8 text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground">
No default commands available
</span>
<p className="text-xs text-muted-foreground mt-2 text-center px-4">
Default commands are built-in system commands
</p>
</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>
{/* Custom Tab Content */}
{activeTab === "custom" && (
<>
{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 custom 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>
))}
</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>