diff --git a/src-tauri/src/commands/agents.rs b/src-tauri/src/commands/agents.rs index 5e61549..1c8d4e1 100644 --- a/src-tauri/src/commands/agents.rs +++ b/src-tauri/src/commands/agents.rs @@ -33,6 +33,7 @@ pub struct Agent { pub enable_file_read: bool, pub enable_file_write: bool, pub enable_network: bool, + pub hooks: Option, // JSON string of hooks configuration pub created_at: String, pub updated_at: String, } @@ -89,6 +90,7 @@ pub struct AgentData { pub system_prompt: String, pub default_task: Option, pub model: String, + pub hooks: Option, } /// Database connection state @@ -235,6 +237,7 @@ pub fn init_database(app: &AppHandle) -> SqliteResult { enable_file_read BOOLEAN NOT NULL DEFAULT 1, enable_file_write BOOLEAN NOT NULL DEFAULT 1, enable_network BOOLEAN NOT NULL DEFAULT 0, + hooks TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )", @@ -247,6 +250,7 @@ pub fn init_database(app: &AppHandle) -> SqliteResult { "ALTER TABLE agents ADD COLUMN model TEXT DEFAULT 'sonnet'", [], ); + let _ = conn.execute("ALTER TABLE agents ADD COLUMN hooks TEXT", []); let _ = conn.execute( "ALTER TABLE agents ADD COLUMN enable_file_read BOOLEAN DEFAULT 1", [], @@ -349,7 +353,7 @@ pub async fn list_agents(db: State<'_, AgentDb>) -> Result, String> { let conn = db.0.lock().map_err(|e| e.to_string())?; let mut stmt = conn - .prepare("SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, created_at, updated_at FROM agents ORDER BY created_at DESC") + .prepare("SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, created_at, updated_at FROM agents ORDER BY created_at DESC") .map_err(|e| e.to_string())?; let agents = stmt @@ -366,8 +370,9 @@ pub async fn list_agents(db: State<'_, AgentDb>) -> Result, String> { enable_file_read: row.get::<_, bool>(6).unwrap_or(true), enable_file_write: row.get::<_, bool>(7).unwrap_or(true), enable_network: row.get::<_, bool>(8).unwrap_or(false), - created_at: row.get(9)?, - updated_at: row.get(10)?, + hooks: row.get(9)?, + created_at: row.get(10)?, + updated_at: row.get(11)?, }) }) .map_err(|e| e.to_string())? @@ -389,6 +394,7 @@ pub async fn create_agent( enable_file_read: Option, enable_file_write: Option, enable_network: Option, + hooks: Option, ) -> Result { let conn = db.0.lock().map_err(|e| e.to_string())?; let model = model.unwrap_or_else(|| "sonnet".to_string()); @@ -397,8 +403,8 @@ pub async fn create_agent( let enable_network = enable_network.unwrap_or(false); conn.execute( - "INSERT INTO agents (name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network], + "INSERT INTO agents (name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks], ) .map_err(|e| e.to_string())?; @@ -407,7 +413,7 @@ pub async fn create_agent( // Fetch the created agent let agent = conn .query_row( - "SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, created_at, updated_at FROM agents WHERE id = ?1", + "SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, created_at, updated_at FROM agents WHERE id = ?1", params![id], |row| { Ok(Agent { @@ -420,8 +426,9 @@ pub async fn create_agent( enable_file_read: row.get(6)?, enable_file_write: row.get(7)?, enable_network: row.get(8)?, - created_at: row.get(9)?, - updated_at: row.get(10)?, + hooks: row.get(9)?, + created_at: row.get(10)?, + updated_at: row.get(11)?, }) }, ) @@ -443,13 +450,14 @@ pub async fn update_agent( enable_file_read: Option, enable_file_write: Option, enable_network: Option, + hooks: Option, ) -> Result { let conn = db.0.lock().map_err(|e| e.to_string())?; let model = model.unwrap_or_else(|| "sonnet".to_string()); // Build dynamic query based on provided parameters let mut query = - "UPDATE agents SET name = ?1, icon = ?2, system_prompt = ?3, default_task = ?4, model = ?5" + "UPDATE agents SET name = ?1, icon = ?2, system_prompt = ?3, default_task = ?4, model = ?5, hooks = ?6" .to_string(); let mut params_vec: Vec> = vec![ Box::new(name), @@ -457,8 +465,9 @@ pub async fn update_agent( Box::new(system_prompt), Box::new(default_task), Box::new(model), + Box::new(hooks), ]; - let mut param_count = 5; + let mut param_count = 6; if let Some(efr) = enable_file_read { param_count += 1; @@ -489,7 +498,7 @@ pub async fn update_agent( // Fetch the updated agent let agent = conn .query_row( - "SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, created_at, updated_at FROM agents WHERE id = ?1", + "SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, created_at, updated_at FROM agents WHERE id = ?1", params![id], |row| { Ok(Agent { @@ -502,8 +511,9 @@ pub async fn update_agent( enable_file_read: row.get(6)?, enable_file_write: row.get(7)?, enable_network: row.get(8)?, - created_at: row.get(9)?, - updated_at: row.get(10)?, + hooks: row.get(9)?, + created_at: row.get(10)?, + updated_at: row.get(11)?, }) }, ) @@ -530,7 +540,7 @@ pub async fn get_agent(db: State<'_, AgentDb>, id: i64) -> Result let agent = conn .query_row( - "SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, created_at, updated_at FROM agents WHERE id = ?1", + "SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, created_at, updated_at FROM agents WHERE id = ?1", params![id], |row| { Ok(Agent { @@ -543,8 +553,9 @@ pub async fn get_agent(db: State<'_, AgentDb>, id: i64) -> Result enable_file_read: row.get::<_, bool>(6).unwrap_or(true), enable_file_write: row.get::<_, bool>(7).unwrap_or(true), enable_network: row.get::<_, bool>(8).unwrap_or(false), - created_at: row.get(9)?, - updated_at: row.get(10)?, + hooks: row.get(9)?, + created_at: row.get(10)?, + updated_at: row.get(11)?, }) }, ) @@ -683,6 +694,42 @@ pub async fn execute_agent( // Get the agent from database let agent = get_agent(db.clone(), agent_id).await?; let execution_model = model.unwrap_or(agent.model.clone()); + + // Create .claude/settings.json with agent hooks if it doesn't exist + if let Some(hooks_json) = &agent.hooks { + let claude_dir = std::path::Path::new(&project_path).join(".claude"); + let settings_path = claude_dir.join("settings.json"); + + // Create .claude directory if it doesn't exist + if !claude_dir.exists() { + std::fs::create_dir_all(&claude_dir) + .map_err(|e| format!("Failed to create .claude directory: {}", e))?; + info!("Created .claude directory at: {:?}", claude_dir); + } + + // Check if settings.json already exists + if !settings_path.exists() { + // Parse the hooks JSON + let hooks: serde_json::Value = serde_json::from_str(hooks_json) + .map_err(|e| format!("Failed to parse agent hooks: {}", e))?; + + // Create a settings object with just the hooks + let settings = serde_json::json!({ + "hooks": hooks + }); + + // Write the settings file + let settings_content = serde_json::to_string_pretty(&settings) + .map_err(|e| format!("Failed to serialize settings: {}", e))?; + + std::fs::write(&settings_path, settings_content) + .map_err(|e| format!("Failed to write settings.json: {}", e))?; + + info!("Created settings.json with agent hooks at: {:?}", settings_path); + } else { + info!("settings.json already exists at: {:?}", settings_path); + } + } // Create a new run record let run_id = { @@ -1719,7 +1766,7 @@ pub async fn export_agent(db: State<'_, AgentDb>, id: i64) -> Result, id: i64) -> Result(1)?, "system_prompt": row.get::<_, String>(2)?, "default_task": row.get::<_, Option>(3)?, - "model": row.get::<_, String>(4)? + "model": row.get::<_, String>(4)?, + "hooks": row.get::<_, Option>(5)? })) }, ) @@ -2023,13 +2071,14 @@ pub async fn import_agent(db: State<'_, AgentDb>, json_data: String) -> Result, json_data: String) -> Result, json_data: String) -> Result, @@ -2174,17 +2174,148 @@ pub async fn track_session_messages( project_path: String, messages: Vec, ) -> Result<(), String> { - let mgr = state + log::info!( + "Tracking {} messages for session {}", + messages.len(), + session_id + ); + + let manager = state .get_or_create_manager( - session_id, - project_id, - std::path::PathBuf::from(project_path), + session_id.clone(), + project_id.clone(), + PathBuf::from(&project_path), ) .await - .map_err(|e| e.to_string())?; + .map_err(|e| format!("Failed to get checkpoint manager: {}", e))?; - for m in messages { - mgr.track_message(m).await.map_err(|e| e.to_string())?; + for message in messages { + manager + .track_message(message) + .await + .map_err(|e| format!("Failed to track message: {}", e))?; } + Ok(()) } + +/// Gets hooks configuration from settings at specified scope +#[tauri::command] +pub async fn get_hooks_config(scope: String, project_path: Option) -> Result { + log::info!("Getting hooks config for scope: {}, project: {:?}", scope, project_path); + + let settings_path = match scope.as_str() { + "user" => { + get_claude_dir() + .map_err(|e| e.to_string())? + .join("settings.json") + }, + "project" => { + let path = project_path.ok_or("Project path required for project scope")?; + PathBuf::from(path).join(".claude").join("settings.json") + }, + "local" => { + let path = project_path.ok_or("Project path required for local scope")?; + PathBuf::from(path).join(".claude").join("settings.local.json") + }, + _ => return Err("Invalid scope".to_string()) + }; + + if !settings_path.exists() { + log::info!("Settings file does not exist at {:?}, returning empty hooks", settings_path); + return Ok(serde_json::json!({})); + } + + let content = fs::read_to_string(&settings_path) + .map_err(|e| format!("Failed to read settings: {}", e))?; + + let settings: serde_json::Value = serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse settings: {}", e))?; + + Ok(settings.get("hooks").cloned().unwrap_or(serde_json::json!({}))) +} + +/// Updates hooks configuration in settings at specified scope +#[tauri::command] +pub async fn update_hooks_config( + scope: String, + hooks: serde_json::Value, + project_path: Option +) -> Result { + log::info!("Updating hooks config for scope: {}, project: {:?}", scope, project_path); + + let settings_path = match scope.as_str() { + "user" => { + get_claude_dir() + .map_err(|e| e.to_string())? + .join("settings.json") + }, + "project" => { + let path = project_path.ok_or("Project path required for project scope")?; + let claude_dir = PathBuf::from(path).join(".claude"); + fs::create_dir_all(&claude_dir) + .map_err(|e| format!("Failed to create .claude directory: {}", e))?; + claude_dir.join("settings.json") + }, + "local" => { + let path = project_path.ok_or("Project path required for local scope")?; + let claude_dir = PathBuf::from(path).join(".claude"); + fs::create_dir_all(&claude_dir) + .map_err(|e| format!("Failed to create .claude directory: {}", e))?; + claude_dir.join("settings.local.json") + }, + _ => return Err("Invalid scope".to_string()) + }; + + // Read existing settings or create new + let mut settings = if settings_path.exists() { + let content = fs::read_to_string(&settings_path) + .map_err(|e| format!("Failed to read settings: {}", e))?; + serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse settings: {}", e))? + } else { + serde_json::json!({}) + }; + + // Update hooks section + settings["hooks"] = hooks; + + // Write back with pretty formatting + let json_string = serde_json::to_string_pretty(&settings) + .map_err(|e| format!("Failed to serialize settings: {}", e))?; + + fs::write(&settings_path, json_string) + .map_err(|e| format!("Failed to write settings: {}", e))?; + + Ok("Hooks configuration updated successfully".to_string()) +} + +/// Validates a hook command by dry-running it +#[tauri::command] +pub async fn validate_hook_command(command: String) -> Result { + log::info!("Validating hook command syntax"); + + // Validate syntax without executing + let mut cmd = std::process::Command::new("bash"); + cmd.arg("-n") // Syntax check only + .arg("-c") + .arg(&command); + + match cmd.output() { + Ok(output) => { + if output.status.success() { + Ok(serde_json::json!({ + "valid": true, + "message": "Command syntax is valid" + })) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Ok(serde_json::json!({ + "valid": false, + "message": format!("Syntax error: {}", stderr) + })) + } + } + Err(e) => Err(format!("Failed to validate command: {}", e)) + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 5302b6e..c9f9cd1 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -26,6 +26,7 @@ use commands::claude::{ open_new_session, read_claude_md_file, restore_checkpoint, resume_claude_code, save_claude_md_file, save_claude_settings, save_system_prompt, search_files, track_checkpoint_message, track_session_messages, update_checkpoint_settings, + get_hooks_config, update_hooks_config, validate_hook_command, ClaudeProcessState, }; use commands::mcp::{ @@ -110,6 +111,9 @@ fn main() { list_directory_contents, search_files, get_recently_modified_files, + get_hooks_config, + update_hooks_config, + validate_hook_command, // Checkpoint Management create_checkpoint, diff --git a/src/App.tsx b/src/App.tsx index 9f0e6e0..5f9629c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,8 +19,23 @@ import { MCPManager } from "@/components/MCPManager"; import { NFOCredits } from "@/components/NFOCredits"; import { ClaudeBinaryDialog } from "@/components/ClaudeBinaryDialog"; import { Toast, ToastContainer } from "@/components/ui/toast"; +import { ProjectSettings } from '@/components/ProjectSettings'; -type View = "welcome" | "projects" | "agents" | "editor" | "settings" | "claude-file-editor" | "claude-code-session" | "usage-dashboard" | "mcp"; +type View = + | "welcome" + | "projects" + | "editor" + | "claude-file-editor" + | "claude-code-session" + | "settings" + | "cc-agents" + | "create-agent" + | "github-agents" + | "agent-execution" + | "agent-run-view" + | "mcp" + | "usage-dashboard" + | "project-settings"; /** * Main App component - Manages the Claude directory browser UI @@ -39,6 +54,8 @@ function App() { const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null); const [activeClaudeSessionId, setActiveClaudeSessionId] = useState(null); const [isClaudeStreaming, setIsClaudeStreaming] = useState(false); + const [projectForSettings, setProjectForSettings] = useState(null); + const [previousView, setPreviousView] = useState("welcome"); // Load projects on mount when in projects view useEffect(() => { @@ -157,6 +174,31 @@ function App() { setView(newView); }; + /** + * Handles navigating to hooks configuration + */ + const handleProjectSettings = (project: Project) => { + setProjectForSettings(project); + handleViewChange("project-settings"); + }; + + /** + * Handles navigating to hooks configuration from a project path + */ + const handleProjectSettingsFromPath = (projectPath: string) => { + // Create a temporary project object from the path + const projectId = projectPath.replace(/[^a-zA-Z0-9]/g, '-'); + const tempProject: Project = { + id: projectId, + path: projectPath, + sessions: [], + created_at: Date.now() / 1000 + }; + setProjectForSettings(tempProject); + setPreviousView(view); + handleViewChange("project-settings"); + }; + const renderContent = () => { switch (view) { case "welcome": @@ -186,7 +228,7 @@ function App() { > handleViewChange("agents")} + onClick={() => handleViewChange("cc-agents")} >
@@ -217,11 +259,11 @@ function App() {
); - case "agents": + case "cc-agents": return ( -
- handleViewChange("welcome")} /> -
+ handleViewChange("welcome")} + /> ); case "editor": @@ -334,6 +376,9 @@ function App() { ) : (
@@ -370,6 +415,7 @@ function App() { setIsClaudeStreaming(isStreaming); setActiveClaudeSessionId(sessionId); }} + onProjectSettings={handleProjectSettingsFromPath} /> ); @@ -383,6 +429,20 @@ function App() { handleViewChange("welcome")} /> ); + case "project-settings": + if (projectForSettings) { + return ( + { + setProjectForSettings(null); + handleViewChange(previousView || "projects"); + }} + /> + ); + } + break; + default: return null; } diff --git a/src/components/AgentExecution.tsx b/src/components/AgentExecution.tsx index f929560..e267eaf 100644 --- a/src/components/AgentExecution.tsx +++ b/src/components/AgentExecution.tsx @@ -11,12 +11,21 @@ import { Copy, ChevronDown, Maximize2, - X + X, + Settings2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover } from "@/components/ui/popover"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { api, type Agent } from "@/lib/api"; import { cn } from "@/lib/utils"; import { open } from "@tauri-apps/plugin-dialog"; @@ -25,6 +34,8 @@ import { StreamMessage } from "./StreamMessage"; import { ExecutionControlBar } from "./ExecutionControlBar"; import { ErrorBoundary } from "./ErrorBoundary"; import { useVirtualizer } from "@tanstack/react-virtual"; +import { AGENT_ICONS } from "./CCAgents"; +import { HooksEditor } from "./HooksEditor"; interface AgentExecutionProps { /** @@ -78,6 +89,10 @@ export const AgentExecution: React.FC = ({ const [error, setError] = useState(null); const [copyPopoverOpen, setCopyPopoverOpen] = useState(false); + // Hooks configuration state + const [isHooksDialogOpen, setIsHooksDialogOpen] = useState(false); + const [activeHooksTab, setActiveHooksTab] = useState("project"); + // Execution stats const [executionStartTime, setExecutionStartTime] = useState(null); const [totalTokens, setTotalTokens] = useState(0); @@ -266,6 +281,10 @@ export const AgentExecution: React.FC = ({ } }; + const handleOpenHooksDialog = async () => { + setIsHooksDialogOpen(true); + }; + const handleExecute = async () => { try { setIsRunning(true); @@ -501,86 +520,41 @@ export const AgentExecution: React.FC = ({ initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }} - className="flex items-center justify-between p-4" + className="p-6" > -
- -
- {renderIcon()} -
-
-

{agent.name}

- {isRunning && ( -
-
- Running -
- )} +
+
+ +
+
+ {renderIcon()} +
+
+

Execute: {agent.name}

+

+ {model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'} +

-

- {isRunning ? "Click back to return to main menu - view in CC Agents > Running Sessions" : "Execute CC Agent"} -

-
- -
- {messages.length > 0 && ( - <> - - - - Copy Output - - - } - content={ -
- - -
- } - open={copyPopoverOpen} - onOpenChange={setCopyPopoverOpen} - align="end" - /> - - )} +
+ +
@@ -620,6 +594,15 @@ export const AgentExecution: React.FC = ({ > +
@@ -931,9 +914,56 @@ export const AgentExecution: React.FC = ({
)} + + {/* Hooks Configuration Dialog */} + + + + Configure Hooks + + Configure hooks that run before, during, and after tool executions. Changes are saved immediately. + + + + + + Project Settings + Local Settings + + + +
+

