feat: handle CwdChanged and FileChanged hook events (closes #253)

This commit is contained in:
2026-04-13 09:33:03 -07:00
committed by Naomi Carrigan
parent 542d2eb315
commit 022d7d7b6c
2 changed files with 215 additions and 4 deletions
+62
View File
@@ -318,6 +318,20 @@ pub struct PostCompactEvent {
pub conversation_id: Option<String>, pub conversation_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CwdChangedEvent {
pub cwd: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileChangedEvent {
pub file: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentStartEvent { pub struct AgentStartEvent {
pub tool_use_id: String, pub tool_use_id: String,
@@ -741,4 +755,52 @@ mod tests {
assert!(serialized.contains("\"session_id\":\"sess-xyz\"")); assert!(serialized.contains("\"session_id\":\"sess-xyz\""));
assert!(!serialized.contains("conversation_id")); 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"));
}
} }
+153 -4
View File
@@ -15,10 +15,10 @@ use crate::process_ext::HideWindow;
use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats}; use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
use crate::types::{ use crate::types::{
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
ConnectionStatus, ContentBlock, ElicitationEvent, ElicitationResultEvent, MessageCost, ConnectionStatus, ContentBlock, CwdChangedEvent, ElicitationEvent, ElicitationResultEvent,
OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, QuestionOption, SessionEvent, FileChangedEvent, MessageCost, OutputEvent, PermissionPromptEvent, PermissionPromptEventItem,
PostCompactEvent, StateChangeEvent, StopFailureEvent, TodoItem, TodoUpdateEvent, PostCompactEvent, QuestionOption, SessionEvent, StateChangeEvent, StopFailureEvent, TodoItem,
UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo, TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo,
}; };
use parking_lot::RwLock; use parking_lot::RwLock;
use std::cell::RefCell; use std::cell::RefCell;
@@ -1078,6 +1078,8 @@ fn handle_stderr(
let is_elicitation_result = line.contains("[ElicitationResult Hook]"); let is_elicitation_result = line.contains("[ElicitationResult Hook]");
let is_stop_failure = line.contains("[StopFailure Hook]"); let is_stop_failure = line.contains("[StopFailure Hook]");
let is_post_compact = line.contains("[PostCompact 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 { let line_type = if is_worktree_create || is_worktree_remove {
"worktree" "worktree"
@@ -1089,6 +1091,10 @@ fn handle_stderr(
"error" "error"
} else if is_post_compact { } else if is_post_compact {
"compact-prompt" "compact-prompt"
} else if is_cwd_changed {
"cwd-changed"
} else if is_file_changed {
"file-changed"
} else { } else {
"error" "error"
}; };
@@ -1227,6 +1233,57 @@ fn handle_stderr(
parent_tool_use_id: None, 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 { } else {
let _ = app.emit( let _ = app.emit(
"claude:output", "claude:output",
@@ -1440,6 +1497,42 @@ fn parse_post_compact_hook(line: &str) -> PostCompactData {
PostCompactData { session_id } 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. /// Extracts a double-quoted string value from a `key="value"` pair in a hook line.
/// Handles escape sequences within the quoted value. /// Handles escape sequences within the quoted value.
fn extract_quoted_value(line: &str, key: &str) -> Option<String> { fn extract_quoted_value(line: &str, key: &str) -> Option<String> {
@@ -3588,6 +3681,62 @@ mod tests {
assert_eq!(data.session_id, None); 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] #[test]
fn test_build_stop_failure_message_no_fields() { fn test_build_stop_failure_message_no_fields() {
let data = StopFailureData { let data = StopFailureData {