diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 73a1f57..2864eb4 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -318,6 +318,20 @@ pub struct PostCompactEvent { pub conversation_id: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CwdChangedEvent { + pub cwd: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileChangedEvent { + pub file: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentStartEvent { pub tool_use_id: String, @@ -741,4 +755,52 @@ mod tests { assert!(serialized.contains("\"session_id\":\"sess-xyz\"")); assert!(!serialized.contains("conversation_id")); } + + #[test] + fn test_cwd_changed_event_serialization() { + let event = CwdChangedEvent { + cwd: "/home/naomi/code/my-project".to_string(), + conversation_id: Some("conv-abc".to_string()), + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"cwd\":\"/home/naomi/code/my-project\"")); + assert!(serialized.contains("\"conversation_id\":\"conv-abc\"")); + } + + #[test] + fn test_cwd_changed_event_omits_none_fields() { + let event = CwdChangedEvent { + cwd: "/tmp/workspace".to_string(), + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"cwd\":\"/tmp/workspace\"")); + assert!(!serialized.contains("conversation_id")); + } + + #[test] + fn test_file_changed_event_serialization() { + let event = FileChangedEvent { + file: "/home/naomi/code/my-project/src/main.rs".to_string(), + conversation_id: Some("conv-abc".to_string()), + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"file\":\"/home/naomi/code/my-project/src/main.rs\"")); + assert!(serialized.contains("\"conversation_id\":\"conv-abc\"")); + } + + #[test] + fn test_file_changed_event_omits_none_fields() { + let event = FileChangedEvent { + file: "/tmp/test.txt".to_string(), + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"file\":\"/tmp/test.txt\"")); + assert!(!serialized.contains("conversation_id")); + } } diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 1eef472..2775058 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -15,10 +15,10 @@ use crate::process_ext::HideWindow; use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats}; use crate::types::{ AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, - ConnectionStatus, ContentBlock, ElicitationEvent, ElicitationResultEvent, MessageCost, - OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, QuestionOption, SessionEvent, - PostCompactEvent, StateChangeEvent, StopFailureEvent, TodoItem, TodoUpdateEvent, - UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo, + ConnectionStatus, ContentBlock, CwdChangedEvent, ElicitationEvent, ElicitationResultEvent, + FileChangedEvent, MessageCost, OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, + PostCompactEvent, QuestionOption, SessionEvent, StateChangeEvent, StopFailureEvent, TodoItem, + TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo, }; use parking_lot::RwLock; use std::cell::RefCell; @@ -1078,6 +1078,8 @@ fn handle_stderr( let is_elicitation_result = line.contains("[ElicitationResult Hook]"); let is_stop_failure = line.contains("[StopFailure Hook]"); 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 line_type = if is_worktree_create || is_worktree_remove { "worktree" @@ -1089,6 +1091,10 @@ fn handle_stderr( "error" } else if is_post_compact { "compact-prompt" + } else if is_cwd_changed { + "cwd-changed" + } else if is_file_changed { + "file-changed" } else { "error" }; @@ -1227,6 +1233,57 @@ fn handle_stderr( parent_tool_use_id: None, }, ); + } else if is_cwd_changed { + let data = parse_cwd_changed_hook(&line); + let friendly_content = + format!("Working directory changed to: {}", data.cwd); + + let _ = app.emit( + "claude:cwd-changed", + CwdChangedEvent { + cwd: data.cwd, + conversation_id: conversation_id.clone(), + }, + ); + + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "cwd-changed".to_string(), + content: friendly_content, + tool_name: None, + conversation_id: conversation_id.clone(), + cost: None, + parent_tool_use_id: None, + }, + ); + } else if is_file_changed { + let data = parse_file_changed_hook(&line); + let friendly_content = if data.file.is_empty() { + "File changed".to_string() + } else { + format!("File changed: {}", data.file) + }; + + let _ = app.emit( + "claude:file-changed", + FileChangedEvent { + file: data.file, + conversation_id: conversation_id.clone(), + }, + ); + + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "file-changed".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", @@ -1440,6 +1497,42 @@ fn parse_post_compact_hook(line: &str) -> PostCompactData { PostCompactData { session_id } } +#[derive(Debug)] +struct CwdChangedData { + cwd: String, +} + +fn parse_cwd_changed_hook(line: &str) -> CwdChangedData { + let cwd = extract_quoted_value(line, "cwd") + .or_else(|| extract_quoted_value(line, "path")) + .or_else(|| { + line.split("[CwdChanged Hook]") + .nth(1) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + }) + .unwrap_or_default(); + CwdChangedData { cwd } +} + +#[derive(Debug)] +struct FileChangedData { + file: String, +} + +fn parse_file_changed_hook(line: &str) -> FileChangedData { + let file = extract_quoted_value(line, "file") + .or_else(|| extract_quoted_value(line, "path")) + .or_else(|| { + line.split("[FileChanged Hook]") + .nth(1) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + }) + .unwrap_or_default(); + FileChangedData { file } +} + /// 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 { @@ -3588,6 +3681,62 @@ mod tests { assert_eq!(data.session_id, None); } + #[test] + fn test_parse_cwd_changed_hook_with_cwd_key() { + let line = r#"[CwdChanged Hook] cwd="/home/naomi/code/my-project""#; + let data = parse_cwd_changed_hook(line); + assert_eq!(data.cwd, "/home/naomi/code/my-project"); + } + + #[test] + fn test_parse_cwd_changed_hook_with_path_key() { + let line = r#"[CwdChanged Hook] path="/tmp/workspace""#; + let data = parse_cwd_changed_hook(line); + assert_eq!(data.cwd, "/tmp/workspace"); + } + + #[test] + fn test_parse_cwd_changed_hook_bare_path_fallback() { + let line = "[CwdChanged Hook] /home/naomi/code/project"; + let data = parse_cwd_changed_hook(line); + assert_eq!(data.cwd, "/home/naomi/code/project"); + } + + #[test] + fn test_parse_cwd_changed_hook_empty_line() { + let line = "[CwdChanged Hook]"; + let data = parse_cwd_changed_hook(line); + assert_eq!(data.cwd, ""); + } + + #[test] + fn test_parse_file_changed_hook_with_file_key() { + let line = r#"[FileChanged Hook] file="/home/naomi/code/project/src/main.rs""#; + let data = parse_file_changed_hook(line); + assert_eq!(data.file, "/home/naomi/code/project/src/main.rs"); + } + + #[test] + fn test_parse_file_changed_hook_with_path_key() { + let line = r#"[FileChanged Hook] path="/tmp/test.txt""#; + let data = parse_file_changed_hook(line); + assert_eq!(data.file, "/tmp/test.txt"); + } + + #[test] + fn test_parse_file_changed_hook_bare_path_fallback() { + let line = "[FileChanged Hook] /home/naomi/code/project/README.md"; + let data = parse_file_changed_hook(line); + assert_eq!(data.file, "/home/naomi/code/project/README.md"); + } + + #[test] + fn test_parse_file_changed_hook_empty_line() { + let line = "[FileChanged Hook]"; + let data = parse_file_changed_hook(line); + assert_eq!(data.file, ""); + } + #[test] fn test_build_stop_failure_message_no_fields() { let data = StopFailureData {