feat: CodePilot - desktop GUI for Claude Code

Full-featured desktop application built with Next.js + Electron:
- Streaming chat with Claude Code via Agent SDK
- Session management with SQLite persistence
- Project file tree and file preview
- MCP server and custom skills management
- Permission controls with Code/Plan/Ask modes
- Dark/light theme, token tracking
- macOS DMG packaging with electron-builder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-06 21:04:05 +08:00
1 parent 6776cc6297
commit ff1fb38735
149 files changed
+31117 -122

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
'use client';
import { useState, useEffect } from 'react';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
interface ConfigEditorProps {
value: string;
onSave: (value: string) => void;
label?: string;
}
export function ConfigEditor({ value, onSave, label }: ConfigEditorProps) {
const [text, setText] = useState(value);
const [error, setError] = useState<string | null>(null);
useEffect(() => { setText(value); }, [value]);
function handleSave() {
try {
JSON.parse(text);
setError(null);
onSave(text);
} catch (e) {
setError(e instanceof Error ? e.message : 'Invalid JSON');
}
}
function handleFormat() {
try {
const parsed = JSON.parse(text);
setText(JSON.stringify(parsed, null, 2));
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : 'Invalid JSON');
}
}
return (
<div className="space-y-2">
{label && <Label>{label}</Label>}
<Textarea
value={text}
onChange={(e) => {
setText(e.target.value);
setError(null);
}}
className="font-mono text-sm min-h-[200px]"
placeholder="{}"
/>
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex gap-2">
<Button size="sm" onClick={handleSave}>
Save
</Button>
<Button size="sm" variant="outline" onClick={handleFormat}>
Format
</Button>
</div>
</div>
);
}
+211
View File
@@ -0,0 +1,211 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { HugeiconsIcon } from "@hugeicons/react";
import { PlusSignIcon, ListViewIcon, CodeIcon, Loading02Icon } from "@hugeicons/core-free-icons";
import { McpServerList } from "@/components/plugins/McpServerList";
import { McpServerEditor } from "@/components/plugins/McpServerEditor";
import { ConfigEditor } from "@/components/plugins/ConfigEditor";
import type { MCPServer } from "@/types";
export function McpManager() {
const [servers, setServers] = useState<Record<string, MCPServer>>({});
const [loading, setLoading] = useState(true);
const [editorOpen, setEditorOpen] = useState(false);
const [editingName, setEditingName] = useState<string | undefined>();
const [editingServer, setEditingServer] = useState<MCPServer | undefined>();
const [tab, setTab] = useState<"list" | "json">("list");
const [error, setError] = useState<string | null>(null);
const fetchServers = useCallback(async () => {
try {
setError(null);
const res = await fetch("/api/plugins/mcp");
const data = await res.json();
if (data.mcpServers) {
setServers(data.mcpServers);
} else if (data.error) {
setError(data.error);
}
} catch (err) {
console.error("Failed to fetch MCP servers:", err);
setError("Failed to connect to API");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchServers();
}, [fetchServers]);
function handleEdit(name: string, server: MCPServer) {
setEditingName(name);
setEditingServer(server);
setEditorOpen(true);
}
function handleAdd() {
setEditingName(undefined);
setEditingServer(undefined);
setEditorOpen(true);
}
async function handleDelete(name: string) {
try {
const res = await fetch(`/api/plugins/mcp/${encodeURIComponent(name)}`, {
method: "DELETE",
});
if (res.ok) {
setServers((prev) => {
const updated = { ...prev };
delete updated[name];
return updated;
});
} else {
const data = await res.json();
console.error("Failed to delete MCP server:", data.error);
}
} catch (err) {
console.error("Failed to delete MCP server:", err);
}
}
async function handleSave(name: string, server: MCPServer) {
if (editingName && editingName !== name) {
const updated = { ...servers };
delete updated[editingName];
updated[name] = server;
try {
await fetch("/api/plugins/mcp", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mcpServers: updated }),
});
setServers(updated);
} catch (err) {
console.error("Failed to save MCP server:", err);
}
} else if (editingName) {
const updated = { ...servers, [name]: server };
try {
await fetch("/api/plugins/mcp", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mcpServers: updated }),
});
setServers(updated);
} catch (err) {
console.error("Failed to save MCP server:", err);
}
} else {
try {
const res = await fetch("/api/plugins/mcp", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, server }),
});
if (res.ok) {
setServers((prev) => ({ ...prev, [name]: server }));
} else {
const data = await res.json();
console.error("Failed to add MCP server:", data.error);
}
} catch (err) {
console.error("Failed to add MCP server:", err);
}
}
}
async function handleJsonSave(jsonStr: string) {
try {
const parsed = JSON.parse(jsonStr);
await fetch("/api/plugins/mcp", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mcpServers: parsed }),
});
setServers(parsed);
} catch (err) {
console.error("Failed to save MCP config:", err);
}
}
const serverCount = Object.keys(servers).length;
return (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">MCP Servers</h3>
{serverCount > 0 && (
<span className="text-sm text-muted-foreground">
({serverCount})
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5">
Configure Model Context Protocol servers for Claude
</p>
</div>
<Button size="sm" className="gap-1" onClick={handleAdd}>
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
Add Server
</Button>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 mb-4">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<Tabs value={tab} onValueChange={(v) => setTab(v as "list" | "json")}>
<TabsList>
<TabsTrigger value="list" className="gap-1.5">
<HugeiconsIcon icon={ListViewIcon} className="h-3.5 w-3.5" />
Servers
</TabsTrigger>
<TabsTrigger value="json" className="gap-1.5">
<HugeiconsIcon icon={CodeIcon} className="h-3.5 w-3.5" />
JSON Config
</TabsTrigger>
</TabsList>
<TabsContent value="list" className="mt-4">
{loading ? (
<div className="flex items-center justify-center gap-2 py-12 text-muted-foreground">
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />
<p className="text-sm">Loading MCP servers...</p>
</div>
) : (
<McpServerList
servers={servers}
onEdit={handleEdit}
onDelete={handleDelete}
/>
)}
</TabsContent>
<TabsContent value="json" className="mt-4">
<ConfigEditor
value={JSON.stringify(servers, null, 2)}
onSave={handleJsonSave}
label="MCP Server Configuration"
/>
</TabsContent>
</Tabs>
<McpServerEditor
open={editorOpen}
onOpenChange={setEditorOpen}
name={editingName}
server={editingServer}
onSave={handleSave}
/>
</div>
);
}
+376
View File
@@ -0,0 +1,376 @@
'use client';
import { useState, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { HugeiconsIcon } from "@hugeicons/react";
import { ServerStack01Icon, Wifi01Icon, GlobeIcon, CodeIcon } from "@hugeicons/core-free-icons";
import type { MCPServer } from '@/types';
type ServerType = 'stdio' | 'sse' | 'http';
interface McpServerEditorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
name?: string;
server?: MCPServer;
onSave: (name: string, server: MCPServer) => void;
}
export function McpServerEditor({
open,
onOpenChange,
name: initialName,
server: initialServer,
onSave,
}: McpServerEditorProps) {
const isEditing = !!initialName;
const [name, setName] = useState(initialName || '');
const [serverType, setServerType] = useState<ServerType>(
initialServer?.type || 'stdio'
);
const [command, setCommand] = useState(initialServer?.command || '');
const [args, setArgs] = useState(initialServer?.args?.join('\n') || '');
const [url, setUrl] = useState(initialServer?.url || '');
const [headersText, setHeadersText] = useState(
initialServer?.headers ? JSON.stringify(initialServer.headers, null, 2) : '{}'
);
const [envText, setEnvText] = useState(
initialServer?.env ? JSON.stringify(initialServer.env, null, 2) : '{}'
);
const [jsonMode, setJsonMode] = useState(false);
const [jsonText, setJsonText] = useState('');
const [error, setError] = useState<string | null>(null);
// Reset form when dialog opens with new data
useEffect(() => {
if (open) {
setName(initialName || '');
setServerType(initialServer?.type || 'stdio');
setCommand(initialServer?.command || '');
setArgs(initialServer?.args?.join('\n') || '');
setUrl(initialServer?.url || '');
setHeadersText(
initialServer?.headers
? JSON.stringify(initialServer.headers, null, 2)
: '{}'
);
setEnvText(
initialServer?.env
? JSON.stringify(initialServer.env, null, 2)
: '{}'
);
setJsonMode(false);
setJsonText(
initialServer
? JSON.stringify(initialServer, null, 2)
: '{\n "command": "",\n "args": []\n}'
);
setError(null);
}
}, [open, initialName, initialServer]);
function handleSave() {
setError(null);
if (!name.trim()) {
setError('Server name is required');
return;
}
if (jsonMode) {
try {
const parsed = JSON.parse(jsonText);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
setError('JSON must be an object');
return;
}
onSave(name.trim(), parsed as MCPServer);
onOpenChange(false);
} catch {
setError('Invalid JSON configuration');
}
return;
}
// Validate based on server type
if (serverType === 'stdio') {
if (!command.trim()) {
setError('Command is required for stdio servers');
return;
}
} else {
if (!url.trim()) {
setError('URL is required for SSE/HTTP servers');
return;
}
}
let env: Record<string, string> | undefined;
try {
const parsed = JSON.parse(envText);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
env = Object.keys(parsed).length > 0 ? parsed : undefined;
} else {
setError('Environment must be a JSON object');
return;
}
} catch {
setError('Invalid JSON in environment variables');
return;
}
let headers: Record<string, string> | undefined;
if (serverType !== 'stdio') {
try {
const parsed = JSON.parse(headersText);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
headers = Object.keys(parsed).length > 0 ? parsed : undefined;
} else {
setError('Headers must be a JSON object');
return;
}
} catch {
setError('Invalid JSON in headers');
return;
}
}
const serverArgs = args
.split('\n')
.map((s: string) => s.trim())
.filter(Boolean);
const server: MCPServer = serverType === 'stdio'
? {
command: command.trim(),
...(serverArgs.length > 0 ? { args: serverArgs } : {}),
...(env ? { env } : {}),
}
: {
command: '',
type: serverType,
...(url ? { url: url.trim() } : {}),
...(serverArgs.length > 0 ? { args: serverArgs } : {}),
...(env ? { env } : {}),
...(headers ? { headers } : {}),
};
onSave(name.trim(), server);
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{isEditing ? `Edit MCP Server: ${initialName}` : 'Add MCP Server'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="server-name">Server Name</Label>
<Input
id="server-name"
value={name}
onChange={(e) => {
setName(e.target.value);
setError(null);
}}
placeholder="my-mcp-server"
disabled={isEditing}
/>
</div>
<div className="flex items-center gap-2">
<Label className="shrink-0">Edit Mode:</Label>
<Button
variant={jsonMode ? 'outline' : 'default'}
size="sm"
onClick={() => {
setJsonMode(false);
setError(null);
}}
>
Form
</Button>
<Button
variant={jsonMode ? 'default' : 'outline'}
size="sm"
className="gap-1.5"
onClick={() => {
// Build current config as JSON for the editor
const currentConfig: Record<string, unknown> = {};
if (serverType !== 'stdio') {
currentConfig.type = serverType;
if (url) currentConfig.url = url;
} else {
currentConfig.command = command;
}
const argsArr = args.split('\n').map(s => s.trim()).filter(Boolean);
if (argsArr.length > 0) currentConfig.args = argsArr;
try {
const envParsed = JSON.parse(envText);
if (Object.keys(envParsed).length > 0) currentConfig.env = envParsed;
} catch { /* ignore */ }
try {
const headersParsed = JSON.parse(headersText);
if (Object.keys(headersParsed).length > 0) currentConfig.headers = headersParsed;
} catch { /* ignore */ }
setJsonText(JSON.stringify(currentConfig, null, 2));
setJsonMode(true);
setError(null);
}}
>
<HugeiconsIcon icon={CodeIcon} className="h-3.5 w-3.5" />
JSON
</Button>
</div>
{jsonMode ? (
<div className="space-y-2">
<Label>Server Configuration (JSON)</Label>
<Textarea
value={jsonText}
onChange={(e) => {
setJsonText(e.target.value);
setError(null);
}}
className="font-mono text-sm min-h-[250px]"
placeholder='{"command": "npx", "args": ["-y", "@server/name"]}'
/>
</div>
) : (
<>
<div className="space-y-2">
<Label>Server Type</Label>
<Tabs
value={serverType}
onValueChange={(v) => {
setServerType(v as ServerType);
setError(null);
}}
>
<TabsList className="w-full">
<TabsTrigger value="stdio" className="flex-1 gap-1.5">
<HugeiconsIcon icon={ServerStack01Icon} className="h-3.5 w-3.5" />
stdio
</TabsTrigger>
<TabsTrigger value="sse" className="flex-1 gap-1.5">
<HugeiconsIcon icon={Wifi01Icon} className="h-3.5 w-3.5" />
SSE
</TabsTrigger>
<TabsTrigger value="http" className="flex-1 gap-1.5">
<HugeiconsIcon icon={GlobeIcon} className="h-3.5 w-3.5" />
HTTP
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{serverType === 'stdio' ? (
<>
<div className="space-y-2">
<Label htmlFor="server-command">Command</Label>
<Input
id="server-command"
value={command}
onChange={(e) => {
setCommand(e.target.value);
setError(null);
}}
placeholder="npx -y @modelcontextprotocol/server-name"
className="font-mono text-sm"
/>
</div>
<div className="space-y-2">
<Label htmlFor="server-args">Arguments (one per line)</Label>
<Textarea
id="server-args"
value={args}
onChange={(e) => setArgs(e.target.value)}
placeholder={"--flag\nvalue"}
className="font-mono text-sm min-h-[80px]"
/>
</div>
</>
) : (
<>
<div className="space-y-2">
<Label htmlFor="server-url">URL</Label>
<Input
id="server-url"
value={url}
onChange={(e) => {
setUrl(e.target.value);
setError(null);
}}
placeholder={
serverType === 'sse'
? 'http://localhost:3001/sse'
: 'http://localhost:3001'
}
className="font-mono text-sm"
/>
</div>
<div className="space-y-2">
<Label htmlFor="server-headers">Headers (JSON)</Label>
<Textarea
id="server-headers"
value={headersText}
onChange={(e) => {
setHeadersText(e.target.value);
setError(null);
}}
placeholder='{"Authorization": "Bearer ..."}'
className="font-mono text-sm min-h-[80px]"
/>
</div>
</>
)}
<div className="space-y-2">
<Label htmlFor="server-env">Environment Variables (JSON)</Label>
<Textarea
id="server-env"
value={envText}
onChange={(e) => {
setEnvText(e.target.value);
setError(null);
}}
placeholder='{"API_KEY": "..."}'
className="font-mono text-sm min-h-[80px]"
/>
</div>
</>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave}>
{isEditing ? 'Save Changes' : 'Add Server'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+121
View File
@@ -0,0 +1,121 @@
'use client';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
import { Delete02Icon, PencilIcon, ServerStack01Icon, Wifi01Icon, GlobeIcon } from "@hugeicons/core-free-icons";
import type { MCPServer } from '@/types';
interface McpServerListProps {
servers: Record<string, MCPServer>;
onEdit: (name: string, server: MCPServer) => void;
onDelete: (name: string) => void;
}
function getServerTypeInfo(server: MCPServer) {
const type = server.type || 'stdio';
switch (type) {
case 'sse':
return { label: 'SSE', icon: Wifi01Icon, color: 'text-blue-500' };
case 'http':
return { label: 'HTTP', icon: GlobeIcon, color: 'text-green-500' };
default:
return { label: 'stdio', icon: ServerStack01Icon, color: 'text-muted-foreground' };
}
}
export function McpServerList({ servers, onEdit, onDelete }: McpServerListProps) {
const entries = Object.entries(servers);
if (entries.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<HugeiconsIcon icon={ServerStack01Icon} className="h-10 w-10 mb-3 opacity-50" />
<p className="text-sm">No MCP servers configured</p>
<p className="text-xs mt-1">
Add an MCP server to extend Claude&apos;s capabilities
</p>
</div>
);
}
return (
<div className="space-y-3">
{entries.map(([name, server]) => {
const typeInfo = getServerTypeInfo(server);
return (
<Card key={name}>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
<div className="flex-1 min-w-0 mr-3">
<div className="flex items-center gap-2 mb-1">
<HugeiconsIcon icon={typeInfo.icon} className={`h-4 w-4 shrink-0 ${typeInfo.color}`} />
<CardTitle className="text-sm font-medium">{name}</CardTitle>
<Badge variant="outline" className="text-xs shrink-0">
{typeInfo.label}
</Badge>
<Badge variant="secondary" className="text-xs shrink-0">
Configured
</Badge>
</div>
<CardDescription className="text-xs mt-1 font-mono">
{server.url
? server.url
: `${server.command} ${server.args?.join(' ') || ''}`}
</CardDescription>
</div>
<div className="flex gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onEdit(name, server)}
>
<HugeiconsIcon icon={PencilIcon} className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => onDelete(name)}
>
<HugeiconsIcon icon={Delete02Icon} className="h-3.5 w-3.5" />
</Button>
</div>
</CardHeader>
{(server.env && Object.keys(server.env).length > 0) ||
(server.args && server.args.length > 0) ? (
<CardContent className="pt-0">
{server.args && server.args.length > 0 && (
<div className="mb-2">
<p className="text-xs text-muted-foreground mb-1">Arguments:</p>
<div className="flex gap-1 flex-wrap">
{server.args.map((arg, i) => (
<Badge key={i} variant="outline" className="text-xs font-mono">
{arg}
</Badge>
))}
</div>
</div>
)}
{server.env && Object.keys(server.env).length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-1">Environment:</p>
<div className="flex gap-1 flex-wrap">
{Object.keys(server.env).map((key) => (
<Badge key={key} variant="outline" className="text-xs font-mono">
{key}
</Badge>
))}
</div>
</div>
)}
</CardContent>
) : null}
</Card>
);
})}
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
'use client';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowDown01Icon, ArrowUp01Icon, ZapIcon } from "@hugeicons/core-free-icons";
export interface SkillInfo {
name: string;
description: string;
source: 'global' | 'project' | 'plugin';
content: string;
filePath: string;
enabled: boolean;
}
interface PluginCardProps {
plugin: SkillInfo;
onSelect: (plugin: SkillInfo) => void;
}
export function PluginCard({ plugin, onSelect }: PluginCardProps) {
const [expanded, setExpanded] = useState(false);
const isProject = plugin.source === 'project';
const isPlugin = plugin.source === 'plugin';
const displayName = isProject
? plugin.name.replace('project:', '')
: plugin.name;
return (
<Card className="transition-colors hover:bg-accent/50">
<CardHeader
className="flex flex-row items-start justify-between space-y-0 pb-3 cursor-pointer"
onClick={() => onSelect(plugin)}
>
<div className="flex-1 min-w-0 mr-3">
<div className="flex items-center gap-2 mb-1">
<HugeiconsIcon icon={ZapIcon} className="h-4 w-4 text-muted-foreground shrink-0" />
<CardTitle className="text-sm font-medium truncate">
/{displayName}
</CardTitle>
<Badge
variant={isProject ? 'secondary' : 'outline'}
className="text-xs shrink-0"
>
{isPlugin ? 'Plugin' : isProject ? 'Project' : 'Global'}
</Badge>
</div>
<CardDescription className="text-xs line-clamp-2">
{plugin.description}
</CardDescription>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
>
{expanded ? (
<HugeiconsIcon icon={ArrowUp01Icon} className="h-4 w-4" />
) : (
<HugeiconsIcon icon={ArrowDown01Icon} className="h-4 w-4" />
)}
</Button>
</CardHeader>
{expanded && (
<CardContent className="pt-0">
<div className="rounded-md bg-muted p-3 max-h-60 overflow-auto">
<pre className="text-xs font-mono whitespace-pre-wrap break-words">
{plugin.content}
</pre>
</div>
<p className="text-xs text-muted-foreground mt-2 font-mono truncate">
{plugin.filePath}
</p>
</CardContent>
)}
</Card>
);
}
+71
View File
@@ -0,0 +1,71 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { HugeiconsIcon } from "@hugeicons/react";
import { Cancel01Icon, ZapIcon } from "@hugeicons/core-free-icons";
import type { SkillInfo } from './PluginCard';
interface PluginDetailProps {
plugin: SkillInfo;
onClose: () => void;
}
export function PluginDetail({ plugin, onClose }: PluginDetailProps) {
const isProject = plugin.source === 'project';
const displayName = isProject
? plugin.name.replace('project:', '')
: plugin.name;
return (
<div className="border rounded-lg p-4 bg-card">
<div className="flex items-start justify-between mb-3">
<div>
<div className="flex items-center gap-2">
<HugeiconsIcon icon={ZapIcon} className="h-4 w-4 text-muted-foreground" />
<h3 className="text-lg font-semibold">/{displayName}</h3>
<Badge variant={isProject ? 'secondary' : 'outline'}>
{isProject ? 'Project' : 'Global'}
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
{plugin.description}
</p>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<HugeiconsIcon icon={Cancel01Icon} className="h-4 w-4" />
</Button>
</div>
<Separator className="my-3" />
<div className="space-y-3">
<div>
<p className="text-sm font-medium mb-1">Source</p>
<p className="text-xs text-muted-foreground font-mono break-all">
{plugin.filePath}
</p>
</div>
<div>
<p className="text-sm font-medium mb-1">Type</p>
<p className="text-xs text-muted-foreground">
{isProject ? 'Project-level skill' : 'User-level skill'}
</p>
</div>
<Separator />
<div>
<p className="text-sm font-medium mb-2">Content</p>
<div className="rounded-md bg-muted p-3 max-h-80 overflow-auto">
<pre className="text-xs font-mono whitespace-pre-wrap break-words">
{plugin.content}
</pre>
</div>
</div>
</div>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
'use client';
import { useState } from 'react';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PluginCard, type SkillInfo } from './PluginCard';
import { HugeiconsIcon } from "@hugeicons/react";
import { Search01Icon, GlobeIcon, FolderOpenIcon, Plug01Icon } from "@hugeicons/core-free-icons";
interface PluginListProps {
plugins: SkillInfo[];
onSelect: (plugin: SkillInfo) => void;
}
export function PluginList({ plugins, onSelect }: PluginListProps) {
const [search, setSearch] = useState('');
const [sourceFilter, setSourceFilter] = useState<'all' | 'global' | 'project' | 'plugin'>('all');
const filtered = plugins.filter((p) => {
const matchesSearch =
p.name.toLowerCase().includes(search.toLowerCase()) ||
p.description.toLowerCase().includes(search.toLowerCase());
if (sourceFilter === 'all') return matchesSearch;
return matchesSearch && p.source === sourceFilter;
});
const globalCount = plugins.filter((p) => p.source === 'global').length;
const projectCount = plugins.filter((p) => p.source === 'project').length;
const pluginCount = plugins.filter((p) => p.source === 'plugin').length;
return (
<div className="space-y-4">
<div className="relative">
<HugeiconsIcon icon={Search01Icon} className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search skills by name..."
className="pl-9"
/>
</div>
<Tabs
value={sourceFilter}
onValueChange={(v) => setSourceFilter(v as 'all' | 'global' | 'project' | 'plugin')}
>
<TabsList>
<TabsTrigger value="all">
All ({plugins.length})
</TabsTrigger>
<TabsTrigger value="global" className="gap-1.5">
<HugeiconsIcon icon={GlobeIcon} className="h-3.5 w-3.5" />
Global ({globalCount})
</TabsTrigger>
<TabsTrigger value="project" className="gap-1.5">
<HugeiconsIcon icon={FolderOpenIcon} className="h-3.5 w-3.5" />
Project ({projectCount})
</TabsTrigger>
{pluginCount > 0 && (
<TabsTrigger value="plugin" className="gap-1.5">
<HugeiconsIcon icon={Plug01Icon} className="h-3.5 w-3.5" />
Plugin ({pluginCount})
</TabsTrigger>
)}
</TabsList>
<TabsContent value={sourceFilter} className="mt-4">
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm">
{plugins.length === 0
? 'No skills found'
: 'No matching skills'}
</p>
<p className="text-xs mt-1">
{plugins.length === 0
? 'Add .md files to ~/.claude/commands/ or .claude/commands/ to create skills'
: 'Try adjusting your search or filter'}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{filtered.map((plugin) => (
<PluginCard
key={plugin.name}
plugin={plugin}
onSelect={onSelect}
/>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}