From d7b1ff44c4da50cdb7bda0022b61f8316576d4c4 Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 11 Mar 2026 12:51:04 -0700 Subject: [PATCH] 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 --- src-tauri/src/types.rs | 18 ++++ src-tauri/src/wsl_bridge.rs | 133 ++++++++++++++++++++++++--- src/lib/components/StatusBar.svelte | 17 ++++ src/lib/stores/claude.ts | 4 + src/lib/stores/conversations.test.ts | 48 ++++++++++ src/lib/stores/conversations.ts | 15 +++ src/lib/tauri.ts | 13 +++ src/lib/types/worktree.ts | 13 +++ 8 files changed, 246 insertions(+), 15 deletions(-) create mode 100644 src/lib/types/worktree.ts diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 93fcf27..f73cb4b 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -296,6 +296,24 @@ pub struct AgentStartEvent { pub model: Option, } +#[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, + /// "create" or "remove" + pub event_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub worktree: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentEndEvent { pub tool_use_id: String, diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 38bacd3..be994b9 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -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, } +fn parse_worktree_hook(line: &str) -> Option { + // 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 { + 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 { // 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()); + } } diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 77485ea..5824dd7 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -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} {/if} + {#if worktreeInfo} +
+ + + + {worktreeInfo.branch} +
+ {/if} {:else}
cwd: diff --git a/src/lib/stores/claude.ts b/src/lib/stores/claude.ts index b163f73..3b3cdcb 100644 --- a/src/lib/stores/claude.ts +++ b/src/lib/stores/claude.ts @@ -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, diff --git a/src/lib/stores/conversations.test.ts b/src/lib/stores/conversations.test.ts index 2a5d9e7..b4ead50 100644 --- a/src/lib/stores/conversations.test.ts +++ b/src/lib/stores/conversations.test.ts @@ -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 }; diff --git a/src/lib/stores/conversations.ts b/src/lib/stores/conversations.ts index 666b4d1..cf8bf31 100644 --- a/src/lib/stores/conversations.ts +++ b/src/lib/stores/conversations.ts @@ -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(); diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index ad1f434..f37ef38 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -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("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("claude:question", (event) => { const questionEvent = event.payload; diff --git a/src/lib/types/worktree.ts b/src/lib/types/worktree.ts new file mode 100644 index 0000000..7dd2880 --- /dev/null +++ b/src/lib/types/worktree.ts @@ -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; +}