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
This commit is contained in:
2026-02-24 17:39:09 -08:00
committed by Naomi Carrigan
parent 108a1b16b2
commit 54280b3920
14 changed files with 83 additions and 5 deletions
+8
View File
@@ -25,6 +25,9 @@ pub struct ClaudeStartOptions {
#[serde(default)] #[serde(default)]
pub resume_session_id: Option<String>, pub resume_session_id: Option<String>,
#[serde(default)]
pub use_worktree: bool,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -113,6 +116,9 @@ pub struct HikariConfig {
#[serde(default = "default_discord_rpc_enabled")] #[serde(default = "default_discord_rpc_enabled")]
pub discord_rpc_enabled: bool, pub discord_rpc_enabled: bool,
#[serde(default)]
pub use_worktree: bool,
} }
impl Default for HikariConfig { impl Default for HikariConfig {
@@ -145,6 +151,7 @@ impl Default for HikariConfig {
budget_action: BudgetAction::Warn, budget_action: BudgetAction::Warn,
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
use_worktree: false,
} }
} }
} }
@@ -284,6 +291,7 @@ mod tests {
budget_action: BudgetAction::Block, budget_action: BudgetAction::Block,
budget_warning_threshold: 0.75, budget_warning_threshold: 0.75,
discord_rpc_enabled: true, discord_rpc_enabled: true,
use_worktree: true,
}; };
let json = serde_json::to_string(&config).unwrap(); let json = serde_json::to_string(&config).unwrap();
+19 -2
View File
@@ -265,6 +265,11 @@ impl WslBridge {
} }
} }
// Add worktree flag if requested
if options.use_worktree {
cmd.arg("--worktree");
}
cmd.current_dir(working_dir); cmd.current_dir(working_dir);
// Set API key as environment variable if specified // 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) // Use bash -lc to load login profile (ensures PATH includes claude)
cmd.args(["-e", "bash", "-lc", &claude_cmd]); 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( let _ = app.emit(
"claude:output", "claude:output",
OutputEvent { OutputEvent {
line_type: "error".to_string(), line_type: if is_worktree_event {
"worktree".to_string()
} else {
"error".to_string()
},
content: line, content: line,
tool_name: None, tool_name: None,
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
+2
View File
@@ -61,6 +61,7 @@ async function changeDirectory(path: string): Promise<void> {
custom_instructions: config.custom_instructions || null, custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null, mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools, allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false,
}, },
}); });
@@ -135,6 +136,7 @@ async function startNewConversation(): Promise<void> {
custom_instructions: config.custom_instructions || null, custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null, mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools, allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false,
}, },
}); });
+16
View File
@@ -53,6 +53,7 @@
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false,
}); });
let showCustomThemeEditor = $state(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" 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"
></textarea> ></textarea>
</div> </div>
<!-- Worktree Isolation -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.use_worktree}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
/>
<span class="text-sm text-[var(--text-primary)]">Worktree isolation</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Launch sessions with <code class="font-mono">--worktree</code> for isolated git worktree environments
</p>
</div>
</section> </section>
<!-- Greeting Section --> <!-- Greeting Section -->
+1
View File
@@ -362,6 +362,7 @@ User: ${formattedMessage}`;
custom_instructions: config.custom_instructions || null, custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null, mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools, allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false,
}, },
}); });
@@ -87,6 +87,7 @@
custom_instructions: config.custom_instructions || null, custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null, mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: newGrantedTools, allowed_tools: newGrantedTools,
use_worktree: config.use_worktree ?? false,
}, },
}); });
+3
View File
@@ -101,6 +101,7 @@
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false,
}); });
let streamerModeActive = $state(false); let streamerModeActive = $state(false);
@@ -178,6 +179,7 @@
custom_instructions: currentConfig.custom_instructions || null, custom_instructions: currentConfig.custom_instructions || null,
mcp_servers_json: currentConfig.mcp_servers_json || null, mcp_servers_json: currentConfig.mcp_servers_json || null,
allowed_tools: allAllowedTools, allowed_tools: allAllowedTools,
use_worktree: currentConfig.use_worktree ?? false,
}, },
}); });
@@ -289,6 +291,7 @@
custom_instructions: currentConfig.custom_instructions || null, custom_instructions: currentConfig.custom_instructions || null,
mcp_servers_json: currentConfig.mcp_servers_json || null, mcp_servers_json: currentConfig.mcp_servers_json || null,
allowed_tools: allAllowedTools, allowed_tools: allAllowedTools,
use_worktree: currentConfig.use_worktree ?? false,
}, },
}); });
+8
View File
@@ -98,6 +98,8 @@
return "terminal-rate-limit"; return "terminal-rate-limit";
case "compact-prompt": case "compact-prompt":
return "terminal-compact-prompt"; return "terminal-compact-prompt";
case "worktree":
return "terminal-worktree";
default: default:
return "terminal-default"; return "terminal-default";
} }
@@ -117,6 +119,8 @@
return "[error]"; return "[error]";
case "rate-limit": case "rate-limit":
return "[rate-limit]"; return "[rate-limit]";
case "worktree":
return "[worktree]";
default: default:
return ""; return "";
} }
@@ -386,6 +390,10 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
.terminal-worktree {
color: var(--terminal-worktree, #34d399);
}
.compact-action-btn { .compact-action-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+12
View File
@@ -40,6 +40,8 @@ function getLineClass(type: string): string {
return "terminal-rate-limit"; return "terminal-rate-limit";
case "compact-prompt": case "compact-prompt":
return "terminal-compact-prompt"; return "terminal-compact-prompt";
case "worktree":
return "terminal-worktree";
default: default:
return "terminal-default"; return "terminal-default";
} }
@@ -59,6 +61,8 @@ function getLinePrefix(type: string): string {
return "[error]"; return "[error]";
case "rate-limit": case "rate-limit":
return "[rate-limit]"; return "[rate-limit]";
case "worktree":
return "[worktree]";
default: default:
return ""; return "";
} }
@@ -116,6 +120,10 @@ describe("getLineClass", () => {
expect(getLineClass("compact-prompt")).toBe("terminal-compact-prompt"); 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", () => { it("returns terminal-default for unknown line types", () => {
expect(getLineClass("unknown")).toBe("terminal-default"); expect(getLineClass("unknown")).toBe("terminal-default");
expect(getLineClass("")).toBe("terminal-default"); expect(getLineClass("")).toBe("terminal-default");
@@ -152,6 +160,10 @@ describe("getLinePrefix", () => {
expect(getLinePrefix("compact-prompt")).toBe(""); 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)", () => { it("returns empty string for thinking lines (no prefix)", () => {
expect(getLinePrefix("thinking")).toBe(""); expect(getLinePrefix("thinking")).toBe("");
}); });
@@ -106,6 +106,7 @@
custom_instructions: config.custom_instructions || null, custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null, mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: grantedToolsList, allowed_tools: grantedToolsList,
use_worktree: config.use_worktree ?? false,
}, },
}); });
+3
View File
@@ -194,6 +194,7 @@ describe("config store", () => {
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false,
}; };
expect(config.model).toBe("claude-sonnet-4"); expect(config.model).toBe("claude-sonnet-4");
@@ -240,6 +241,7 @@ describe("config store", () => {
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false,
}; };
expect(config.model).toBeNull(); expect(config.model).toBeNull();
@@ -785,6 +787,7 @@ describe("config store", () => {
budget_warning_threshold: 0.9, budget_warning_threshold: 0.9,
discord_rpc_enabled: false, discord_rpc_enabled: false,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false,
}; };
const mockInvokeImpl = vi.mocked(invoke); const mockInvokeImpl = vi.mocked(invoke);
+3
View File
@@ -47,6 +47,8 @@ export interface HikariConfig {
discord_rpc_enabled: boolean; discord_rpc_enabled: boolean;
// Thinking blocks settings // Thinking blocks settings
show_thinking_blocks: boolean; show_thinking_blocks: boolean;
// Worktree isolation
use_worktree: boolean;
} }
const defaultConfig: HikariConfig = { const defaultConfig: HikariConfig = {
@@ -87,6 +89,7 @@ const defaultConfig: HikariConfig = {
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false,
}; };
function createConfigStore() { function createConfigStore() {
+4 -2
View File
@@ -331,7 +331,8 @@ export async function initializeTauriListeners() {
| "error" | "error"
| "thinking" | "thinking"
| "rate-limit" | "rate-limit"
| "compact-prompt", | "compact-prompt"
| "worktree",
content, content,
tool_name || undefined, tool_name || undefined,
costData, costData,
@@ -348,7 +349,8 @@ export async function initializeTauriListeners() {
| "error" | "error"
| "thinking" | "thinking"
| "rate-limit" | "rate-limit"
| "compact-prompt", | "compact-prompt"
| "worktree",
content, content,
tool_name || undefined, tool_name || undefined,
costData, costData,
+2 -1
View File
@@ -8,7 +8,8 @@ export interface TerminalLine {
| "error" | "error"
| "thinking" | "thinking"
| "rate-limit" | "rate-limit"
| "compact-prompt"; | "compact-prompt"
| "worktree";
content: string; content: string;
timestamp: Date; timestamp: Date;
toolName?: string; toolName?: string;