+ Project hooks are stored in .claude/settings.json and + are committed to version control. +

+ +
+
+ + +
+

+ Local hooks are stored in .claude/settings.local.json and + are not committed to version control. +

+ +
+
+
+
+
); }; - -// Import AGENT_ICONS for icon rendering -import { AGENT_ICONS } from "./CCAgents"; diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index 4aec76c..f2589da 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -8,7 +8,6 @@ import { ChevronDown, GitBranch, Settings, - Globe, ChevronUp, X, Hash @@ -46,6 +45,10 @@ interface ClaudeCodeSessionProps { * Callback to go back */ onBack: () => void; + /** + * Callback to open hooks configuration + */ + onProjectSettings?: (projectPath: string) => void; /** * Optional className for styling */ @@ -66,6 +69,7 @@ export const ClaudeCodeSession: React.FC = ({ session, initialProjectPath = "", onBack, + onProjectSettings, className, onStreamingChange, }) => { @@ -792,8 +796,6 @@ export const ClaudeCodeSession: React.FC = ({ // Keep the previewUrl so it can be restored when reopening }; - - const handlePreviewUrlChange = (url: string) => { console.log('[ClaudeCodeSession] Preview URL changed to:', url); setPreviewUrl(url); @@ -971,107 +973,110 @@ export const ClaudeCodeSession: React.FC = ({
- -
-

