From 54280b39208bbd221bb3b70b397bcd804ea64f24 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 24 Feb 2026 17:39:09 -0800 Subject: [PATCH] feat: add worktree isolation support and hook event display Closes #152, closes #150 - Add use_worktree bool to ClaudeStartOptions and HikariConfig (Rust + TS) - Pass --worktree flag to claude in both WSL and non-WSL command paths - Detect WorktreeCreate/WorktreeRemove hook events in stderr handler - Emit worktree events with distinct line_type instead of error - Add worktree line type to TerminalLine union, Terminal rendering, and tests - Display worktree events in green with [worktree] prefix - Add worktree isolation toggle to Agent Settings section of ConfigSidebar - Thread use_worktree through all seven start_claude call sites --- src-tauri/src/config.rs | 8 ++++++++ src-tauri/src/wsl_bridge.rs | 21 +++++++++++++++++++-- src/lib/commands/slashCommands.ts | 2 ++ src/lib/components/ConfigSidebar.svelte | 16 ++++++++++++++++ src/lib/components/InputBar.svelte | 1 + src/lib/components/PermissionModal.svelte | 1 + src/lib/components/StatusBar.svelte | 3 +++ src/lib/components/Terminal.svelte | 8 ++++++++ src/lib/components/Terminal.test.ts | 12 ++++++++++++ src/lib/components/UserQuestionModal.svelte | 1 + src/lib/stores/config.test.ts | 3 +++ src/lib/stores/config.ts | 3 +++ src/lib/tauri.ts | 6 ++++-- src/lib/types/messages.ts | 3 ++- 14 files changed, 83 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 0e0cfce..eb52ab1 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -25,6 +25,9 @@ pub struct ClaudeStartOptions { #[serde(default)] pub resume_session_id: Option, + + #[serde(default)] + pub use_worktree: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -113,6 +116,9 @@ pub struct HikariConfig { #[serde(default = "default_discord_rpc_enabled")] pub discord_rpc_enabled: bool, + + #[serde(default)] + pub use_worktree: bool, } impl Default for HikariConfig { @@ -145,6 +151,7 @@ impl Default for HikariConfig { budget_action: BudgetAction::Warn, budget_warning_threshold: 0.8, discord_rpc_enabled: true, + use_worktree: false, } } } @@ -284,6 +291,7 @@ mod tests { budget_action: BudgetAction::Block, budget_warning_threshold: 0.75, discord_rpc_enabled: true, + use_worktree: true, }; let json = serde_json::to_string(&config).unwrap(); diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 120de9f..b074df8 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -265,6 +265,11 @@ impl WslBridge { } } + // Add worktree flag if requested + if options.use_worktree { + cmd.arg("--worktree"); + } + cmd.current_dir(working_dir); // Set API key as environment variable if specified @@ -351,6 +356,11 @@ impl WslBridge { } } + // Add worktree flag if requested + if options.use_worktree { + claude_cmd.push_str(" --worktree"); + } + // Use bash -lc to load login profile (ensures PATH includes claude) cmd.args(["-e", "bash", "-lc", &claude_cmd]); @@ -751,11 +761,18 @@ fn handle_stderr( } } - // Still emit the stderr line as output + // Worktree hook events are informational — emit with a distinct type + let is_worktree_event = line.contains("[WorktreeCreate Hook]") + || line.contains("[WorktreeRemove Hook]"); + let _ = app.emit( "claude:output", OutputEvent { - line_type: "error".to_string(), + line_type: if is_worktree_event { + "worktree".to_string() + } else { + "error".to_string() + }, content: line, tool_name: None, conversation_id: conversation_id.clone(), diff --git a/src/lib/commands/slashCommands.ts b/src/lib/commands/slashCommands.ts index a1e4147..cdcfdd7 100644 --- a/src/lib/commands/slashCommands.ts +++ b/src/lib/commands/slashCommands.ts @@ -61,6 +61,7 @@ async function changeDirectory(path: string): Promise { custom_instructions: config.custom_instructions || null, mcp_servers_json: config.mcp_servers_json || null, allowed_tools: allAllowedTools, + use_worktree: config.use_worktree ?? false, }, }); @@ -135,6 +136,7 @@ async function startNewConversation(): Promise { custom_instructions: config.custom_instructions || null, mcp_servers_json: config.mcp_servers_json || null, allowed_tools: allAllowedTools, + use_worktree: config.use_worktree ?? false, }, }); diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index 014931f..4c82802 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -53,6 +53,7 @@ budget_warning_threshold: 0.8, discord_rpc_enabled: true, show_thinking_blocks: true, + use_worktree: false, }); let showCustomThemeEditor = $state(false); @@ -473,6 +474,21 @@ class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none" > + + +
+ +

