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>, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentEndEvent { pub struct AgentEndEvent {
pub tool_use_id: String, pub tool_use_id: String,
+118 -15
View File
@@ -17,7 +17,7 @@ use crate::types::{
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent, ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent,
PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent, TodoItem, PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent, TodoItem,
TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo,
}; };
use parking_lot::RwLock; use parking_lot::RwLock;
use std::cell::RefCell; use std::cell::RefCell;
@@ -956,9 +956,10 @@ fn handle_stderr(
} }
// Hook events are informational — emit with distinct types instead of error // Hook events are informational — emit with distinct types instead of error
let line_type = if line.contains("[WorktreeCreate Hook]") let is_worktree_create = line.contains("[WorktreeCreate Hook]");
|| line.contains("[WorktreeRemove Hook]") let is_worktree_remove = line.contains("[WorktreeRemove Hook]");
{
let line_type = if is_worktree_create || is_worktree_remove {
"worktree" "worktree"
} else if line.contains("[ConfigChange Hook]") { } else if line.contains("[ConfigChange Hook]") {
"config-change" "config-change"
@@ -966,17 +967,56 @@ fn handle_stderr(
"error" "error"
}; };
let _ = app.emit( // For worktree hooks, parse structured data and emit a dedicated event
"claude:output", if is_worktree_create || is_worktree_remove {
OutputEvent { let worktree_info = parse_worktree_hook(&line);
line_type: line_type.to_string(), let event_type = if is_worktree_create { "create" } else { "remove" };
content: line, let friendly_content = if let Some(ref info) = worktree_info {
tool_name: None, if is_worktree_create {
conversation_id: conversation_id.clone(), format!(
cost: None, "Worktree created: {} (branch: {}) at {}",
parent_tool_use_id: None, 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, Err(_) => break,
_ => {} _ => {}
@@ -991,6 +1031,29 @@ struct SubagentStartData {
parent_tool_use_id: Option<String>, 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> { 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"), ... // 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; *pending_since.lock() = None;
assert!(pending_since.lock().is_none(), "pending_since cleared on Result"); 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 connectionStatus: ConnectionStatus = $state("disconnected");
let workingDirectory = $state(""); let workingDirectory = $state("");
let worktreeInfo: import("$lib/types/worktree").WorktreeInfo | null = $state(null);
let selectedDirectory = $state("/home/naomi"); let selectedDirectory = $state("/home/naomi");
let isConnecting = $state(false); let isConnecting = $state(false);
let grantedToolsList: string[] = $state([]); let grantedToolsList: string[] = $state([]);
@@ -115,6 +116,10 @@
workingDirectory = dir; workingDirectory = dir;
}); });
claudeStore.worktreeInfo.subscribe((info) => {
worktreeInfo = info;
});
claudeStore.grantedTools.subscribe((tools) => { claudeStore.grantedTools.subscribe((tools) => {
grantedToolsList = Array.from(tools); grantedToolsList = Array.from(tools);
}); });
@@ -392,6 +397,18 @@
{workingDirectory} {workingDirectory}
</div> </div>
{/if} {/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} {:else}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-sm text-gray-600">cwd:</span> <span class="text-sm text-gray-600">cwd:</span>
+4
View File
@@ -26,6 +26,7 @@ export const claudeStore = {
grantedTools: conversationsStore.grantedTools, grantedTools: conversationsStore.grantedTools,
pendingRetryMessage: conversationsStore.pendingRetryMessage, pendingRetryMessage: conversationsStore.pendingRetryMessage,
attachments: conversationsStore.attachments, attachments: conversationsStore.attachments,
worktreeInfo: conversationsStore.worktreeInfo,
// New conversation-aware subscriptions // New conversation-aware subscriptions
conversations: conversationsStore.conversations, conversations: conversationsStore.conversations,
@@ -70,6 +71,9 @@ export const claudeStore = {
// Draft text (per-tab input persistence) // Draft text (per-tab input persistence)
setDraftText: conversationsStore.setDraftText, setDraftText: conversationsStore.setDraftText,
// Worktree info (per-conversation)
setWorktreeInfo: conversationsStore.setWorktreeInfo,
// Conversation management // Conversation management
createConversation: conversationsStore.createConversation, createConversation: conversationsStore.createConversation,
deleteConversation: conversationsStore.deleteConversation, 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", () => { describe("isProcessing state management", () => {
it("starts as false by default", () => { it("starts as false by default", () => {
const conversation = { id: "conv-1", isProcessing: false }; const conversation = { id: "conv-1", isProcessing: false };
+15
View File
@@ -7,6 +7,7 @@ import type {
Attachment, Attachment,
} from "$lib/types/messages"; } from "$lib/types/messages";
import type { CharacterState } from "$lib/types/states"; import type { CharacterState } from "$lib/types/states";
import type { WorktreeInfo } from "$lib/types/worktree";
import { cleanupConversationTracking } from "$lib/tauri"; import { cleanupConversationTracking } from "$lib/tauri";
import { characterState } from "$lib/stores/character"; import { characterState } from "$lib/stores/character";
import { sessionsStore } from "$lib/stores/sessions"; import { sessionsStore } from "$lib/stores/sessions";
@@ -41,6 +42,7 @@ export interface Conversation {
successSoundFired: boolean; successSoundFired: boolean;
taskStartSoundFired: boolean; taskStartSoundFired: boolean;
draftText: string; draftText: string;
worktreeInfo: WorktreeInfo | null;
} }
const TAB_NAMES = [ const TAB_NAMES = [
@@ -165,6 +167,7 @@ function createConversationsStore() {
successSoundFired: false, successSoundFired: false,
taskStartSoundFired: false, taskStartSoundFired: false,
draftText: "", draftText: "",
worktreeInfo: null,
}; };
} }
@@ -220,6 +223,7 @@ function createConversationsStore() {
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null); const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1); const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
const attachments = derived(activeConversation, ($conv) => $conv?.attachments || []); const attachments = derived(activeConversation, ($conv) => $conv?.attachments || []);
const worktreeInfo = derived(activeConversation, ($conv) => $conv?.worktreeInfo ?? null);
return { return {
// Expose derived stores for compatibility // Expose derived stores for compatibility
@@ -235,6 +239,7 @@ function createConversationsStore() {
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe }, pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
scrollPosition: { subscribe: scrollPosition.subscribe }, scrollPosition: { subscribe: scrollPosition.subscribe },
attachments: { subscribe: attachments.subscribe }, attachments: { subscribe: attachments.subscribe },
worktreeInfo: { subscribe: worktreeInfo.subscribe },
// New conversation-specific stores // New conversation-specific stores
conversations: { subscribe: conversations.subscribe }, 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 // Add initialization helper
initialize: () => { initialize: () => {
ensureInitialized(); ensureInitialized();
+13
View File
@@ -13,6 +13,7 @@ import type {
} from "$lib/types/messages"; } from "$lib/types/messages";
import type { CharacterState } from "$lib/types/states"; import type { CharacterState } from "$lib/types/states";
import type { AgentStartPayload, AgentEndPayload } from "$lib/types/agents"; import type { AgentStartPayload, AgentEndPayload } from "$lib/types/agents";
import type { WorktreeEvent } from "$lib/types/worktree";
import { agentStore } from "$lib/stores/agents"; import { agentStore } from "$lib/stores/agents";
import { todos } from "$lib/stores/todos"; import { todos } from "$lib/stores/todos";
import { import {
@@ -563,6 +564,18 @@ export async function initializeTauriListeners() {
}); });
unlisteners.push(agentEndUnlisten); 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 questionUnlisten = await listen<UserQuestionEvent>("claude:question", (event) => {
const questionEvent = event.payload; 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;
}