diff --git a/packages/desktop/src/App.tsx b/packages/desktop/src/App.tsx
index 332a5b1..2762dab 100644
--- a/packages/desktop/src/App.tsx
+++ b/packages/desktop/src/App.tsx
@@ -7,6 +7,7 @@ import {
Sidebar,
FileBrowser,
ConfigPanel,
+ CommandPanel,
Toaster,
listSessions,
createSession,
@@ -19,6 +20,7 @@ export function App() {
const [isInitializing, setIsInitializing] = useState(true);
const [showFileBrowser, setShowFileBrowser] = useState(false);
const [showConfig, setShowConfig] = useState(false);
+ const [showCommands, setShowCommands] = useState(false);
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
// 初始化:加载或创建会话
@@ -89,6 +91,7 @@ export function App() {
showFileBrowser={showFileBrowser}
onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)}
onOpenConfig={() => setShowConfig(true)}
+ onOpenCommands={() => setShowCommands(true)}
/>
) : (
@@ -112,6 +115,9 @@ export function App() {
{/* 配置面板 */}
{showConfig && setShowConfig(false)} />}
+ {/* 命令面板 */}
+ {showCommands && setShowCommands(false)} />}
+
{/* Toast 通知 */}
diff --git a/packages/desktop/src/pages/Chat.tsx b/packages/desktop/src/pages/Chat.tsx
index 16f7518..638efc2 100644
--- a/packages/desktop/src/pages/Chat.tsx
+++ b/packages/desktop/src/pages/Chat.tsx
@@ -3,7 +3,7 @@
*/
import { useEffect, useRef } from 'react';
-import { WifiOff, MessageSquare, Settings, FolderOpen } from 'lucide-react';
+import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import {
useChat,
@@ -20,6 +20,7 @@ interface ChatPageProps {
showFileBrowser?: boolean;
onToggleFileBrowser?: () => void;
onOpenConfig?: () => void;
+ onOpenCommands?: () => void;
}
export function ChatPage({
@@ -28,6 +29,7 @@ export function ChatPage({
showFileBrowser,
onToggleFileBrowser,
onOpenConfig,
+ onOpenCommands,
}: ChatPageProps) {
const {
messages,
@@ -121,8 +123,21 @@ export function ChatPage({
{/* 工具栏按钮 */}
- {(onOpenConfig || onToggleFileBrowser) && (
+ {(onOpenConfig || onToggleFileBrowser || onOpenCommands) && (
+ {/* 命令按钮 */}
+ {onOpenCommands && (
+
+
+
+ )}
+
{/* 配置按钮 */}
{onOpenConfig && (
void;
+ /** 保存成功回调 */
+ onSaved?: () => void;
+ /** 是否启用响应式布局 */
+ responsive?: boolean;
+}
+
+export function CommandEditor({
+ commandName,
+ onClose,
+ onSaved,
+ responsive = false,
+}: CommandEditorProps) {
+ const isEditMode = !!commandName;
+
+ // 表单状态
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [template, setTemplate] = useState('');
+ const [agent, setAgent] = useState('');
+ const [model, setModel] = useState('');
+ const [subtask, setSubtask] = useState(false);
+ const [scope, setScope] = useState<'user' | 'project'>('user');
+
+ // UI 状态
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [originalData, setOriginalData] = useState(null);
+
+ // 编辑模式:加载现有命令
+ useEffect(() => {
+ if (isEditMode && commandName) {
+ setLoading(true);
+ setError(null);
+
+ getCommandContent(commandName)
+ .then((result) => {
+ if (result.success && result.data) {
+ const data = result.data;
+ setOriginalData(data);
+ setName(data.name);
+ setDescription(data.description || '');
+ setTemplate(data.template);
+ setAgent(data.agent || '');
+ setModel(data.model || '');
+ setSubtask(data.subtask || false);
+ } else {
+ setError(result.error || 'Failed to load command');
+ }
+ })
+ .catch((err) => {
+ setError(err instanceof Error ? err.message : 'Failed to load command');
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ }
+ }, [isEditMode, commandName]);
+
+ // 保存命令
+ const handleSave = async () => {
+ // 验证
+ if (!name.trim()) {
+ toast.error('Command name is required');
+ return;
+ }
+ if (!template.trim()) {
+ toast.error('Template is required');
+ return;
+ }
+
+ setSaving(true);
+ setError(null);
+
+ try {
+ if (isEditMode) {
+ // 更新模式
+ const input: UpdateCommandInput = {
+ description: description || undefined,
+ template,
+ agent: agent || undefined,
+ model: model || undefined,
+ subtask,
+ };
+
+ const result = await updateCommand(commandName!, input);
+ if (result.success) {
+ toast.success('Command updated');
+ onSaved?.();
+ onClose();
+ } else {
+ setError(result.error || 'Failed to update command');
+ }
+ } else {
+ // 创建模式
+ const input: CreateCommandInput = {
+ name: name.trim(),
+ description: description || undefined,
+ template,
+ agent: agent || undefined,
+ model: model || undefined,
+ subtask,
+ scope,
+ };
+
+ const result = await createCommand(input);
+ if (result.success) {
+ toast.success('Command created');
+ onSaved?.();
+ onClose();
+ } else {
+ setError(result.error || 'Failed to create command');
+ }
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to save command');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ // 是否为内置命令(不可编辑)
+ const isBuiltin = originalData?.source === 'builtin';
+
+ return (
+
+
+ e.stopPropagation()}
+ className={cn(
+ 'bg-gray-800 max-h-[90vh] overflow-auto',
+ responsive
+ ? 'w-full md:w-full md:max-w-2xl md:mx-4 rounded-t-2xl md:rounded-lg'
+ : 'rounded-lg w-full max-w-2xl mx-4'
+ )}
+ >
+ {/* Header */}
+
+ {responsive && (
+
+ )}
+
+ {isEditMode ? `Edit Command: /${commandName}` : 'Create Command'}
+
+
+
+
+ {/* Content */}
+ {loading ? (
+
+ ) : (
+
+ {/* Error */}
+ {error && (
+
+
+ {error}
+
+ )}
+
+ {/* Builtin Warning */}
+ {isBuiltin && (
+
+
+ Builtin commands cannot be modified
+
+ )}
+
+ {/* Name */}
+
+
+
setName(e.target.value)}
+ placeholder="deploy/staging"
+ disabled={isEditMode || isBuiltin}
+ className="font-mono"
+ />
+
+ Command name. Use / for nested commands (e.g., deploy/staging)
+
+
+
+ {/* Description */}
+
+
+ setDescription(e.target.value)}
+ placeholder="Deploy to staging environment"
+ disabled={isBuiltin}
+ />
+
+
+ {/* Template */}
+
+
+ {/* Agent & Model */}
+
+
+
+
setAgent(e.target.value)}
+ placeholder="code-review"
+ disabled={isBuiltin}
+ />
+
Optional agent to use
+
+
+
+
+
setModel(e.target.value)}
+ placeholder="claude-sonnet-4-20250514"
+ disabled={isBuiltin}
+ />
+
Optional model override
+
+
+
+ {/* Subtask & Scope */}
+
+
+
+
+
Run as background subtask
+
+
+
+
+ {!isEditMode && (
+
+
+
+
Where to store the command
+
+ )}
+
+
+ {/* Source Info (edit mode) */}
+ {isEditMode && originalData && (
+
+
Command Info
+
+
+ Source:
+ {originalData.source}
+
+ {originalData.sourcePath && (
+
+ Path:
+
+ {originalData.sourcePath}
+
+
+ )}
+
+
+ )}
+
+ )}
+
+ {/* Footer */}
+
+
+ {!isBuiltin && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/components/CommandPanel.tsx b/packages/ui/src/components/CommandPanel.tsx
new file mode 100644
index 0000000..b1ec875
--- /dev/null
+++ b/packages/ui/src/components/CommandPanel.tsx
@@ -0,0 +1,403 @@
+/**
+ * CommandPanel Component
+ *
+ * 命令管理面板:列出、搜索、创建、编辑、删除命令
+ */
+
+import { useState, useEffect, useCallback } from 'react';
+import {
+ X,
+ Plus,
+ Search,
+ Pencil,
+ Trash2,
+ RefreshCw,
+ Terminal,
+ User,
+ FolderOpen,
+ Cog,
+} from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { toast } from 'sonner';
+import { cn } from '../utils/cn';
+import { modalOverlay, modalContent, smoothTransition } from '../utils/animations';
+import { Button } from '../primitives/Button';
+import { Input } from '../primitives/Input';
+import { Skeleton } from './Skeleton';
+import { CommandEditor } from './CommandEditor';
+import {
+ listCommands,
+ deleteCommand,
+ reloadCommands,
+ type CommandListResponse,
+} from '../api/client.js';
+
+interface CommandPanelProps {
+ onClose: () => void;
+ /** 是否启用响应式布局 */
+ responsive?: boolean;
+}
+
+type CommandItem = CommandListResponse['commands'][number];
+
+// Source 图标映射
+function getSourceIcon(source: string) {
+ switch (source) {
+ case 'builtin':
+ return ;
+ case 'user':
+ return ;
+ case 'project':
+ return ;
+ default:
+ return ;
+ }
+}
+
+// Source 标签样式
+function getSourceBadgeClass(source: string) {
+ switch (source) {
+ case 'builtin':
+ return 'bg-gray-700 text-gray-300';
+ case 'user':
+ return 'bg-blue-500/20 text-blue-400';
+ case 'project':
+ return 'bg-green-500/20 text-green-400';
+ default:
+ return 'bg-gray-700 text-gray-300';
+ }
+}
+
+export function CommandPanel({ onClose, responsive = false }: CommandPanelProps) {
+ // 数据状态
+ const [commands, setCommands] = useState([]);
+ const [filteredCommands, setFilteredCommands] = useState([]);
+ const [stats, setStats] = useState<{ total: number; bySource: Record } | null>(
+ null
+ );
+
+ // UI 状态
+ const [loading, setLoading] = useState(true);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [deletingCommand, setDeletingCommand] = useState(null);
+ const [reloading, setReloading] = useState(false);
+
+ // 编辑器状态
+ const [editorOpen, setEditorOpen] = useState(false);
+ const [editingCommand, setEditingCommand] = useState(undefined);
+
+ // 加载命令列表
+ const loadCommands = useCallback(async () => {
+ setLoading(true);
+ try {
+ const result = await listCommands();
+ if (result.success) {
+ setCommands(result.data.commands);
+ setFilteredCommands(result.data.commands);
+ setStats(result.data.stats);
+ } else {
+ toast.error('Failed to load commands');
+ }
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Failed to load commands');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ // 初始加载
+ useEffect(() => {
+ loadCommands();
+ }, [loadCommands]);
+
+ // 搜索过滤
+ useEffect(() => {
+ if (!searchQuery.trim()) {
+ setFilteredCommands(commands);
+ return;
+ }
+
+ const query = searchQuery.toLowerCase();
+ const filtered = commands.filter(
+ (cmd) =>
+ cmd.name.toLowerCase().includes(query) || cmd.description?.toLowerCase().includes(query)
+ );
+ setFilteredCommands(filtered);
+ }, [searchQuery, commands]);
+
+ // 删除命令
+ const handleDelete = async (name: string) => {
+ if (!confirm(`Are you sure you want to delete command "/${name}"?`)) {
+ return;
+ }
+
+ setDeletingCommand(name);
+ try {
+ const result = await deleteCommand(name);
+ if (result.success) {
+ toast.success(`Command "/${name}" deleted`);
+ loadCommands();
+ } else {
+ toast.error(result.error || 'Failed to delete command');
+ }
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Failed to delete command');
+ } finally {
+ setDeletingCommand(null);
+ }
+ };
+
+ // 重新加载命令
+ const handleReload = async () => {
+ setReloading(true);
+ try {
+ const result = await reloadCommands();
+ if (result.success) {
+ toast.success('Commands reloaded');
+ loadCommands();
+ } else {
+ toast.error('Failed to reload commands');
+ }
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Failed to reload commands');
+ } finally {
+ setReloading(false);
+ }
+ };
+
+ // 打开编辑器(创建)
+ const openCreateEditor = () => {
+ setEditingCommand(undefined);
+ setEditorOpen(true);
+ };
+
+ // 打开编辑器(编辑)
+ const openEditEditor = (name: string) => {
+ setEditingCommand(name);
+ setEditorOpen(true);
+ };
+
+ // 编辑器保存回调
+ const handleEditorSaved = () => {
+ loadCommands();
+ };
+
+ // Loading 骨架屏
+ const LoadingSkeleton = () => (
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+ );
+
+ return (
+ <>
+
+
+ e.stopPropagation()}
+ className={cn(
+ 'bg-gray-800 max-h-[90vh] overflow-hidden flex flex-col',
+ responsive
+ ? 'w-full md:w-full md:max-w-2xl md:mx-4 rounded-t-2xl md:rounded-lg'
+ : 'rounded-lg w-full max-w-2xl mx-4'
+ )}
+ >
+ {/* Header */}
+
+ {responsive && (
+
+ )}
+
+
Commands
+ {stats && (
+
+ {stats.total} commands ({stats.bySource.builtin || 0} builtin,{' '}
+ {stats.bySource.user || 0} user, {stats.bySource.project || 0} project)
+
+ )}
+
+
+
+
+ {/* Toolbar */}
+
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="Search commands..."
+ className="pl-9"
+ />
+
+
+
+
+
+ {/* Command List */}
+
+ {loading ? (
+
+ ) : filteredCommands.length === 0 ? (
+
+
+
+ {searchQuery
+ ? `No commands matching "${searchQuery}"`
+ : 'No commands found'}
+
+ {!searchQuery && (
+
+ )}
+
+ ) : (
+
+ {filteredCommands.map((command) => (
+
+ {/* Icon */}
+ {getSourceIcon(command.source)}
+
+ {/* Info */}
+
+
+ /{command.name}
+
+ {command.source}
+
+
+ {command.description && (
+
+ {command.description}
+
+ )}
+
+
+ {/* Actions */}
+
+
+ {command.source !== 'builtin' && (
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ {/* Command Editor */}
+ {editorOpen && (
+ setEditorOpen(false)}
+ onSaved={handleEditorSaved}
+ responsive={responsive}
+ />
+ )}
+ >
+ );
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index b619e29..743c9f0 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -67,6 +67,8 @@ export * from './utils/animations.js';
export { ChatMessage, StreamingMessage, TypingIndicator } from './components/ChatMessage.js';
export { ChatInput } from './components/ChatInput.js';
export { CommandMenu, type CommandMenuItem } from './components/CommandMenu.js';
+export { CommandPanel } from './components/CommandPanel.js';
+export { CommandEditor } from './components/CommandEditor.js';
export { Sidebar } from './components/Sidebar.js';
export { FileBrowser } from './components/FileBrowser.js';
export { ConfigPanel } from './components/ConfigPanel.js';
diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx
index fd42c91..999abf4 100644
--- a/packages/web/src/App.tsx
+++ b/packages/web/src/App.tsx
@@ -9,6 +9,7 @@ import {
Sidebar,
FileBrowser,
ConfigPanel,
+ CommandPanel,
Toaster,
listSessions,
createSession,
@@ -21,6 +22,7 @@ export function App() {
const [isInitializing, setIsInitializing] = useState(true);
const [showFileBrowser, setShowFileBrowser] = useState(false);
const [showConfig, setShowConfig] = useState(false);
+ const [showCommands, setShowCommands] = useState(false);
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
// 初始化:加载或创建会话
@@ -105,6 +107,7 @@ export function App() {
showFileBrowser={showFileBrowser}
onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)}
onOpenConfig={() => setShowConfig(true)}
+ onOpenCommands={() => setShowCommands(true)}
/>
) : (
@@ -153,6 +156,9 @@ export function App() {
{/* 配置面板 */}
{showConfig &&
setShowConfig(false)} responsive />}
+ {/* 命令面板 */}
+ {showCommands && setShowCommands(false)} responsive />}
+
{/* 移动端底部文件按钮 */}