Files
hikari-desktop/src/lib/components/PermissionModal.svelte
T
hikari 54280b3920 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
2026-02-24 17:39:09 -08:00

309 lines
11 KiB
Svelte

<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { SvelteSet } from "svelte/reactivity";
import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import type { PermissionRequest } from "$lib/types/messages";
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config";
let permissions: PermissionRequest[] = $state([]);
let selectedPermissions = new SvelteSet<string>();
let grantedToolsList: string[] = $state([]);
let workingDirectory = $state("");
conversationsStore.pendingPermissions.subscribe((perms) => {
permissions = perms;
// When new permissions arrive, select all by default
if (perms.length > 0) {
selectedPermissions = new SvelteSet(perms.map((p) => p.id));
characterState.setState("permission");
}
});
claudeStore.grantedTools.subscribe((tools) => {
grantedToolsList = Array.from(tools);
});
claudeStore.currentWorkingDirectory.subscribe((dir) => {
workingDirectory = dir;
});
async function handleApproveAndReconnect() {
const selectedPerms = permissions.filter((p) => selectedPermissions.has(p.id));
if (selectedPerms.length === 0) {
claudeStore.addLine("system", "No permissions selected to approve");
claudeStore.clearPermission();
characterState.setTemporaryState("idle", 1000);
return;
}
// Capture conversation history before clearing/reconnecting
const conversationHistory = claudeStore.getConversationHistory();
// Grant all selected tools
const newlyGrantedTools: string[] = [];
for (const perm of selectedPerms) {
if (!grantedToolsList.includes(perm.tool)) {
claudeStore.grantTool(perm.tool);
newlyGrantedTools.push(perm.tool);
}
}
const newGrantedTools = [...grantedToolsList, ...newlyGrantedTools];
const toolNames = selectedPerms.map((p) => p.tool).join(", ");
claudeStore.addLine(
"system",
`Permission granted for ${selectedPerms.length} tool(s): ${toolNames}. Reconnecting with context...`
);
claudeStore.clearPermission();
// Stop current session and reconnect with new permissions
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
// Prevent stats reset on reconnection
setSkipNextGreeting(true);
await invoke("stop_claude", { conversationId });
// Small delay to ensure clean shutdown
await new Promise((resolve) => setTimeout(resolve, 500));
const config = configStore.getConfig();
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDirectory || "/home/naomi",
model: config.model || null,
api_key: config.api_key || null,
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: newGrantedTools,
use_worktree: config.use_worktree ?? false,
},
});
// Update Discord RPC when reconnecting after permission grant
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
// Wait for connection to establish
await new Promise((resolve) => setTimeout(resolve, 1000));
// Send conversation context to restore state
if (conversationHistory) {
const blockedActions = selectedPerms
.map((p) => `- ${p.tool} with input:\n${JSON.stringify(p.input, null, 2)}`)
.join("\n\n");
const contextMessage = `[CONTEXT RESTORATION]
I just granted you permission to use ${selectedPerms.length} tool(s): ${toolNames}. Here's our conversation so far:
${conversationHistory}
The actions that were blocked:
${blockedActions}
Please continue where we left off and retry those actions now that you have permission.`;
await invoke("send_prompt", {
conversationId,
message: contextMessage,
});
}
characterState.setTemporaryState("success", 2000);
} catch (error) {
console.error("Failed to reconnect:", error);
claudeStore.addLine("error", `Reconnect failed: ${error}`);
}
}
function handleDismiss() {
claudeStore.clearPermission();
claudeStore.addLine("system", "Permission request dismissed");
characterState.setTemporaryState("idle", 1000);
}
function formatInput(input: Record<string, unknown>): string {
try {
return JSON.stringify(input, null, 2);
} catch {
return String(input);
}
}
function isToolAlreadyGranted(toolName: string): boolean {
return grantedToolsList.includes(toolName);
}
function togglePermission(toolRequestId: string) {
if (selectedPermissions.has(toolRequestId)) {
selectedPermissions.delete(toolRequestId);
} else {
selectedPermissions.add(toolRequestId);
}
}
function selectAll() {
selectedPermissions = new SvelteSet(permissions.map((p) => p.id));
}
function selectNone() {
selectedPermissions = new SvelteSet();
}
function handleKeydown(event: KeyboardEvent) {
if (permissions.length === 0) return;
if (event.key === "Enter") {
event.preventDefault();
handleApproveAndReconnect();
} else if (event.key === "Escape") {
event.preventDefault();
handleDismiss();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if permissions.length > 0}
<div
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-[60] backdrop-blur-sm"
>
<div
class="permission-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-2xl w-full mx-4 shadow-2xl max-h-[90vh] overflow-y-auto"
>
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center">
<span class="text-xl">🔐</span>
</div>
<div class="flex-1">
<h2 class="text-lg font-semibold text-[var(--text-primary)]">
{permissions.length === 1
? "Permission Required"
: `${permissions.length} Permissions Required`}
</h2>
<p class="text-sm text-[var(--text-secondary)]">
Hikari tried to use {permissions.length === 1
? "a restricted tool"
: "restricted tools"}
</p>
</div>
<div class="flex gap-2 text-xs">
<button
onclick={selectAll}
class="px-2 py-1 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded transition-colors"
>
Select All
</button>
<button
onclick={selectNone}
class="px-2 py-1 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded transition-colors"
>
Select None
</button>
</div>
</div>
<div class="mb-4 px-3 py-2 bg-amber-500/10 border border-amber-500/30 rounded-md">
<p class="text-sm text-amber-300">
{permissions.length === 1
? "This action was automatically blocked. Select which permissions to grant."
: "These actions were automatically blocked. Select which permissions to grant."}
</p>
</div>
<div class="space-y-3 mb-6">
{#each permissions as perm (perm.id)}
<div
class="border border-[var(--border-color)] rounded-lg p-4 cursor-pointer transition-colors {selectedPermissions.has(
perm.id
)
? 'bg-green-500/10 border-green-500/30'
: 'bg-[var(--bg-secondary)] hover:bg-[var(--bg-secondary)]/80'}"
role="button"
tabindex="0"
onclick={() => togglePermission(perm.id)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
togglePermission(perm.id);
}
}}
>
<div class="flex items-start gap-3">
<div class="mt-1">
<input
type="checkbox"
checked={selectedPermissions.has(perm.id)}
onchange={() => togglePermission(perm.id)}
class="w-4 h-4 accent-green-500"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
<span class="text-[var(--accent-primary)] font-mono text-sm font-medium">
{perm.tool}
</span>
{#if isToolAlreadyGranted(perm.tool)}
<span class="text-xs text-green-400 bg-green-500/20 px-2 py-0.5 rounded">
Already Granted
</span>
{/if}
</div>
<div class="text-sm text-[var(--text-secondary)] mb-2">
{perm.description}
</div>
{#if Object.keys(perm.input).length > 0}
<details class="text-xs">
<summary
class="cursor-pointer text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
View details
</summary>
<pre
class="mt-2 px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] overflow-x-auto max-h-32">{formatInput(
perm.input
)}</pre>
</details>
{/if}
</div>
</div>
</div>
{/each}
</div>
<div class="flex gap-3">
<button
onclick={handleDismiss}
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
>
Dismiss All
</button>
<button
onclick={handleApproveAndReconnect}
disabled={selectedPermissions.size === 0}
class="flex-1 px-4 py-2 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Approve Selected ({selectedPermissions.size})
</button>
</div>
</div>
</div>
{/if}