From 6fe7e97550d0e776cc26b3fde1923479285b3e80 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 13 Apr 2026 09:58:09 -0700 Subject: [PATCH] feat: handle TaskCreated hook event (closes #254) --- src-tauri/src/types.rs | 57 +++++++++++++++++++++ src-tauri/src/wsl_bridge.rs | 98 ++++++++++++++++++++++++++++++++++++- 2 files changed, 153 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 2864eb4..0f04038 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -332,6 +332,18 @@ pub struct FileChangedEvent { pub conversation_id: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskCreatedEvent { + #[serde(skip_serializing_if = "Option::is_none")] + pub task_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_tool_use_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentStartEvent { pub tool_use_id: String, @@ -803,4 +815,49 @@ mod tests { assert!(serialized.contains("\"file\":\"/tmp/test.txt\"")); assert!(!serialized.contains("conversation_id")); } + + #[test] + fn test_task_created_event_serialization() { + let event = TaskCreatedEvent { + task_id: Some("task-abc123".to_string()), + description: Some("Explore the codebase".to_string()), + parent_tool_use_id: Some("toolu_xyz".to_string()), + conversation_id: Some("conv-abc".to_string()), + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"task_id\":\"task-abc123\"")); + assert!(serialized.contains("\"description\":\"Explore the codebase\"")); + assert!(serialized.contains("\"parent_tool_use_id\":\"toolu_xyz\"")); + assert!(serialized.contains("\"conversation_id\":\"conv-abc\"")); + } + + #[test] + fn test_task_created_event_omits_none_fields() { + let event = TaskCreatedEvent { + task_id: None, + description: None, + parent_tool_use_id: None, + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert_eq!(serialized, "{}"); + } + + #[test] + fn test_task_created_event_partial_fields() { + let event = TaskCreatedEvent { + task_id: Some("task-001".to_string()), + description: None, + parent_tool_use_id: None, + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"task_id\":\"task-001\"")); + assert!(!serialized.contains("description")); + assert!(!serialized.contains("parent_tool_use_id")); + assert!(!serialized.contains("conversation_id")); + } } diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 2775058..819ece3 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -17,8 +17,9 @@ use crate::types::{ AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, CwdChangedEvent, ElicitationEvent, ElicitationResultEvent, FileChangedEvent, MessageCost, OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, - PostCompactEvent, QuestionOption, SessionEvent, StateChangeEvent, StopFailureEvent, TodoItem, - TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo, + PostCompactEvent, QuestionOption, SessionEvent, StateChangeEvent, StopFailureEvent, + TaskCreatedEvent, TodoItem, TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, + WorktreeEvent, WorktreeInfo, }; use parking_lot::RwLock; use std::cell::RefCell; @@ -1080,6 +1081,7 @@ fn handle_stderr( let is_post_compact = line.contains("[PostCompact Hook]"); let is_cwd_changed = line.contains("[CwdChanged Hook]"); let is_file_changed = line.contains("[FileChanged Hook]"); + let is_task_created = line.contains("[TaskCreated Hook]"); let line_type = if is_worktree_create || is_worktree_remove { "worktree" @@ -1095,6 +1097,8 @@ fn handle_stderr( "cwd-changed" } else if is_file_changed { "file-changed" + } else if is_task_created { + "task-created" } else { "error" }; @@ -1284,6 +1288,34 @@ fn handle_stderr( parent_tool_use_id: None, }, ); + } else if is_task_created { + let data = parse_task_created_hook(&line); + let friendly_content = match data.description.as_deref() { + Some(desc) if !desc.is_empty() => format!("Task created: {}", desc), + _ => "Task created".to_string(), + }; + + let _ = app.emit( + "claude:task-created", + TaskCreatedEvent { + task_id: data.task_id, + description: data.description, + parent_tool_use_id: data.parent_tool_use_id, + conversation_id: conversation_id.clone(), + }, + ); + + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "task-created".to_string(), + content: friendly_content, + tool_name: None, + conversation_id: conversation_id.clone(), + cost: None, + parent_tool_use_id: None, + }, + ); } else { let _ = app.emit( "claude:output", @@ -1533,6 +1565,32 @@ fn parse_file_changed_hook(line: &str) -> FileChangedData { FileChangedData { file } } +#[derive(Debug)] +struct TaskCreatedData { + task_id: Option, + description: Option, + parent_tool_use_id: Option, +} + +fn parse_task_created_hook(line: &str) -> TaskCreatedData { + let task_id = extract_debug_string_value(line, "task_id") + .or_else(|| extract_quoted_value(line, "task_id")); + + let description = extract_quoted_value(line, "description") + .or_else(|| extract_quoted_value(line, "prompt")); + + let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") { + line.split("parent_tool_use_id=Some(\"") + .nth(1) + .and_then(|s| s.split('"').next()) + .map(|s| s.to_string()) + } else { + None + }; + + TaskCreatedData { task_id, description, parent_tool_use_id } +} + /// Extracts a double-quoted string value from a `key="value"` pair in a hook line. /// Handles escape sequences within the quoted value. fn extract_quoted_value(line: &str, key: &str) -> Option { @@ -3737,6 +3795,42 @@ mod tests { assert_eq!(data.file, ""); } + #[test] + fn test_parse_task_created_hook_with_all_fields() { + let line = r#"[TaskCreated Hook] task_id=Some("task-abc123"), description="Explore the codebase", parent_tool_use_id=Some("toolu_xyz")"#; + let data = parse_task_created_hook(line); + assert_eq!(data.task_id, Some("task-abc123".to_string())); + assert_eq!(data.description, Some("Explore the codebase".to_string())); + assert_eq!(data.parent_tool_use_id, Some("toolu_xyz".to_string())); + } + + #[test] + fn test_parse_task_created_hook_with_description_only() { + let line = r#"[TaskCreated Hook] description="Search for relevant files""#; + let data = parse_task_created_hook(line); + assert_eq!(data.description, Some("Search for relevant files".to_string())); + assert_eq!(data.task_id, None); + assert_eq!(data.parent_tool_use_id, None); + } + + #[test] + fn test_parse_task_created_hook_no_parent() { + let line = r#"[TaskCreated Hook] task_id=Some("task-001"), description="Run tests""#; + let data = parse_task_created_hook(line); + assert_eq!(data.task_id, Some("task-001".to_string())); + assert_eq!(data.description, Some("Run tests".to_string())); + assert_eq!(data.parent_tool_use_id, None); + } + + #[test] + fn test_parse_task_created_hook_empty_line() { + let line = "[TaskCreated Hook]"; + let data = parse_task_created_hook(line); + assert_eq!(data.task_id, None); + assert_eq!(data.description, None); + assert_eq!(data.parent_tool_use_id, None); + } + #[test] fn test_build_stop_failure_message_no_fields() { let data = StopFailureData {