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:
@@ -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">
|
{/* Custom Tab Content */}
|
||||||
{Object.entries(groupedCommands).map(([groupKey, groupCommands]) => (
|
{activeTab === "custom" && (
|
||||||
<div key={groupKey}>
|
<>
|
||||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-3 mb-1">
|
{filteredCommands.length === 0 && (
|
||||||
{groupKey}
|
<div className="flex flex-col items-center justify-center h-full">
|
||||||
</h3>
|
<Search className="h-8 w-8 text-muted-foreground mb-2" />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
<div className="space-y-0.5">
|
{searchQuery ? 'No commands found' : 'No custom commands available'}
|
||||||
{groupCommands.map((command) => {
|
</span>
|
||||||
const Icon = getCommandIcon(command);
|
{!searchQuery && (
|
||||||
const globalIndex = filteredCommands.indexOf(command);
|
<p className="text-xs text-muted-foreground mt-2 text-center px-4">
|
||||||
const isSelected = globalIndex === selectedIndex;
|
Create commands in <code className="px-1">.claude/commands/</code> or <code className="px-1">~/.claude/commands/</code>
|
||||||
|
</p>
|
||||||
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>
|
|
||||||
|
{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>
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user