feat: consume worktree field from status line hook events

Parse structured WorktreeInfo (name, path, branch, original_repo_directory)
from WorktreeCreate/Remove hook events and emit a dedicated claude:worktree
event. Store per-conversation worktree state and display an emerald branch
badge in the status bar so users can see at a glance which worktree and
branch each session is running on.

Closes #206
This commit is contained in:
2026-03-11 12:51:04 -07:00
committed by Naomi Carrigan
parent 31d156d768
commit d7b1ff44c4
8 changed files with 246 additions and 15 deletions
+118 -15
View File
@@ -17,7 +17,7 @@ use crate::types::{
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent,
PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent, TodoItem,
TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent,
TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo,
};
use parking_lot::RwLock;
use std::cell::RefCell;
@@ -956,9 +956,10 @@ fn handle_stderr(
}
// Hook events are informational — emit with distinct types instead of error
let line_type = if line.contains("[WorktreeCreate Hook]")
|| line.contains("[WorktreeRemove Hook]")
{
let is_worktree_create = line.contains("[WorktreeCreate Hook]");
let is_worktree_remove = line.contains("[WorktreeRemove Hook]");
let line_type = if is_worktree_create || is_worktree_remove {
"worktree"
} else if line.contains("[ConfigChange Hook]") {
"config-change"
@@ -966,17 +967,56 @@ fn handle_stderr(
"error"
};
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: line_type.to_string(),
content: line,
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
// For worktree hooks, parse structured data and emit a dedicated event
if is_worktree_create || is_worktree_remove {
let worktree_info = parse_worktree_hook(&line);
let event_type = if is_worktree_create { "create" } else { "remove" };
let friendly_content = if let Some(ref info) = worktree_info {
if is_worktree_create {
format!(
"Worktree created: {} (branch: {}) at {}",
info.name, info.branch, info.path
)
} else {
format!("Worktree removed: {} (branch: {})", info.name, info.branch)
}
} else {
line.clone()
};
let _ = app.emit(
"claude:worktree",
WorktreeEvent {
conversation_id: conversation_id.clone(),
event_type: event_type.to_string(),
worktree: worktree_info,
},
);
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "worktree".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",
OutputEvent {
line_type: line_type.to_string(),
content: line,
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
}
}
Err(_) => break,
_ => {}
@@ -991,6 +1031,29 @@ struct SubagentStartData {
parent_tool_use_id: Option<String>,
}
fn parse_worktree_hook(line: &str) -> Option<WorktreeInfo> {
// Parse: [WorktreeCreate/Remove Hook] name=worktree-abc, path=/tmp/worktrees/worktree-abc,
// branch=feat/my-feature, original_repo_directory=/home/naomi/code/project, session_id=xxx
let extract = |key: &str| -> Option<String> {
let after_key = line.split(&format!("{}=", key)).nth(1)?;
let value = after_key.split(',').next()?.trim().to_string();
if value.is_empty() { None } else { Some(value) }
};
let name = extract("name")?;
let path = extract("path")?;
let branch = extract("branch")?;
let original_repo_directory = extract("original_repo_directory")?;
Some(WorktreeInfo {
name,
path,
branch,
original_repo_directory,
})
}
fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
// Parse: [SubagentStart Hook] agent_id=agent-xxx, agent_type=general-purpose, parent_tool_use_id=Some("toolu_xxx"), ...
@@ -2913,4 +2976,44 @@ mod tests {
*pending_since.lock() = None;
assert!(pending_since.lock().is_none(), "pending_since cleared on Result");
}
#[test]
fn test_parse_worktree_hook_create_with_all_fields() {
let line = r#"[WorktreeCreate Hook] name=worktree-abc, path=/tmp/worktrees/worktree-abc, branch=feat/my-feature, original_repo_directory=/home/naomi/code/project, session_id=123"#;
let result = parse_worktree_hook(line);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.name, "worktree-abc");
assert_eq!(info.path, "/tmp/worktrees/worktree-abc");
assert_eq!(info.branch, "feat/my-feature");
assert_eq!(info.original_repo_directory, "/home/naomi/code/project");
}
#[test]
fn test_parse_worktree_hook_remove_with_all_fields() {
let line = r#"[WorktreeRemove Hook] name=worktree-xyz, path=/tmp/worktrees/worktree-xyz, branch=fix/bug-123, original_repo_directory=/home/naomi/code/other, session_id=456"#;
let result = parse_worktree_hook(line);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.name, "worktree-xyz");
assert_eq!(info.branch, "fix/bug-123");
assert_eq!(info.original_repo_directory, "/home/naomi/code/other");
}
#[test]
fn test_parse_worktree_hook_missing_field_returns_none() {
// Missing branch field — should return None
let line = r#"[WorktreeCreate Hook] name=worktree-abc, path=/tmp/worktrees/worktree-abc, original_repo_directory=/home/naomi/code/project, session_id=123"#;
let result = parse_worktree_hook(line);
assert!(result.is_none());
}
#[test]
fn test_parse_worktree_hook_invalid_returns_none() {
let line = "[WorktreeCreate Hook] no structured data here";
let result = parse_worktree_hook(line);
assert!(result.is_none());
}
}