diff --git a/packages/desktop/src/App.tsx b/packages/desktop/src/App.tsx
index 04b425f..9d230c7 100644
--- a/packages/desktop/src/App.tsx
+++ b/packages/desktop/src/App.tsx
@@ -9,6 +9,7 @@ import {
ConfigPanel,
CommandPanel,
MCPPanel,
+ HooksPanel,
Toaster,
listSessions,
createSession,
@@ -23,6 +24,7 @@ export function App() {
const [showConfig, setShowConfig] = useState(false);
const [showCommands, setShowCommands] = useState(false);
const [showMCP, setShowMCP] = useState(false);
+ const [showHooks, setShowHooks] = useState(false);
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
// 初始化:加载或创建会话
@@ -95,6 +97,7 @@ export function App() {
onOpenConfig={() => setShowConfig(true)}
onOpenCommands={() => setShowCommands(true)}
onOpenMCP={() => setShowMCP(true)}
+ onOpenHooks={() => setShowHooks(true)}
/>
) : (
@@ -124,6 +127,9 @@ export function App() {
{/* MCP 面板 */}
{showMCP && setShowMCP(false)} />}
+ {/* Hooks 面板 */}
+ {showHooks && setShowHooks(false)} />}
+
{/* Toast 通知 */}
diff --git a/packages/desktop/src/pages/Chat.tsx b/packages/desktop/src/pages/Chat.tsx
index c68c8a5..fb0317b 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, Terminal, Plug } from 'lucide-react';
+import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug, Zap } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import {
useChat,
@@ -22,6 +22,7 @@ interface ChatPageProps {
onOpenConfig?: () => void;
onOpenCommands?: () => void;
onOpenMCP?: () => void;
+ onOpenHooks?: () => void;
}
export function ChatPage({
@@ -32,6 +33,7 @@ export function ChatPage({
onOpenConfig,
onOpenCommands,
onOpenMCP,
+ onOpenHooks,
}: ChatPageProps) {
const {
messages,
@@ -125,8 +127,21 @@ export function ChatPage({
{/* 工具栏按钮 */}
- {(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP) && (
+ {(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks) && (
+ {/* Hooks 按钮 */}
+ {onOpenHooks && (
+
+
+
+ )}
+
{/* MCP 按钮 */}
{onOpenMCP && (
Promise;
+ loadHookConfig: (directory: string) => Promise;
+ getConfigFilePath: (directory: string) => Promise;
+ createDefaultConfig: (directory: string) => Promise;
+}
+
+interface ShellCommandConfig {
+ command: string[];
+ environment?: Record;
+ timeout?: number;
+ cwd?: string;
+}
+
+interface FileHookConfig {
+ [pattern: string]: ShellCommandConfig[];
+}
+
+interface HookConfig {
+ file_edited?: FileHookConfig;
+ file_created?: FileHookConfig;
+ file_deleted?: FileHookConfig;
+ session_completed?: ShellCommandConfig[];
+}
+
+interface ProjectConfig {
+ hooks?: HookConfig;
+ plugins?: string[];
+ [key: string]: unknown;
+}
+
+interface HookTestResult {
+ success: boolean;
+ stdout: string;
+ stderr: string;
+ exitCode: number;
+ duration: number;
+}
+
+export const hooksRouter = new Hono();
+
+// Core 模块缓存
+let hooksModule: HooksModule | null = null;
+
+/**
+ * 初始化 Hooks 模块
+ */
+async function initHooksModule(): Promise {
+ if (hooksModule) return hooksModule;
+
+ try {
+ const corePath = '@ai-assistant/core';
+ const core = (await import(corePath)) as Record;
+
+ if (
+ typeof core.loadProjectConfig !== 'function' ||
+ typeof core.loadHookConfig !== 'function' ||
+ typeof core.getConfigFilePath !== 'function' ||
+ typeof core.createDefaultConfig !== 'function'
+ ) {
+ console.warn('[Hooks] Core module missing Hooks exports');
+ return null;
+ }
+
+ hooksModule = {
+ loadProjectConfig: core.loadProjectConfig as HooksModule['loadProjectConfig'],
+ loadHookConfig: core.loadHookConfig as HooksModule['loadHookConfig'],
+ getConfigFilePath: core.getConfigFilePath as HooksModule['getConfigFilePath'],
+ createDefaultConfig: core.createDefaultConfig as HooksModule['createDefaultConfig'],
+ };
+
+ console.log('[Hooks] Hooks module initialized');
+ return hooksModule;
+ } catch (error) {
+ console.warn('[Hooks] Failed to load Hooks module:', error);
+ return null;
+ }
+}
+
+/**
+ * 移除 JSON 中的注释(支持 JSONC 格式)
+ */
+function stripJsonComments(jsonString: string): string {
+ let result = jsonString.replace(/\/\/.*$/gm, '');
+ result = result.replace(/\/\*[\s\S]*?\*\//g, '');
+ return result;
+}
+
+/**
+ * 读取配置文件
+ */
+async function readConfigFile(configPath: string): Promise {
+ try {
+ const content = await fs.readFile(configPath, 'utf-8');
+ const cleanContent = stripJsonComments(content);
+ return JSON.parse(cleanContent);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * 写入配置文件
+ */
+async function writeConfigFile(configPath: string, config: ProjectConfig): Promise {
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2));
+}
+
+/**
+ * 获取或创建配置文件路径
+ */
+async function getOrCreateConfigPath(workdir: string): Promise {
+ const module = await initHooksModule();
+ if (module) {
+ const existingPath = await module.getConfigFilePath(workdir);
+ if (existingPath) return existingPath;
+ }
+ return path.join(workdir, '.ai-assistant.json');
+}
+
+/**
+ * GET /hooks/config - 获取完整钩子配置
+ */
+hooksRouter.get('/config', async (c) => {
+ const module = await initHooksModule();
+
+ if (!module) {
+ return c.json(
+ {
+ success: false,
+ error: 'Hooks module not available',
+ },
+ 503
+ );
+ }
+
+ const config = getConfig();
+ const hookConfig = await module.loadHookConfig(config.workdir);
+
+ return c.json({
+ success: true,
+ data: hookConfig || {
+ file_edited: {},
+ file_created: {},
+ file_deleted: {},
+ session_completed: [],
+ },
+ });
+});
+
+/**
+ * PUT /hooks/config - 更新完整钩子配置
+ */
+hooksRouter.put('/config', async (c) => {
+ const module = await initHooksModule();
+
+ if (!module) {
+ return c.json(
+ {
+ success: false,
+ error: 'Hooks module not available',
+ },
+ 503
+ );
+ }
+
+ try {
+ const newHookConfig = await c.req.json();
+ const config = getConfig();
+ const configPath = await getOrCreateConfigPath(config.workdir);
+
+ // 读取现有配置
+ let projectConfig = await readConfigFile(configPath);
+ if (!projectConfig) {
+ projectConfig = {};
+ }
+
+ // 更新 hooks 部分
+ projectConfig.hooks = newHookConfig;
+
+ // 写入配置文件
+ await writeConfigFile(configPath, projectConfig);
+
+ return c.json({
+ success: true,
+ data: newHookConfig,
+ });
+ } catch (error) {
+ return c.json(
+ {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to update config',
+ },
+ 500
+ );
+ }
+});
+
+/**
+ * GET /hooks/file-edited - 获取 file_edited 钩子
+ */
+hooksRouter.get('/file-edited', async (c) => {
+ const module = await initHooksModule();
+
+ if (!module) {
+ return c.json(
+ {
+ success: false,
+ error: 'Hooks module not available',
+ },
+ 503
+ );
+ }
+
+ const config = getConfig();
+ const hookConfig = await module.loadHookConfig(config.workdir);
+
+ return c.json({
+ success: true,
+ data: hookConfig?.file_edited || {},
+ });
+});
+
+/**
+ * PUT /hooks/file-edited - 更新 file_edited 钩子
+ */
+hooksRouter.put('/file-edited', async (c) => {
+ const module = await initHooksModule();
+
+ if (!module) {
+ return c.json(
+ {
+ success: false,
+ error: 'Hooks module not available',
+ },
+ 503
+ );
+ }
+
+ try {
+ const newFileEditedHooks = await c.req.json();
+ const config = getConfig();
+ const configPath = await getOrCreateConfigPath(config.workdir);
+
+ let projectConfig = await readConfigFile(configPath);
+ if (!projectConfig) {
+ projectConfig = {};
+ }
+ if (!projectConfig.hooks) {
+ projectConfig.hooks = {};
+ }
+
+ projectConfig.hooks.file_edited = newFileEditedHooks;
+ await writeConfigFile(configPath, projectConfig);
+
+ return c.json({
+ success: true,
+ data: newFileEditedHooks,
+ });
+ } catch (error) {
+ return c.json(
+ {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to update file_edited hooks',
+ },
+ 500
+ );
+ }
+});
+
+/**
+ * GET /hooks/file-created - 获取 file_created 钩子
+ */
+hooksRouter.get('/file-created', async (c) => {
+ const module = await initHooksModule();
+
+ if (!module) {
+ return c.json(
+ {
+ success: false,
+ error: 'Hooks module not available',
+ },
+ 503
+ );
+ }
+
+ const config = getConfig();
+ const hookConfig = await module.loadHookConfig(config.workdir);
+
+ return c.json({
+ success: true,
+ data: hookConfig?.file_created || {},
+ });
+});
+
+/**
+ * PUT /hooks/file-created - 更新 file_created 钩子
+ */
+hooksRouter.put('/file-created', async (c) => {
+ const module = await initHooksModule();
+
+ if (!module) {
+ return c.json(
+ {
+ success: false,
+ error: 'Hooks module not available',
+ },
+ 503
+ );
+ }
+
+ try {
+ const newFileCreatedHooks = await c.req.json();
+ const config = getConfig();
+ const configPath = await getOrCreateConfigPath(config.workdir);
+
+ let projectConfig = await readConfigFile(configPath);
+ if (!projectConfig) {
+ projectConfig = {};
+ }
+ if (!projectConfig.hooks) {
+ projectConfig.hooks = {};
+ }
+
+ projectConfig.hooks.file_created = newFileCreatedHooks;
+ await writeConfigFile(configPath, projectConfig);
+
+ return c.json({
+ success: true,
+ data: newFileCreatedHooks,
+ });
+ } catch (error) {
+ return c.json(
+ {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to update file_created hooks',
+ },
+ 500
+ );
+ }
+});
+
+/**
+ * GET /hooks/file-deleted - 获取 file_deleted 钩子
+ */
+hooksRouter.get('/file-deleted', async (c) => {
+ const module = await initHooksModule();
+
+ if (!module) {
+ return c.json(
+ {
+ success: false,
+ error: 'Hooks module not available',
+ },
+ 503
+ );
+ }
+
+ const config = getConfig();
+ const hookConfig = await module.loadHookConfig(config.workdir);
+
+ return c.json({
+ success: true,
+ data: hookConfig?.file_deleted || {},
+ });
+});
+
+/**
+ * PUT /hooks/file-deleted - 更新 file_deleted 钩子
+ */
+hooksRouter.put('/file-deleted', async (c) => {
+ const module = await initHooksModule();
+
+ if (!module) {
+ return c.json(
+ {
+ success: false,
+ error: 'Hooks module not available',
+ },
+ 503
+ );
+ }
+
+ try {
+ const newFileDeletedHooks = await c.req.json();
+ const config = getConfig();
+ const configPath = await getOrCreateConfigPath(config.workdir);
+
+ let projectConfig = await readConfigFile(configPath);
+ if (!projectConfig) {
+ projectConfig = {};
+ }
+ if (!projectConfig.hooks) {
+ projectConfig.hooks = {};
+ }
+
+ projectConfig.hooks.file_deleted = newFileDeletedHooks;
+ await writeConfigFile(configPath, projectConfig);
+
+ return c.json({
+ success: true,
+ data: newFileDeletedHooks,
+ });
+ } catch (error) {
+ return c.json(
+ {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to update file_deleted hooks',
+ },
+ 500
+ );
+ }
+});
+
+/**
+ * GET /hooks/session-completed - 获取 session_completed 钩子
+ */
+hooksRouter.get('/session-completed', async (c) => {
+ const module = await initHooksModule();
+
+ if (!module) {
+ return c.json(
+ {
+ success: false,
+ error: 'Hooks module not available',
+ },
+ 503
+ );
+ }
+
+ const config = getConfig();
+ const hookConfig = await module.loadHookConfig(config.workdir);
+
+ return c.json({
+ success: true,
+ data: hookConfig?.session_completed || [],
+ });
+});
+
+/**
+ * PUT /hooks/session-completed - 更新 session_completed 钩子
+ */
+hooksRouter.put('/session-completed', async (c) => {
+ const module = await initHooksModule();
+
+ if (!module) {
+ return c.json(
+ {
+ success: false,
+ error: 'Hooks module not available',
+ },
+ 503
+ );
+ }
+
+ try {
+ const newSessionCompletedHooks = await c.req.json();
+ const config = getConfig();
+ const configPath = await getOrCreateConfigPath(config.workdir);
+
+ let projectConfig = await readConfigFile(configPath);
+ if (!projectConfig) {
+ projectConfig = {};
+ }
+ if (!projectConfig.hooks) {
+ projectConfig.hooks = {};
+ }
+
+ projectConfig.hooks.session_completed = newSessionCompletedHooks;
+ await writeConfigFile(configPath, projectConfig);
+
+ return c.json({
+ success: true,
+ data: newSessionCompletedHooks,
+ });
+ } catch (error) {
+ return c.json(
+ {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to update session_completed hooks',
+ },
+ 500
+ );
+ }
+});
+
+/**
+ * POST /hooks/test - 测试执行钩子命令
+ */
+hooksRouter.post('/test', async (c) => {
+ try {
+ const commandConfig = await c.req.json();
+ const config = getConfig();
+
+ // 验证命令配置
+ if (!commandConfig.command || !Array.isArray(commandConfig.command) || commandConfig.command.length === 0) {
+ return c.json(
+ {
+ success: false,
+ error: 'Invalid command configuration: command must be a non-empty array',
+ },
+ 400
+ );
+ }
+
+ const startTime = Date.now();
+ const timeout = commandConfig.timeout || 30000;
+ const cwd = commandConfig.cwd || config.workdir;
+
+ // 执行命令
+ const result = await new Promise((resolve) => {
+ const [cmd, ...args] = commandConfig.command;
+ const proc = spawn(cmd, args, {
+ cwd,
+ env: {
+ ...process.env,
+ ...commandConfig.environment,
+ },
+ shell: true,
+ timeout,
+ });
+
+ let stdout = '';
+ let stderr = '';
+
+ proc.stdout?.on('data', (data) => {
+ stdout += data.toString();
+ });
+
+ proc.stderr?.on('data', (data) => {
+ stderr += data.toString();
+ });
+
+ proc.on('close', (code) => {
+ resolve({
+ success: code === 0,
+ stdout,
+ stderr,
+ exitCode: code ?? -1,
+ duration: Date.now() - startTime,
+ });
+ });
+
+ proc.on('error', (error) => {
+ resolve({
+ success: false,
+ stdout,
+ stderr: error.message,
+ exitCode: -1,
+ duration: Date.now() - startTime,
+ });
+ });
+ });
+
+ return c.json({
+ success: true,
+ data: result,
+ });
+ } catch (error) {
+ return c.json(
+ {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to test command',
+ },
+ 500
+ );
+ }
+});
diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts
index 25cea22..5bd4fa4 100644
--- a/packages/server/src/routes/index.ts
+++ b/packages/server/src/routes/index.ts
@@ -10,3 +10,4 @@ export { configRouter, getConfig, setConfig } from './config.js';
export { filesRouter, setWorkingDirectory, getWorkingDirectory } from './files.js';
export { commandsRouter } from './commands.js';
export { mcpRouter } from './mcp.js';
+export { hooksRouter } from './hooks.js';
diff --git a/packages/ui/src/api/client.ts b/packages/ui/src/api/client.ts
index 5f7f262..615ddae 100644
--- a/packages/ui/src/api/client.ts
+++ b/packages/ui/src/api/client.ts
@@ -20,6 +20,10 @@ import type {
MCPServerStatus,
MCPToolInfo,
MCPConfig,
+ HookConfig,
+ FileHookConfig,
+ ShellCommandConfig,
+ HookTestResult,
} from './types.js';
// Re-export types
@@ -45,6 +49,11 @@ export type {
MCPToolInfo,
MCPConfig,
MCPServerConfigInfo,
+ // Hooks types
+ HookConfig,
+ FileHookConfig,
+ ShellCommandConfig,
+ HookTestResult,
} from './types.js';
// API Configuration
@@ -344,3 +353,126 @@ export async function getMCPConfig(): Promise<{
}> {
return request('GET', '/mcp/config');
}
+
+// ============ Hooks API ============
+
+/**
+ * 获取完整钩子配置
+ */
+export async function getHooksConfig(): Promise<{
+ success: boolean;
+ data: HookConfig;
+ error?: string;
+}> {
+ return request('GET', '/hooks/config');
+}
+
+/**
+ * 更新完整钩子配置
+ */
+export async function updateHooksConfig(config: HookConfig): Promise<{
+ success: boolean;
+ data: HookConfig;
+ error?: string;
+}> {
+ return request('PUT', '/hooks/config', config);
+}
+
+/**
+ * 获取 file_edited 钩子配置
+ */
+export async function getFileEditedHooks(): Promise<{
+ success: boolean;
+ data: FileHookConfig;
+ error?: string;
+}> {
+ return request('GET', '/hooks/file-edited');
+}
+
+/**
+ * 更新 file_edited 钩子配置
+ */
+export async function updateFileEditedHooks(hooks: FileHookConfig): Promise<{
+ success: boolean;
+ data: FileHookConfig;
+ error?: string;
+}> {
+ return request('PUT', '/hooks/file-edited', hooks);
+}
+
+/**
+ * 获取 file_created 钩子配置
+ */
+export async function getFileCreatedHooks(): Promise<{
+ success: boolean;
+ data: FileHookConfig;
+ error?: string;
+}> {
+ return request('GET', '/hooks/file-created');
+}
+
+/**
+ * 更新 file_created 钩子配置
+ */
+export async function updateFileCreatedHooks(hooks: FileHookConfig): Promise<{
+ success: boolean;
+ data: FileHookConfig;
+ error?: string;
+}> {
+ return request('PUT', '/hooks/file-created', hooks);
+}
+
+/**
+ * 获取 file_deleted 钩子配置
+ */
+export async function getFileDeletedHooks(): Promise<{
+ success: boolean;
+ data: FileHookConfig;
+ error?: string;
+}> {
+ return request('GET', '/hooks/file-deleted');
+}
+
+/**
+ * 更新 file_deleted 钩子配置
+ */
+export async function updateFileDeletedHooks(hooks: FileHookConfig): Promise<{
+ success: boolean;
+ data: FileHookConfig;
+ error?: string;
+}> {
+ return request('PUT', '/hooks/file-deleted', hooks);
+}
+
+/**
+ * 获取 session_completed 钩子配置
+ */
+export async function getSessionCompletedHooks(): Promise<{
+ success: boolean;
+ data: ShellCommandConfig[];
+ error?: string;
+}> {
+ return request('GET', '/hooks/session-completed');
+}
+
+/**
+ * 更新 session_completed 钩子配置
+ */
+export async function updateSessionCompletedHooks(hooks: ShellCommandConfig[]): Promise<{
+ success: boolean;
+ data: ShellCommandConfig[];
+ error?: string;
+}> {
+ return request('PUT', '/hooks/session-completed', hooks);
+}
+
+/**
+ * 测试执行钩子命令
+ */
+export async function testHookCommand(command: ShellCommandConfig): Promise<{
+ success: boolean;
+ data?: HookTestResult;
+ error?: string;
+}> {
+ return request('POST', '/hooks/test', command);
+}
diff --git a/packages/ui/src/api/types.ts b/packages/ui/src/api/types.ts
index 8b34f0a..68c33c8 100644
--- a/packages/ui/src/api/types.ts
+++ b/packages/ui/src/api/types.ts
@@ -236,3 +236,48 @@ export interface MCPServerConfigInfo {
enabled?: boolean;
timeout?: number;
}
+
+// ============ Hooks 相关 ============
+
+/** Shell 命令配置 */
+export interface ShellCommandConfig {
+ /** 命令数组,如 ['npm', 'run', 'lint'] */
+ command: string[];
+ /** 环境变量 */
+ environment?: Record;
+ /** 超时时间(毫秒) */
+ timeout?: number;
+ /** 工作目录 */
+ cwd?: string;
+}
+
+/** 文件钩子配置 (glob pattern -> commands) */
+export interface FileHookConfig {
+ [pattern: string]: ShellCommandConfig[];
+}
+
+/** 完整钩子配置 */
+export interface HookConfig {
+ /** 文件编辑后触发 */
+ file_edited?: FileHookConfig;
+ /** 文件创建后触发 */
+ file_created?: FileHookConfig;
+ /** 文件删除后触发 */
+ file_deleted?: FileHookConfig;
+ /** 会话结束时触发 */
+ session_completed?: ShellCommandConfig[];
+}
+
+/** 钩子命令测试结果 */
+export interface HookTestResult {
+ /** 是否成功 */
+ success: boolean;
+ /** 标准输出 */
+ stdout: string;
+ /** 标准错误输出 */
+ stderr: string;
+ /** 退出码 */
+ exitCode: number;
+ /** 执行时间(毫秒) */
+ duration: number;
+}
diff --git a/packages/ui/src/components/HookEditor.tsx b/packages/ui/src/components/HookEditor.tsx
new file mode 100644
index 0000000..f8c7765
--- /dev/null
+++ b/packages/ui/src/components/HookEditor.tsx
@@ -0,0 +1,548 @@
+/**
+ * HookEditor Component
+ *
+ * 钩子编辑器:用于添加和编辑单个钩子规则
+ */
+
+import { useState } from 'react';
+import {
+ X,
+ Plus,
+ Trash2,
+ Play,
+ RefreshCw,
+ ChevronDown,
+ ChevronRight,
+} from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { cn } from '../utils/cn';
+import { modalOverlay, modalContent, smoothTransition } from '../utils/animations';
+import { Button } from '../primitives/Button';
+import type { ShellCommandConfig } from '../api/client.js';
+
+type HookType = 'file_edited' | 'file_created' | 'file_deleted' | 'session_completed';
+
+interface HookEditorProps {
+ type: HookType;
+ pattern?: string;
+ commands: ShellCommandConfig[];
+ isNew: boolean;
+ onSave: (type: HookType, pattern: string, commands: ShellCommandConfig[]) => Promise;
+ onCancel: () => void;
+ onTest: (command: ShellCommandConfig, id: string) => Promise;
+ saving: boolean;
+ responsive?: boolean;
+}
+
+interface CommandEditorState {
+ command: string[];
+ environment: Record;
+ timeout?: number;
+ cwd?: string;
+ showAdvanced: boolean;
+}
+
+export function HookEditor({
+ type,
+ pattern: initialPattern,
+ commands: initialCommands,
+ isNew,
+ onSave,
+ onCancel,
+ onTest,
+ saving,
+ responsive = false,
+}: HookEditorProps) {
+ const isFileHook = type !== 'session_completed';
+
+ // 表单状态
+ const [pattern, setPattern] = useState(initialPattern || '');
+ const [commandStates, setCommandStates] = useState(() => {
+ if (initialCommands.length > 0) {
+ return initialCommands.map((cmd) => ({
+ command: cmd.command,
+ environment: cmd.environment || {},
+ timeout: cmd.timeout,
+ cwd: cmd.cwd,
+ showAdvanced: !!(cmd.environment && Object.keys(cmd.environment).length > 0) || !!cmd.timeout || !!cmd.cwd,
+ }));
+ }
+ return [{
+ command: [''],
+ environment: {},
+ timeout: undefined,
+ cwd: undefined,
+ showAdvanced: false,
+ }];
+ });
+
+ const [testingIndex, setTestingIndex] = useState(null);
+ const [errors, setErrors] = useState<{ pattern?: string; commands?: string[] }>({});
+
+ // 验证表单
+ const validate = () => {
+ const newErrors: { pattern?: string; commands?: string[] } = {};
+ const commandErrors: string[] = [];
+
+ if (isFileHook && !pattern.trim()) {
+ newErrors.pattern = 'Pattern is required';
+ }
+
+ commandStates.forEach((state, idx) => {
+ const cmdStr = state.command.filter(Boolean).join(' ').trim();
+ if (!cmdStr) {
+ commandErrors[idx] = 'Command is required';
+ }
+ });
+
+ if (commandErrors.some(Boolean)) {
+ newErrors.commands = commandErrors;
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ // 保存
+ const handleSave = async () => {
+ if (!validate()) return;
+
+ const commands: ShellCommandConfig[] = commandStates.map((state) => {
+ const cmd: ShellCommandConfig = {
+ command: state.command.filter(Boolean),
+ };
+ if (Object.keys(state.environment).length > 0) {
+ cmd.environment = state.environment;
+ }
+ if (state.timeout) {
+ cmd.timeout = state.timeout;
+ }
+ if (state.cwd) {
+ cmd.cwd = state.cwd;
+ }
+ return cmd;
+ });
+
+ await onSave(type, pattern, commands);
+ };
+
+ // 测试命令
+ const handleTest = async (index: number) => {
+ const state = commandStates[index];
+ const cmd: ShellCommandConfig = {
+ command: state.command.filter(Boolean),
+ };
+ if (Object.keys(state.environment).length > 0) {
+ cmd.environment = state.environment;
+ }
+ if (state.timeout) {
+ cmd.timeout = state.timeout;
+ }
+ if (state.cwd) {
+ cmd.cwd = state.cwd;
+ }
+
+ if (cmd.command.length === 0) return;
+
+ setTestingIndex(index);
+ await onTest(cmd, `editor-${index}`);
+ setTestingIndex(null);
+ };
+
+ // 更新命令
+ const updateCommand = (index: number, updates: Partial) => {
+ setCommandStates((prev) => {
+ const newStates = [...prev];
+ newStates[index] = { ...newStates[index], ...updates };
+ return newStates;
+ });
+ };
+
+ // 更新命令参数
+ const updateCommandArg = (cmdIndex: number, argIndex: number, value: string) => {
+ setCommandStates((prev) => {
+ const newStates = [...prev];
+ const newCommand = [...newStates[cmdIndex].command];
+ newCommand[argIndex] = value;
+ newStates[cmdIndex] = { ...newStates[cmdIndex], command: newCommand };
+ return newStates;
+ });
+ };
+
+ // 添加命令参数
+ const addCommandArg = (cmdIndex: number) => {
+ setCommandStates((prev) => {
+ const newStates = [...prev];
+ newStates[cmdIndex] = {
+ ...newStates[cmdIndex],
+ command: [...newStates[cmdIndex].command, ''],
+ };
+ return newStates;
+ });
+ };
+
+ // 删除命令参数
+ const removeCommandArg = (cmdIndex: number, argIndex: number) => {
+ setCommandStates((prev) => {
+ const newStates = [...prev];
+ const newCommand = newStates[cmdIndex].command.filter((_, i) => i !== argIndex);
+ newStates[cmdIndex] = {
+ ...newStates[cmdIndex],
+ command: newCommand.length > 0 ? newCommand : [''],
+ };
+ return newStates;
+ });
+ };
+
+ // 添加环境变量
+ const addEnvVar = (cmdIndex: number) => {
+ setCommandStates((prev) => {
+ const newStates = [...prev];
+ const newEnv = { ...newStates[cmdIndex].environment };
+ newEnv[`VAR_${Object.keys(newEnv).length + 1}`] = '';
+ newStates[cmdIndex] = { ...newStates[cmdIndex], environment: newEnv };
+ return newStates;
+ });
+ };
+
+ // 更新环境变量
+ const updateEnvVar = (cmdIndex: number, oldKey: string, newKey: string, value: string) => {
+ setCommandStates((prev) => {
+ const newStates = [...prev];
+ const newEnv = { ...newStates[cmdIndex].environment };
+ if (oldKey !== newKey) {
+ delete newEnv[oldKey];
+ }
+ newEnv[newKey] = value;
+ newStates[cmdIndex] = { ...newStates[cmdIndex], environment: newEnv };
+ return newStates;
+ });
+ };
+
+ // 删除环境变量
+ const removeEnvVar = (cmdIndex: number, key: string) => {
+ setCommandStates((prev) => {
+ const newStates = [...prev];
+ const newEnv = { ...newStates[cmdIndex].environment };
+ delete newEnv[key];
+ newStates[cmdIndex] = { ...newStates[cmdIndex], environment: newEnv };
+ return newStates;
+ });
+ };
+
+ // 添加新命令
+ const addCommand = () => {
+ setCommandStates((prev) => [
+ ...prev,
+ {
+ command: [''],
+ environment: {},
+ timeout: undefined,
+ cwd: undefined,
+ showAdvanced: false,
+ },
+ ]);
+ };
+
+ // 删除命令
+ const removeCommand = (index: number) => {
+ if (commandStates.length <= 1) return;
+ setCommandStates((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ const title = isNew
+ ? isFileHook ? 'Add File Hook' : 'Add Session Hook'
+ : isFileHook ? 'Edit File Hook' : 'Edit Session Hook';
+
+ return (
+
+
+ e.stopPropagation()}
+ className={cn(
+ 'bg-gray-800 max-h-[85vh] overflow-hidden flex flex-col',
+ responsive
+ ? 'w-full md:w-full md:max-w-lg md:mx-4 rounded-t-2xl md:rounded-lg'
+ : 'rounded-lg w-full max-w-lg mx-4'
+ )}
+ >
+ {/* Header */}
+
+ {responsive && (
+
+ )}
+
+ {title}
+
+
+
+
+ {/* Content */}
+
+ {/* Pattern (for file hooks) */}
+ {isFileHook && (
+
+
+
setPattern(e.target.value)}
+ placeholder="e.g., *.ts, src/**/*.tsx"
+ className={cn(
+ 'w-full px-3 py-2 bg-gray-900 border rounded-lg text-sm',
+ 'focus:outline-none focus:ring-2 focus:ring-primary-500',
+ errors.pattern ? 'border-red-500' : 'border-gray-700'
+ )}
+ />
+ {errors.pattern && (
+
{errors.pattern}
+ )}
+
+ Use glob patterns to match files (e.g., *.ts, **/*.json)
+
+
+ )}
+
+ {/* Commands */}
+
+
+
+ {isFileHook && (
+
+ )}
+
+
+
+ {commandStates.map((state, cmdIndex) => (
+
+ {/* Command args */}
+
+
+
+ Command {commandStates.length > 1 ? cmdIndex + 1 : ''}
+
+
+
+ {commandStates.length > 1 && (
+
+ )}
+
+
+
+
+ {state.command.map((arg, argIndex) => (
+
+ updateCommandArg(cmdIndex, argIndex, e.target.value)}
+ placeholder={argIndex === 0 ? 'command' : 'arg'}
+ className={cn(
+ 'px-2 py-1 bg-gray-800 border border-gray-600 rounded text-sm font-mono',
+ 'focus:outline-none focus:ring-1 focus:ring-primary-500',
+ argIndex === 0 ? 'min-w-[100px]' : 'min-w-[80px]'
+ )}
+ />
+ {state.command.length > 1 && (
+
+ )}
+
+ ))}
+
+
+
+ {errors.commands?.[cmdIndex] && (
+
{errors.commands[cmdIndex]}
+ )}
+
+
+ {/* Advanced options toggle */}
+
+
+ {/* Advanced options */}
+ {state.showAdvanced && (
+
+ {/* Timeout */}
+
+
+ updateCommand(cmdIndex, {
+ timeout: e.target.value ? parseInt(e.target.value) : undefined
+ })}
+ placeholder="30000"
+ className="w-32 px-2 py-1 bg-gray-800 border border-gray-600 rounded text-sm focus:outline-none focus:ring-1 focus:ring-primary-500"
+ />
+
+
+ {/* Working Directory */}
+
+
+ updateCommand(cmdIndex, { cwd: e.target.value || undefined })}
+ placeholder="(project root)"
+ className="w-full px-2 py-1 bg-gray-800 border border-gray-600 rounded text-sm focus:outline-none focus:ring-1 focus:ring-primary-500"
+ />
+
+
+ {/* Environment Variables */}
+
+
+
+
+
+
+
+
+ )}
+
+ ))}
+
+
+
+
+ {/* Footer */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/components/HooksPanel.tsx b/packages/ui/src/components/HooksPanel.tsx
new file mode 100644
index 0000000..b7c908a
--- /dev/null
+++ b/packages/ui/src/components/HooksPanel.tsx
@@ -0,0 +1,616 @@
+/**
+ * HooksPanel Component
+ *
+ * Hooks 钩子配置管理面板:显示和编辑所有钩子类型
+ */
+
+import { useState, useEffect, useCallback } from 'react';
+import {
+ X,
+ RefreshCw,
+ Zap,
+ Plus,
+ ChevronDown,
+ ChevronRight,
+ FileEdit,
+ FilePlus,
+ FileX,
+ CheckCircle,
+ Trash2,
+ Play,
+ Edit2,
+ AlertCircle,
+} 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 { Skeleton } from './Skeleton';
+import { HookEditor } from './HookEditor';
+import {
+ getHooksConfig,
+ updateHooksConfig,
+ testHookCommand,
+ type HookConfig,
+ type FileHookConfig,
+ type ShellCommandConfig,
+} from '../api/client.js';
+
+interface HooksPanelProps {
+ onClose: () => void;
+ /** 是否启用响应式布局 */
+ responsive?: boolean;
+}
+
+type HookType = 'file_edited' | 'file_created' | 'file_deleted' | 'session_completed';
+
+interface EditingHook {
+ type: HookType;
+ pattern?: string; // 仅用于文件钩子
+ command?: ShellCommandConfig;
+ isNew: boolean;
+}
+
+const HOOK_TYPES: { type: HookType; label: string; icon: React.ReactNode; description: string }[] = [
+ { type: 'file_edited', label: 'File Edited', icon: , description: 'Triggered after a file is edited' },
+ { type: 'file_created', label: 'File Created', icon: , description: 'Triggered after a file is created' },
+ { type: 'file_deleted', label: 'File Deleted', icon: , description: 'Triggered after a file is deleted' },
+ { type: 'session_completed', label: 'Session Completed', icon: , description: 'Triggered when a session ends' },
+];
+
+export function HooksPanel({ onClose, responsive = false }: HooksPanelProps) {
+ // 数据状态
+ const [config, setConfig] = useState({});
+ const [expandedTypes, setExpandedTypes] = useState>(new Set(['file_edited']));
+
+ // UI 状态
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [testingCommand, setTestingCommand] = useState(null);
+
+ // 编辑状态
+ const [editingHook, setEditingHook] = useState(null);
+
+ // 加载配置
+ const loadConfig = useCallback(async (showToast = false) => {
+ try {
+ const result = await getHooksConfig();
+ if (result.success) {
+ setConfig(result.data);
+ if (showToast) {
+ toast.success('Configuration refreshed');
+ }
+ } else {
+ toast.error(result.error || 'Failed to load configuration');
+ }
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Failed to load configuration');
+ }
+ }, []);
+
+ // 初始加载
+ useEffect(() => {
+ setLoading(true);
+ loadConfig().finally(() => setLoading(false));
+ }, [loadConfig]);
+
+ // 刷新
+ const handleRefresh = async () => {
+ setRefreshing(true);
+ await loadConfig(true);
+ setRefreshing(false);
+ };
+
+ // 保存配置
+ const saveConfig = async (newConfig: HookConfig) => {
+ setSaving(true);
+ try {
+ const result = await updateHooksConfig(newConfig);
+ if (result.success) {
+ setConfig(result.data);
+ toast.success('Configuration saved');
+ return true;
+ } else {
+ toast.error(result.error || 'Failed to save configuration');
+ return false;
+ }
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Failed to save configuration');
+ return false;
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ // 切换展开
+ const toggleExpanded = (type: HookType) => {
+ const newExpanded = new Set(expandedTypes);
+ if (newExpanded.has(type)) {
+ newExpanded.delete(type);
+ } else {
+ newExpanded.add(type);
+ }
+ setExpandedTypes(newExpanded);
+ };
+
+ // 测试命令
+ const handleTestCommand = async (cmd: ShellCommandConfig, id: string) => {
+ setTestingCommand(id);
+ try {
+ const result = await testHookCommand(cmd);
+ if (result.success && result.data) {
+ if (result.data.success) {
+ toast.success(
+
+
Command succeeded
+
+ Exit code: {result.data.exitCode} ({result.data.duration}ms)
+
+ {result.data.stdout && (
+
+ {result.data.stdout.slice(0, 500)}
+
+ )}
+
+ );
+ } else {
+ toast.error(
+
+
Command failed
+
+ Exit code: {result.data.exitCode} ({result.data.duration}ms)
+
+ {result.data.stderr && (
+
+ {result.data.stderr.slice(0, 500)}
+
+ )}
+
+ );
+ }
+ } else {
+ toast.error(result.error || 'Test failed');
+ }
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Test failed');
+ } finally {
+ setTestingCommand(null);
+ }
+ };
+
+ // 删除文件钩子的某个 pattern
+ const handleDeleteFileHook = async (type: HookType, pattern: string) => {
+ if (type === 'session_completed') return;
+
+ const fileHooks = config[type] as FileHookConfig | undefined;
+ if (!fileHooks) return;
+
+ const newFileHooks = { ...fileHooks };
+ delete newFileHooks[pattern];
+
+ const newConfig = { ...config, [type]: newFileHooks };
+ await saveConfig(newConfig);
+ };
+
+ // 删除 session_completed 的某个命令
+ const handleDeleteSessionHook = async (index: number) => {
+ const commands = config.session_completed || [];
+ const newCommands = commands.filter((_, i) => i !== index);
+ const newConfig = { ...config, session_completed: newCommands };
+ await saveConfig(newConfig);
+ };
+
+ // 打开编辑器 - 文件钩子
+ const openFileHookEditor = (type: HookType, pattern?: string, commands?: ShellCommandConfig[]) => {
+ setEditingHook({
+ type,
+ pattern,
+ command: commands?.[0],
+ isNew: !pattern,
+ });
+ };
+
+ // 打开编辑器 - Session 钩子
+ const openSessionHookEditor = (command?: ShellCommandConfig, index?: number) => {
+ setEditingHook({
+ type: 'session_completed',
+ pattern: index !== undefined ? String(index) : undefined,
+ command,
+ isNew: index === undefined,
+ });
+ };
+
+ // 保存编辑的钩子
+ const handleSaveHook = async (type: HookType, pattern: string, commands: ShellCommandConfig[]) => {
+ let newConfig: HookConfig;
+
+ if (type === 'session_completed') {
+ // Session hook
+ const existingCommands = [...(config.session_completed || [])];
+ const index = editingHook?.pattern !== undefined ? parseInt(editingHook.pattern) : -1;
+
+ if (index >= 0 && index < existingCommands.length) {
+ // 更新现有命令
+ existingCommands[index] = commands[0];
+ } else {
+ // 添加新命令
+ existingCommands.push(commands[0]);
+ }
+
+ newConfig = { ...config, session_completed: existingCommands };
+ } else {
+ // File hook
+ const fileHooks = { ...(config[type] as FileHookConfig || {}) };
+
+ // 如果是重命名 pattern
+ if (editingHook?.pattern && editingHook.pattern !== pattern) {
+ delete fileHooks[editingHook.pattern];
+ }
+
+ fileHooks[pattern] = commands;
+ newConfig = { ...config, [type]: fileHooks };
+ }
+
+ const success = await saveConfig(newConfig);
+ if (success) {
+ setEditingHook(null);
+ }
+ };
+
+ // 统计
+ const totalHooks = Object.values(config).reduce((sum, hooks) => {
+ if (Array.isArray(hooks)) {
+ return sum + hooks.length;
+ }
+ if (hooks && typeof hooks === 'object') {
+ return sum + Object.keys(hooks).length;
+ }
+ return sum;
+ }, 0);
+
+ // Loading 骨架屏
+ const LoadingSkeleton = () => (
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+ );
+
+ // 渲染文件钩子内容
+ const renderFileHooks = (type: HookType) => {
+ const hooks = config[type] as FileHookConfig | undefined;
+ const patterns = Object.keys(hooks || {});
+
+ if (patterns.length === 0) {
+ return (
+
+ No hooks configured
+
+ );
+ }
+
+ return (
+
+ {patterns.map((pattern) => {
+ const commands = hooks![pattern];
+ const cmdId = `${type}-${pattern}`;
+
+ return (
+
+
+
{pattern}
+
+
+
+
+
+
+ {commands.map((cmd, idx) => (
+
+
+ {cmd.command.join(' ')}
+
+
+
+ ))}
+
+
+ );
+ })}
+
+ );
+ };
+
+ // 渲染 session hooks
+ const renderSessionHooks = () => {
+ const commands = config.session_completed || [];
+
+ if (commands.length === 0) {
+ return (
+
+ No hooks configured
+
+ );
+ }
+
+ return (
+
+ {commands.map((cmd, idx) => {
+ const cmdId = `session-${idx}`;
+ return (
+
+
+ {cmd.command.join(' ')}
+
+
+
+
+
+
+
+ );
+ })}
+
+ );
+ };
+
+ 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 && (
+
+ )}
+
+
+
+ Hooks Configuration
+
+
+ {totalHooks} hooks configured
+
+
+
+
+
+
+
+
+ {/* Content */}
+
+ {loading ? (
+
+ ) : (
+
+ {HOOK_TYPES.map(({ type, label, icon, description }) => {
+ const isExpanded = expandedTypes.has(type);
+ const isFileHook = type !== 'session_completed';
+ const hookCount = isFileHook
+ ? Object.keys((config[type] as FileHookConfig) || {}).length
+ : (config.session_completed || []).length;
+
+ return (
+
+ {/* Type Header */}
+ toggleExpanded(type)}
+ >
+
+
+
{icon}
+
+
+
+ {label}
+ {hookCount > 0 && (
+
+ {hookCount}
+
+ )}
+
+
{description}
+
+
+
+
+
+ {/* Expanded Content */}
+
+ {isExpanded && (
+
+
+ {isFileHook ? renderFileHooks(type) : renderSessionHooks()}
+
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+
+ {/* Footer */}
+
+
+
+ Commands are executed in your project directory. Use caution with destructive operations.
+
+
+
+
+
+
+ {/* Hook Editor Modal */}
+ {editingHook && (
+ setEditingHook(null)}
+ onTest={handleTestCommand}
+ saving={saving}
+ responsive={responsive}
+ />
+ )}
+ >
+ );
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 15ee077..5e2d6f8 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -42,6 +42,18 @@ export {
listMCPTools,
getMCPTool,
getMCPConfig,
+ // Hooks API
+ getHooksConfig,
+ updateHooksConfig,
+ getFileEditedHooks,
+ updateFileEditedHooks,
+ getFileCreatedHooks,
+ updateFileCreatedHooks,
+ getFileDeletedHooks,
+ updateFileDeletedHooks,
+ getSessionCompletedHooks,
+ updateSessionCompletedHooks,
+ testHookCommand,
} from './api/client.js';
// Types
@@ -70,6 +82,11 @@ export type {
MCPToolInfo,
MCPConfig,
MCPServerConfigInfo,
+ // Hooks types
+ HookConfig,
+ FileHookConfig,
+ ShellCommandConfig,
+ HookTestResult,
} from './api/client.js';
// Primitives (shadcn/ui style)
@@ -86,6 +103,8 @@ export { CommandMenu, type CommandMenuItem } from './components/CommandMenu.js';
export { CommandPanel } from './components/CommandPanel.js';
export { CommandEditor } from './components/CommandEditor.js';
export { MCPPanel } from './components/MCPPanel.js';
+export { HooksPanel } from './components/HooksPanel.js';
+export { HookEditor } from './components/HookEditor.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 710ac6d..8bc9364 100644
--- a/packages/web/src/App.tsx
+++ b/packages/web/src/App.tsx
@@ -11,6 +11,7 @@ import {
ConfigPanel,
CommandPanel,
MCPPanel,
+ HooksPanel,
Toaster,
listSessions,
createSession,
@@ -25,6 +26,7 @@ export function App() {
const [showConfig, setShowConfig] = useState(false);
const [showCommands, setShowCommands] = useState(false);
const [showMCP, setShowMCP] = useState(false);
+ const [showHooks, setShowHooks] = useState(false);
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
// 初始化:加载或创建会话
@@ -111,6 +113,7 @@ export function App() {
onOpenConfig={() => setShowConfig(true)}
onOpenCommands={() => setShowCommands(true)}
onOpenMCP={() => setShowMCP(true)}
+ onOpenHooks={() => setShowHooks(true)}
/>
) : (
@@ -165,6 +168,9 @@ export function App() {
{/* MCP 面板 */}
{showMCP &&
setShowMCP(false)} responsive />}
+ {/* Hooks 面板 */}
+ {showHooks && setShowHooks(false)} responsive />}
+
{/* 移动端底部文件按钮 */}