Skip to content

Commit 8567c7c

Browse files
MarkShawn2020claude
andcommitted
feat(commands): 新增 command 使用统计和排序功能
- 后端:扫描 session 文件提取 slash command 使用次数 - 前端:ItemCard 显示使用次数 (×N) - 支持按 Usage/Name 排序,默认使用量倒序 - 添加 regex 依赖用于解析 session 内容 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 95eae37 commit 8567c7c

5 files changed

Lines changed: 135 additions & 7 deletions

File tree

src-tauri/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,5 @@ tauri-plugin-dialog = "2"
2424
serde = { version = "1", features = ["derive"] }
2525
serde_json = "1"
2626
dirs = "6"
27+
regex = "1"
2728

src-tauri/src/lib.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,64 @@ fn get_project_context(project_path: String) -> Result<Vec<ContextFile>, String>
890890
Ok(files)
891891
}
892892

893+
// ============================================================================
894+
// Command Usage Stats Feature
895+
// ============================================================================
896+
897+
#[derive(Debug, Serialize, Deserialize)]
898+
pub struct CommandStats {
899+
pub name: String,
900+
pub count: usize,
901+
}
902+
903+
#[tauri::command]
904+
fn get_command_stats() -> Result<HashMap<String, usize>, String> {
905+
let projects_dir = get_claude_dir().join("projects");
906+
let mut stats: HashMap<String, usize> = HashMap::new();
907+
908+
if !projects_dir.exists() {
909+
return Ok(stats);
910+
}
911+
912+
// Regex to extract command names from session content
913+
let command_pattern = regex::Regex::new(r"<command-name>(/[^<]+)</command-name>")
914+
.map_err(|e| e.to_string())?;
915+
916+
// Iterate all project directories
917+
for project_entry in fs::read_dir(&projects_dir).map_err(|e| e.to_string())? {
918+
let project_entry = project_entry.map_err(|e| e.to_string())?;
919+
let project_path = project_entry.path();
920+
921+
if !project_path.is_dir() {
922+
continue;
923+
}
924+
925+
// Iterate all session files in project
926+
for session_entry in fs::read_dir(&project_path).map_err(|e| e.to_string())? {
927+
let session_entry = session_entry.map_err(|e| e.to_string())?;
928+
let session_path = session_entry.path();
929+
let name = session_path.file_name().unwrap().to_string_lossy().to_string();
930+
931+
// Skip non-session files
932+
if !name.ends_with(".jsonl") || name.starts_with("agent-") {
933+
continue;
934+
}
935+
936+
// Read and parse session file
937+
if let Ok(content) = fs::read_to_string(&session_path) {
938+
for cap in command_pattern.captures_iter(&content) {
939+
if let Some(cmd_name) = cap.get(1) {
940+
let cmd = cmd_name.as_str().to_string();
941+
*stats.entry(cmd).or_insert(0) += 1;
942+
}
943+
}
944+
}
945+
}
946+
}
947+
948+
Ok(stats)
949+
}
950+
893951
// ============================================================================
894952
// Settings Feature
895953
// ============================================================================
@@ -1146,6 +1204,7 @@ pub fn run() {
11461204
get_context_files,
11471205
get_project_context,
11481206
get_settings,
1207+
get_command_stats,
11491208
get_templates_catalog,
11501209
install_command_template,
11511210
install_mcp_template,

src/App.tsx

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,9 @@ function OutputStylesView() {
974974
// Commands Feature
975975
// ============================================================================
976976

977+
type CommandSortKey = "usage" | "name";
978+
type SortDirection = "asc" | "desc";
979+
977980
function CommandsView({
978981
onSelect,
979982
marketplaceItems,
@@ -984,32 +987,85 @@ function CommandsView({
984987
onMarketplaceSelect: (item: MarketplaceItem) => void;
985988
}) {
986989
const [commands, setCommands] = useState<LocalCommand[]>([]);
990+
const [commandStats, setCommandStats] = useState<Record<string, number>>({});
987991
const [loading, setLoading] = useState(true);
992+
const [sortKey, setSortKey] = useState<CommandSortKey>("usage");
993+
const [sortDir, setSortDir] = useState<SortDirection>("desc");
988994
const { search, setSearch, filtered } = useSearch(commands, ["name", "description"]);
989995

990996
useEffect(() => {
991-
invoke<LocalCommand[]>("list_local_commands")
992-
.then(setCommands)
997+
Promise.all([
998+
invoke<LocalCommand[]>("list_local_commands"),
999+
invoke<Record<string, number>>("get_command_stats")
1000+
])
1001+
.then(([cmds, stats]) => {
1002+
setCommands(cmds);
1003+
setCommandStats(stats);
1004+
})
9931005
.finally(() => setLoading(false));
9941006
}, []);
9951007

1008+
// Sort filtered commands
1009+
const sorted = [...filtered].sort((a, b) => {
1010+
if (sortKey === "usage") {
1011+
const aCount = commandStats[a.name] || 0;
1012+
const bCount = commandStats[b.name] || 0;
1013+
return sortDir === "desc" ? bCount - aCount : aCount - bCount;
1014+
} else {
1015+
const cmp = a.name.localeCompare(b.name);
1016+
return sortDir === "desc" ? -cmp : cmp;
1017+
}
1018+
});
1019+
1020+
const toggleSort = (key: CommandSortKey) => {
1021+
if (sortKey === key) {
1022+
setSortDir(sortDir === "desc" ? "asc" : "desc");
1023+
} else {
1024+
setSortKey(key);
1025+
setSortDir(key === "usage" ? "desc" : "asc");
1026+
}
1027+
};
1028+
9961029
if (loading) return <LoadingState message="Loading commands..." />;
9971030

9981031
return (
9991032
<ConfigPage>
10001033
<PageHeader title="Commands" subtitle={`${commands.length} slash commands in ~/.claude/commands`} />
1001-
<SearchInput placeholder="Search local & marketplace..." value={search} onChange={setSearch} />
1034+
<div className="flex items-center gap-4 mb-6">
1035+
<SearchInput
1036+
placeholder="Search local & marketplace..."
1037+
value={search}
1038+
onChange={setSearch}
1039+
className="flex-1 max-w-md px-4 py-2 bg-card border border-border rounded-lg text-ink placeholder:text-muted focus:outline-none focus:border-primary"
1040+
/>
1041+
<div className="flex items-center gap-1 text-xs shrink-0">
1042+
<span className="text-muted mr-1">Sort:</span>
1043+
<button
1044+
onClick={() => toggleSort("usage")}
1045+
className={`px-2 py-1 rounded ${sortKey === "usage" ? "bg-primary/20 text-primary" : "text-muted hover:text-ink"}`}
1046+
>
1047+
Usage {sortKey === "usage" && (sortDir === "desc" ? "↓" : "↑")}
1048+
</button>
1049+
<button
1050+
onClick={() => toggleSort("name")}
1051+
className={`px-2 py-1 rounded ${sortKey === "name" ? "bg-primary/20 text-primary" : "text-muted hover:text-ink"}`}
1052+
>
1053+
Name {sortKey === "name" && (sortDir === "desc" ? "↓" : "↑")}
1054+
</button>
1055+
</div>
1056+
</div>
10021057

10031058
{/* Local results */}
1004-
{filtered.length > 0 && (
1059+
{sorted.length > 0 && (
10051060
<div className="space-y-2">
1006-
{filtered.map((cmd) => (
1061+
{sorted.map((cmd) => (
10071062
<ItemCard
10081063
key={cmd.name}
10091064
name={cmd.name}
10101065
description={cmd.description}
10111066
badge={cmd.argument_hint}
10121067
badgeVariant="muted"
1068+
usageCount={commandStats[cmd.name]}
10131069
onClick={() => onSelect(cmd)}
10141070
/>
10151071
))}

src/components/config/index.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,20 @@ export function SearchInput({
3737
placeholder,
3838
value,
3939
onChange,
40+
className,
4041
}: {
4142
placeholder: string;
4243
value: string;
4344
onChange: (value: string) => void;
45+
className?: string;
4446
}) {
4547
return (
4648
<input
4749
type="text"
4850
placeholder={placeholder}
4951
value={value}
5052
onChange={(e) => onChange(e.target.value)}
51-
className="w-full max-w-md mb-6 px-4 py-2 bg-card border border-border rounded-lg text-ink placeholder:text-muted focus:outline-none focus:border-primary"
53+
className={className ?? "w-full max-w-md mb-6 px-4 py-2 bg-card border border-border rounded-lg text-ink placeholder:text-muted focus:outline-none focus:border-primary"}
5254
/>
5355
);
5456
}
@@ -121,12 +123,14 @@ export function ItemCard({
121123
description,
122124
badge,
123125
badgeVariant = "accent",
126+
usageCount,
124127
onClick,
125128
}: {
126129
name: string;
127130
description?: string | null;
128131
badge?: string | null;
129132
badgeVariant?: "accent" | "muted";
133+
usageCount?: number;
130134
onClick: () => void;
131135
}) {
132136
const badgeClass =
@@ -141,7 +145,14 @@ export function ItemCard({
141145
>
142146
<div className="flex items-start justify-between gap-4">
143147
<div className="flex-1 min-w-0">
144-
<p className="font-mono font-medium text-primary">{name}</p>
148+
<div className="flex items-center gap-2">
149+
<p className="font-mono font-medium text-primary">{name}</p>
150+
{usageCount !== undefined && usageCount > 0 && (
151+
<span className="text-xs text-muted" title={`Used ${usageCount} times`}>
152+
×{usageCount}
153+
</span>
154+
)}
155+
</div>
145156
{description && (
146157
<p className="text-sm text-muted mt-1 line-clamp-2">{description}</p>
147158
)}

0 commit comments

Comments
 (0)