Claude Code Session

-

- {session ? `Resuming session ${session.id.slice(0, 8)}...` : 'Interactive session'} + +

+

Claude Code Session

+

+ {projectPath ? `${projectPath}` : "No project selected"}

- {effectiveSession && ( - <> - - - + {projectPath && onProjectSettings && ( + )} - - {/* Preview Button */} - - - - - - - {showPreview - ? "Close the preview pane" - : "Open a browser preview to test your web applications" +
+ {showSettings && ( + + )} + + + + + + +

Checkpoint Settings

+
+
+
+ {effectiveSession && ( + + + + + + +

Timeline Navigator

+
+
+
+ )} + {messages.length > 0 && ( + + + Copy Output + + } - - - - - {messages.length > 0 && ( - - - Copy Output - - - } - content={ -
- - -
- } - open={copyPopoverOpen} - onOpenChange={setCopyPopoverOpen} - /> - )} + content={ +
+ + +
+ } + open={copyPopoverOpen} + onOpenChange={setCopyPopoverOpen} + /> + )} +
diff --git a/src/components/CreateAgent.tsx b/src/components/CreateAgent.tsx index 3ea9141..3c1ffec 100644 --- a/src/components/CreateAgent.tsx +++ b/src/components/CreateAgent.tsx @@ -11,6 +11,7 @@ import MDEditor from "@uiw/react-md-editor"; import { type AgentIconName } from "./CCAgents"; import { IconPicker, ICON_MAP } from "./IconPicker"; + interface CreateAgentProps { /** * Optional agent to edit (if provided, component is in edit mode) @@ -175,11 +176,11 @@ export const CreateAgent: React.FC = ({ transition={{ duration: 0.3, delay: 0.1 }} className="space-y-6" > - {/* Basic Information */} -
-
-

Basic Information

-
+ {/* Basic Information */} +
+
+

Basic Information

+
{/* Name and Icon */}
@@ -292,8 +293,6 @@ export const CreateAgent: React.FC = ({

- - {/* System Prompt Editor */}
@@ -314,28 +313,28 @@ export const CreateAgent: React.FC = ({
- - {/* Toast Notification */} - - {toast && ( - setToast(null)} - /> - )} - - - {/* Icon Picker Dialog */} - { - setSelectedIcon(iconName as AgentIconName); - setShowIconPicker(false); - }} - isOpen={showIconPicker} - onClose={() => setShowIconPicker(false)} + + {/* Toast Notification */} + + {toast && ( + setToast(null)} /> -
+ )} + + + {/* Icon Picker Dialog */} + { + setSelectedIcon(iconName as AgentIconName); + setShowIconPicker(false); + }} + isOpen={showIconPicker} + onClose={() => setShowIconPicker(false)} + /> + ); }; diff --git a/src/components/HooksEditor.tsx b/src/components/HooksEditor.tsx new file mode 100644 index 0000000..142b529 --- /dev/null +++ b/src/components/HooksEditor.tsx @@ -0,0 +1,930 @@ +/** + * HooksEditor component for managing Claude Code hooks configuration + */ + +import React, { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Plus, + Trash2, + AlertTriangle, + Code2, + Terminal, + FileText, + ChevronRight, + ChevronDown, + Clock, + Zap, + Shield, + PlayCircle, + Info, + Save, + Loader2 +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card } from '@/components/ui/card'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { Textarea } from '@/components/ui/textarea'; +import { Badge } from '@/components/ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { HooksManager } from '@/lib/hooksManager'; +import { api } from '@/lib/api'; +import { + HooksConfiguration, + HookEvent, + HookMatcher, + HookCommand, + HookTemplate, + COMMON_TOOL_MATCHERS, + HOOK_TEMPLATES, +} from '@/types/hooks'; + +interface HooksEditorProps { + projectPath?: string; + scope: 'project' | 'local' | 'user'; + readOnly?: boolean; + className?: string; + onChange?: (hasChanges: boolean, getHooks: () => HooksConfiguration) => void; + hideActions?: boolean; +} + +interface EditableHookCommand extends HookCommand { + id: string; +} + +interface EditableHookMatcher extends Omit { + id: string; + hooks: EditableHookCommand[]; + expanded?: boolean; +} + +const EVENT_INFO: Record = { + PreToolUse: { + label: 'Pre Tool Use', + description: 'Runs before tool calls, can block and provide feedback', + icon: + }, + PostToolUse: { + label: 'Post Tool Use', + description: 'Runs after successful tool completion', + icon: + }, + Notification: { + label: 'Notification', + description: 'Customizes notifications when Claude needs attention', + icon: + }, + Stop: { + label: 'Stop', + description: 'Runs when Claude finishes responding', + icon: + }, + SubagentStop: { + label: 'Subagent Stop', + description: 'Runs when a Claude subagent (Task) finishes', + icon: + } +}; + +export const HooksEditor: React.FC = ({ + projectPath, + scope, + readOnly = false, + className, + onChange, + hideActions = false +}) => { + const [selectedEvent, setSelectedEvent] = useState('PreToolUse'); + const [showTemplateDialog, setShowTemplateDialog] = useState(false); + const [validationErrors, setValidationErrors] = useState([]); + const [validationWarnings, setValidationWarnings] = useState([]); + const isInitialMount = React.useRef(true); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [hooks, setHooks] = useState({}); + + // Events with matchers (tool-related) + const matcherEvents = ['PreToolUse', 'PostToolUse'] as const; + // Events without matchers (non-tool-related) + const directEvents = ['Notification', 'Stop', 'SubagentStop'] as const; + + // Convert hooks to editable format with IDs + const [editableHooks, setEditableHooks] = useState<{ + PreToolUse: EditableHookMatcher[]; + PostToolUse: EditableHookMatcher[]; + Notification: EditableHookCommand[]; + Stop: EditableHookCommand[]; + SubagentStop: EditableHookCommand[]; + }>(() => { + const result = { + PreToolUse: [], + PostToolUse: [], + Notification: [], + Stop: [], + SubagentStop: [] + } as any; + + // Initialize matcher events + matcherEvents.forEach(event => { + const matchers = hooks?.[event] as HookMatcher[] | undefined; + if (matchers && Array.isArray(matchers)) { + result[event] = matchers.map(matcher => ({ + ...matcher, + id: HooksManager.generateId(), + expanded: false, + hooks: (matcher.hooks || []).map(hook => ({ + ...hook, + id: HooksManager.generateId() + })) + })); + } + }); + + // Initialize direct events + directEvents.forEach(event => { + const commands = hooks?.[event] as HookCommand[] | undefined; + if (commands && Array.isArray(commands)) { + result[event] = commands.map(hook => ({ + ...hook, + id: HooksManager.generateId() + })); + } + }); + + return result; + }); + + // Load hooks when projectPath or scope changes + useEffect(() => { + // For user scope, we don't need a projectPath + if (scope === 'user' || projectPath) { + setIsLoading(true); + setLoadError(null); + + api.getHooksConfig(scope, projectPath) + .then((config) => { + setHooks(config || {}); + setHasUnsavedChanges(false); + }) + .catch((err) => { + console.error("Failed to load hooks configuration:", err); + setLoadError(err instanceof Error ? err.message : "Failed to load hooks configuration"); + setHooks({}); + }) + .finally(() => { + setIsLoading(false); + }); + } else { + // No projectPath for project/local scopes + setHooks({}); + } + }, [projectPath, scope]); + + // Reset initial mount flag when hooks prop changes + useEffect(() => { + isInitialMount.current = true; + setHasUnsavedChanges(false); // Reset unsaved changes when hooks prop changes + + // Reinitialize editable hooks when hooks prop changes + const result = { + PreToolUse: [], + PostToolUse: [], + Notification: [], + Stop: [], + SubagentStop: [] + } as any; + + // Initialize matcher events + matcherEvents.forEach(event => { + const matchers = hooks?.[event] as HookMatcher[] | undefined; + if (matchers && Array.isArray(matchers)) { + result[event] = matchers.map(matcher => ({ + ...matcher, + id: HooksManager.generateId(), + expanded: false, + hooks: (matcher.hooks || []).map(hook => ({ + ...hook, + id: HooksManager.generateId() + })) + })); + } + }); + + // Initialize direct events + directEvents.forEach(event => { + const commands = hooks?.[event] as HookCommand[] | undefined; + if (commands && Array.isArray(commands)) { + result[event] = commands.map(hook => ({ + ...hook, + id: HooksManager.generateId() + })); + } + }); + + setEditableHooks(result); + }, [hooks]); + + // Track changes when editable hooks change (but don't save automatically) + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false; + return; + } + + setHasUnsavedChanges(true); + }, [editableHooks]); + + // Notify parent of changes + useEffect(() => { + if (onChange) { + const getHooks = () => { + const newHooks: HooksConfiguration = {}; + + // Handle matcher events + matcherEvents.forEach(event => { + const matchers = editableHooks[event]; + if (matchers.length > 0) { + newHooks[event] = matchers.map(({ id, expanded, ...matcher }) => ({ + ...matcher, + hooks: matcher.hooks.map(({ id, ...hook }) => hook) + })); + } + }); + + // Handle direct events + directEvents.forEach(event => { + const commands = editableHooks[event]; + if (commands.length > 0) { + newHooks[event] = commands.map(({ id, ...hook }) => hook); + } + }); + + return newHooks; + }; + + onChange(hasUnsavedChanges, getHooks); + } + }, [hasUnsavedChanges, editableHooks, onChange]); + + // Save function to be called explicitly + const handleSave = async () => { + if (scope !== 'user' && !projectPath) return; + + setIsSaving(true); + + const newHooks: HooksConfiguration = {}; + + // Handle matcher events + matcherEvents.forEach(event => { + const matchers = editableHooks[event]; + if (matchers.length > 0) { + newHooks[event] = matchers.map(({ id, expanded, ...matcher }) => ({ + ...matcher, + hooks: matcher.hooks.map(({ id, ...hook }) => hook) + })); + } + }); + + // Handle direct events + directEvents.forEach(event => { + const commands = editableHooks[event]; + if (commands.length > 0) { + newHooks[event] = commands.map(({ id, ...hook }) => hook); + } + }); + + try { + await api.updateHooksConfig(scope, newHooks, projectPath); + setHooks(newHooks); + setHasUnsavedChanges(false); + } catch (error) { + console.error('Failed to save hooks:', error); + setLoadError(error instanceof Error ? error.message : 'Failed to save hooks'); + } finally { + setIsSaving(false); + } + }; + + const addMatcher = (event: HookEvent) => { + // Only for events with matchers + if (!matcherEvents.includes(event as any)) return; + + const newMatcher: EditableHookMatcher = { + id: HooksManager.generateId(), + matcher: '', + hooks: [], + expanded: true + }; + + setEditableHooks(prev => ({ + ...prev, + [event]: [...(prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]), newMatcher] + })); + }; + + const addDirectCommand = (event: HookEvent) => { + // Only for events without matchers + if (!directEvents.includes(event as any)) return; + + const newCommand: EditableHookCommand = { + id: HooksManager.generateId(), + type: 'command', + command: '' + }; + + setEditableHooks(prev => ({ + ...prev, + [event]: [...(prev[event as 'Notification' | 'Stop' | 'SubagentStop'] as EditableHookCommand[]), newCommand] + })); + }; + + const updateMatcher = (event: HookEvent, matcherId: string, updates: Partial) => { + if (!matcherEvents.includes(event as any)) return; + + setEditableHooks(prev => ({ + ...prev, + [event]: (prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).map(matcher => + matcher.id === matcherId ? { ...matcher, ...updates } : matcher + ) + })); + }; + + const removeMatcher = (event: HookEvent, matcherId: string) => { + if (!matcherEvents.includes(event as any)) return; + + setEditableHooks(prev => ({ + ...prev, + [event]: (prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).filter(matcher => matcher.id !== matcherId) + })); + }; + + const updateDirectCommand = (event: HookEvent, commandId: string, updates: Partial) => { + if (!directEvents.includes(event as any)) return; + + setEditableHooks(prev => ({ + ...prev, + [event]: (prev[event as 'Notification' | 'Stop' | 'SubagentStop'] as EditableHookCommand[]).map(cmd => + cmd.id === commandId ? { ...cmd, ...updates } : cmd + ) + })); + }; + + const removeDirectCommand = (event: HookEvent, commandId: string) => { + if (!directEvents.includes(event as any)) return; + + setEditableHooks(prev => ({ + ...prev, + [event]: (prev[event as 'Notification' | 'Stop' | 'SubagentStop'] as EditableHookCommand[]).filter(cmd => cmd.id !== commandId) + })); + }; + + const applyTemplate = (template: HookTemplate) => { + if (matcherEvents.includes(template.event as any)) { + // For events with matchers + const newMatcher: EditableHookMatcher = { + id: HooksManager.generateId(), + matcher: template.matcher, + hooks: template.commands.map(cmd => ({ + id: HooksManager.generateId(), + type: 'command' as const, + command: cmd + })), + expanded: true + }; + + setEditableHooks(prev => ({ + ...prev, + [template.event]: [...(prev[template.event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]), newMatcher] + })); + } else { + // For direct events + const newCommands: EditableHookCommand[] = template.commands.map(cmd => ({ + id: HooksManager.generateId(), + type: 'command' as const, + command: cmd + })); + + setEditableHooks(prev => ({ + ...prev, + [template.event]: [...(prev[template.event as 'Notification' | 'Stop' | 'SubagentStop'] as EditableHookCommand[]), ...newCommands] + })); + } + + setSelectedEvent(template.event); + setShowTemplateDialog(false); + }; + + const validateHooks = async () => { + if (!hooks) { + setValidationErrors([]); + setValidationWarnings([]); + return; + } + + const result = await HooksManager.validateConfig(hooks); + setValidationErrors(result.errors.map(e => e.message)); + setValidationWarnings(result.warnings.map(w => `${w.message} in command: ${(w.command || '').substring(0, 50)}...`)); + }; + + useEffect(() => { + validateHooks(); + }, [hooks]); + + const addCommand = (event: HookEvent, matcherId: string) => { + if (!matcherEvents.includes(event as any)) return; + + const newCommand: EditableHookCommand = { + id: HooksManager.generateId(), + type: 'command', + command: '' + }; + + setEditableHooks(prev => ({ + ...prev, + [event]: (prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).map(matcher => + matcher.id === matcherId + ? { ...matcher, hooks: [...matcher.hooks, newCommand] } + : matcher + ) + })); + }; + + const updateCommand = ( + event: HookEvent, + matcherId: string, + commandId: string, + updates: Partial + ) => { + if (!matcherEvents.includes(event as any)) return; + + setEditableHooks(prev => ({ + ...prev, + [event]: (prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).map(matcher => + matcher.id === matcherId + ? { + ...matcher, + hooks: matcher.hooks.map(cmd => + cmd.id === commandId ? { ...cmd, ...updates } : cmd + ) + } + : matcher + ) + })); + }; + + const removeCommand = (event: HookEvent, matcherId: string, commandId: string) => { + if (!matcherEvents.includes(event as any)) return; + + setEditableHooks(prev => ({ + ...prev, + [event]: (prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).map(matcher => + matcher.id === matcherId + ? { ...matcher, hooks: matcher.hooks.filter(cmd => cmd.id !== commandId) } + : matcher + ) + })); + }; + + const renderMatcher = (event: HookEvent, matcher: EditableHookMatcher) => ( + +
+ + +
+
+ + + + + + + +

Tool name pattern (regex supported). Leave empty to match all tools.

+
+
+
+
+ +
+ updateMatcher(event, matcher.id, { matcher: e.target.value })} + disabled={readOnly} + className="flex-1" + /> + + + + {!readOnly && ( + + )} +
+
+
+ + + {matcher.expanded && ( + +
+
+ + {!readOnly && ( + + )} +
+ + {matcher.hooks.length === 0 ? ( +

No commands added yet

+ ) : ( +
+ {matcher.hooks.map((hook) => ( +
+
+
+