+ Launch sessions with --worktree for isolated git worktree environments +

+
diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index f911354..3f9ceeb 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -362,6 +362,7 @@ User: ${formattedMessage}`; custom_instructions: config.custom_instructions || null, mcp_servers_json: config.mcp_servers_json || null, allowed_tools: allAllowedTools, + use_worktree: config.use_worktree ?? false, }, }); diff --git a/src/lib/components/PermissionModal.svelte b/src/lib/components/PermissionModal.svelte index 69e1f08..02ac993 100644 --- a/src/lib/components/PermissionModal.svelte +++ b/src/lib/components/PermissionModal.svelte @@ -87,6 +87,7 @@ custom_instructions: config.custom_instructions || null, mcp_servers_json: config.mcp_servers_json || null, allowed_tools: newGrantedTools, + use_worktree: config.use_worktree ?? false, }, }); diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index df53531..45f12b5 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -101,6 +101,7 @@ budget_warning_threshold: 0.8, discord_rpc_enabled: true, show_thinking_blocks: true, + use_worktree: false, }); let streamerModeActive = $state(false); @@ -178,6 +179,7 @@ custom_instructions: currentConfig.custom_instructions || null, mcp_servers_json: currentConfig.mcp_servers_json || null, allowed_tools: allAllowedTools, + use_worktree: currentConfig.use_worktree ?? false, }, }); @@ -289,6 +291,7 @@ custom_instructions: currentConfig.custom_instructions || null, mcp_servers_json: currentConfig.mcp_servers_json || null, allowed_tools: allAllowedTools, + use_worktree: currentConfig.use_worktree ?? false, }, }); diff --git a/src/lib/components/Terminal.svelte b/src/lib/components/Terminal.svelte index 9933d3c..9d476bb 100644 --- a/src/lib/components/Terminal.svelte +++ b/src/lib/components/Terminal.svelte @@ -98,6 +98,8 @@ return "terminal-rate-limit"; case "compact-prompt": return "terminal-compact-prompt"; + case "worktree": + return "terminal-worktree"; default: return "terminal-default"; } @@ -117,6 +119,8 @@ return "[error]"; case "rate-limit": return "[rate-limit]"; + case "worktree": + return "[worktree]"; default: return ""; } @@ -386,6 +390,10 @@ color: var(--text-secondary); } + .terminal-worktree { + color: var(--terminal-worktree, #34d399); + } + .compact-action-btn { display: inline-flex; align-items: center; diff --git a/src/lib/components/Terminal.test.ts b/src/lib/components/Terminal.test.ts index 332c255..9e3023b 100644 --- a/src/lib/components/Terminal.test.ts +++ b/src/lib/components/Terminal.test.ts @@ -40,6 +40,8 @@ function getLineClass(type: string): string { return "terminal-rate-limit"; case "compact-prompt": return "terminal-compact-prompt"; + case "worktree": + return "terminal-worktree"; default: return "terminal-default"; } @@ -59,6 +61,8 @@ function getLinePrefix(type: string): string { return "[error]"; case "rate-limit": return "[rate-limit]"; + case "worktree": + return "[worktree]"; default: return ""; } @@ -116,6 +120,10 @@ describe("getLineClass", () => { expect(getLineClass("compact-prompt")).toBe("terminal-compact-prompt"); }); + it("returns terminal-worktree for worktree lines", () => { + expect(getLineClass("worktree")).toBe("terminal-worktree"); + }); + it("returns terminal-default for unknown line types", () => { expect(getLineClass("unknown")).toBe("terminal-default"); expect(getLineClass("")).toBe("terminal-default"); @@ -152,6 +160,10 @@ describe("getLinePrefix", () => { expect(getLinePrefix("compact-prompt")).toBe(""); }); + it("returns [worktree] for worktree lines", () => { + expect(getLinePrefix("worktree")).toBe("[worktree]"); + }); + it("returns empty string for thinking lines (no prefix)", () => { expect(getLinePrefix("thinking")).toBe(""); }); diff --git a/src/lib/components/UserQuestionModal.svelte b/src/lib/components/UserQuestionModal.svelte index 822fc52..bda1654 100644 --- a/src/lib/components/UserQuestionModal.svelte +++ b/src/lib/components/UserQuestionModal.svelte @@ -106,6 +106,7 @@ custom_instructions: config.custom_instructions || null, mcp_servers_json: config.mcp_servers_json || null, allowed_tools: grantedToolsList, + use_worktree: config.use_worktree ?? false, }, }); diff --git a/src/lib/stores/config.test.ts b/src/lib/stores/config.test.ts index 5f9e625..87b6283 100644 --- a/src/lib/stores/config.test.ts +++ b/src/lib/stores/config.test.ts @@ -194,6 +194,7 @@ describe("config store", () => { budget_warning_threshold: 0.8, discord_rpc_enabled: true, show_thinking_blocks: true, + use_worktree: false, }; expect(config.model).toBe("claude-sonnet-4"); @@ -240,6 +241,7 @@ describe("config store", () => { budget_warning_threshold: 0.8, discord_rpc_enabled: true, show_thinking_blocks: true, + use_worktree: false, }; expect(config.model).toBeNull(); @@ -785,6 +787,7 @@ describe("config store", () => { budget_warning_threshold: 0.9, discord_rpc_enabled: false, show_thinking_blocks: true, + use_worktree: false, }; const mockInvokeImpl = vi.mocked(invoke); diff --git a/src/lib/stores/config.ts b/src/lib/stores/config.ts index b904379..3042a8b 100644 --- a/src/lib/stores/config.ts +++ b/src/lib/stores/config.ts @@ -47,6 +47,8 @@ export interface HikariConfig { discord_rpc_enabled: boolean; // Thinking blocks settings show_thinking_blocks: boolean; + // Worktree isolation + use_worktree: boolean; } const defaultConfig: HikariConfig = { @@ -87,6 +89,7 @@ const defaultConfig: HikariConfig = { budget_warning_threshold: 0.8, discord_rpc_enabled: true, show_thinking_blocks: true, + use_worktree: false, }; function createConfigStore() { diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 8773a9a..7c1166d 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -331,7 +331,8 @@ export async function initializeTauriListeners() { | "error" | "thinking" | "rate-limit" - | "compact-prompt", + | "compact-prompt" + | "worktree", content, tool_name || undefined, costData, @@ -348,7 +349,8 @@ export async function initializeTauriListeners() { | "error" | "thinking" | "rate-limit" - | "compact-prompt", + | "compact-prompt" + | "worktree", content, tool_name || undefined, costData, diff --git a/src/lib/types/messages.ts b/src/lib/types/messages.ts index e9b3537..5531375 100644 --- a/src/lib/types/messages.ts +++ b/src/lib/types/messages.ts @@ -8,7 +8,8 @@ export interface TerminalLine { | "error" | "thinking" | "rate-limit" - | "compact-prompt"; + | "compact-prompt" + | "worktree"; content: string; timestamp: Date; toolName?: string;