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
+18
View File
@@ -296,6 +296,24 @@ pub struct AgentStartEvent {
pub model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorktreeInfo {
pub name: String,
pub path: String,
pub branch: String,
pub original_repo_directory: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorktreeEvent {
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
/// "create" or "remove"
pub event_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree: Option<WorktreeInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentEndEvent {
pub tool_use_id: String,
+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());
}
}
+17
View File
@@ -29,6 +29,7 @@
let connectionStatus: ConnectionStatus = $state("disconnected");
let workingDirectory = $state("");
let worktreeInfo: import("$lib/types/worktree").WorktreeInfo | null = $state(null);
let selectedDirectory = $state("/home/naomi");
let isConnecting = $state(false);
let grantedToolsList: string[] = $state([]);
@@ -115,6 +116,10 @@
workingDirectory = dir;
});
claudeStore.worktreeInfo.subscribe((info) => {
worktreeInfo = info;
});
claudeStore.grantedTools.subscribe((tools) => {
grantedToolsList = Array.from(tools);
});
@@ -392,6 +397,18 @@
{workingDirectory}
</div>
{/if}
{#if worktreeInfo}
<div
class="flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/15 border border-emerald-500/30 text-emerald-400 text-xs"
title="Worktree: {worktreeInfo.name} | Base: {worktreeInfo.original_repo_directory}"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{worktreeInfo.branch}
</div>
{/if}
{:else}
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600">cwd:</span>
+4
View File
@@ -26,6 +26,7 @@ export const claudeStore = {
grantedTools: conversationsStore.grantedTools,
pendingRetryMessage: conversationsStore.pendingRetryMessage,
attachments: conversationsStore.attachments,
worktreeInfo: conversationsStore.worktreeInfo,
// New conversation-aware subscriptions
conversations: conversationsStore.conversations,
@@ -70,6 +71,9 @@ export const claudeStore = {
// Draft text (per-tab input persistence)
setDraftText: conversationsStore.setDraftText,
// Worktree info (per-conversation)
setWorktreeInfo: conversationsStore.setWorktreeInfo,
// Conversation management
createConversation: conversationsStore.createConversation,
deleteConversation: conversationsStore.deleteConversation,
+48
View File
@@ -562,6 +562,54 @@ describe("draft text persistence", () => {
});
});
describe("worktreeInfo state management", () => {
it("initialises worktreeInfo as null", () => {
const conversation = { worktreeInfo: null };
expect(conversation.worktreeInfo).toBeNull();
});
it("stores worktreeInfo when a worktree is created", () => {
const info = {
name: "worktree-abc",
path: "/tmp/worktrees/worktree-abc",
branch: "feat/my-feature",
original_repo_directory: "/home/naomi/code/project",
};
const conversation = { worktreeInfo: null as typeof info | null };
conversation.worktreeInfo = info;
expect(conversation.worktreeInfo?.branch).toBe("feat/my-feature");
expect(conversation.worktreeInfo?.name).toBe("worktree-abc");
expect(conversation.worktreeInfo?.original_repo_directory).toBe("/home/naomi/code/project");
});
it("clears worktreeInfo when a worktree is removed", () => {
const info = {
name: "worktree-abc",
path: "/tmp/worktrees/worktree-abc",
branch: "feat/my-feature",
original_repo_directory: "/home/naomi/code/project",
};
const conversation = { worktreeInfo: info as typeof info | null };
conversation.worktreeInfo = null;
expect(conversation.worktreeInfo).toBeNull();
});
it("stores worktreeInfo independently per conversation", () => {
const conversations = new Map([
["conv-1", { worktreeInfo: null as { branch: string } | null }],
["conv-2", { worktreeInfo: null as { branch: string } | null }],
]);
const conv1 = conversations.get("conv-1");
if (conv1) conv1.worktreeInfo = { branch: "feat/one" };
expect(conversations.get("conv-1")?.worktreeInfo?.branch).toBe("feat/one");
expect(conversations.get("conv-2")?.worktreeInfo).toBeNull();
});
});
describe("isProcessing state management", () => {
it("starts as false by default", () => {
const conversation = { id: "conv-1", isProcessing: false };
+15
View File
@@ -7,6 +7,7 @@ import type {
Attachment,
} from "$lib/types/messages";
import type { CharacterState } from "$lib/types/states";
import type { WorktreeInfo } from "$lib/types/worktree";
import { cleanupConversationTracking } from "$lib/tauri";
import { characterState } from "$lib/stores/character";
import { sessionsStore } from "$lib/stores/sessions";
@@ -41,6 +42,7 @@ export interface Conversation {
successSoundFired: boolean;
taskStartSoundFired: boolean;
draftText: string;
worktreeInfo: WorktreeInfo | null;
}
const TAB_NAMES = [
@@ -165,6 +167,7 @@ function createConversationsStore() {
successSoundFired: false,
taskStartSoundFired: false,
draftText: "",
worktreeInfo: null,
};
}
@@ -220,6 +223,7 @@ function createConversationsStore() {
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
const attachments = derived(activeConversation, ($conv) => $conv?.attachments || []);
const worktreeInfo = derived(activeConversation, ($conv) => $conv?.worktreeInfo ?? null);
return {
// Expose derived stores for compatibility
@@ -235,6 +239,7 @@ function createConversationsStore() {
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
scrollPosition: { subscribe: scrollPosition.subscribe },
attachments: { subscribe: attachments.subscribe },
worktreeInfo: { subscribe: worktreeInfo.subscribe },
// New conversation-specific stores
conversations: { subscribe: conversations.subscribe },
@@ -976,6 +981,16 @@ function createConversationsStore() {
});
},
setWorktreeInfo: (conversationId: string, info: WorktreeInfo | null) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.worktreeInfo = info;
}
return convs;
});
},
// Add initialization helper
initialize: () => {
ensureInitialized();
+13
View File
@@ -13,6 +13,7 @@ import type {
} from "$lib/types/messages";
import type { CharacterState } from "$lib/types/states";
import type { AgentStartPayload, AgentEndPayload } from "$lib/types/agents";
import type { WorktreeEvent } from "$lib/types/worktree";
import { agentStore } from "$lib/stores/agents";
import { todos } from "$lib/stores/todos";
import {
@@ -563,6 +564,18 @@ export async function initializeTauriListeners() {
});
unlisteners.push(agentEndUnlisten);
const worktreeUnlisten = await listen<WorktreeEvent>("claude:worktree", (event) => {
const { conversation_id, event_type, worktree } = event.payload;
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (targetConversationId) {
claudeStore.setWorktreeInfo(
targetConversationId,
event_type === "create" && worktree ? worktree : null
);
}
});
unlisteners.push(worktreeUnlisten);
const questionUnlisten = await listen<UserQuestionEvent>("claude:question", (event) => {
const questionEvent = event.payload;
+13
View File
@@ -0,0 +1,13 @@
export interface WorktreeInfo {
name: string;
path: string;
branch: string;
original_repo_directory: string;
}
export interface WorktreeEvent {
conversation_id?: string;
/** "create" or "remove" */
event_type: string;
worktree?: WorktreeInfo;
}