generated from nhcarrigan/template
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:
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user