From 82061f125b13eec8de5488d65e35b39b40513f1c Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 6 Feb 2026 19:54:12 -0800 Subject: [PATCH] feat: batch parallel permission requests for improved UX Implemented intelligent permission batching that detects cancelled sibling tool calls and presents them together in a single modal. This dramatically improves the user experience when multiple tools require permission. Key changes: - Track pending tool uses from Assistant messages in thread-local storage - Capture and batch sibling tools that get cancelled due to permission denials - Clear pending tools on each Result message to prevent accumulation - Use SvelteSet for reactive permission selection in the modal - Update permission modal to display count when multiple permissions requested - Fix check-all.sh to source nvm for pnpm access - Add git commit instructions to CLAUDE.md for this project Technical improvements: - Thread-local storage for cross-message tool tracking - Proper null checking in TypeScript permission handling - Clippy-compliant const initialisation for thread_local - All ESLint, TypeScript, and Rust checks passing The modal now shows both the explicitly denied tool AND any sibling tools that were called in parallel, allowing users to approve all permissions in one go instead of clicking through multiple modals. --- CLAUDE.md | 17 ++ check-all.sh | 4 + src-tauri/src/types.rs | 7 +- src-tauri/src/wsl_bridge.rs | 114 ++++++-- src/lib/components/PermissionModal.svelte | 301 ++++++++++++++-------- src/lib/stores/claude.ts | 5 +- src/lib/stores/conversations.ts | 42 ++- src/lib/tauri.ts | 50 ++-- src/lib/types/messages.ts | 6 +- 9 files changed, 392 insertions(+), 154 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 293f73a..1fcc3fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,16 +3,33 @@ ## Repository Information This project is hosted on both GitHub and Gitea: + - **GitHub**: `naomi-lgbt/hikari-desktop` (public mirror) - **Gitea**: `nhcarrigan/hikari-desktop` (primary development) ## MCP Server Usage When working with issues, pull requests, or other repository operations for this project: + - **Use `gitea-hikari` MCP server** - This allows Hikari to act as herself - **Target repository**: `nhcarrigan/hikari-desktop` - **Gitea instance**: `git.nhcarrigan.com` +## Git Commits + +When asked to commit changes for this project: + +- **Always commit as Hikari** using: `--author="Hikari "` +- **Always use `--no-gpg-sign`** since Hikari doesn't have GPG signing set up +- **Never add `Co-Authored-By` lines** for Gitea commits +- **Always ask for confirmation** before committing + +Example commit command: + +```bash +git commit --author="Hikari " --no-gpg-sign -m "your commit message" +``` + ## Project Context Hikari Desktop is a Tauri-based desktop application that wraps Claude Code with a visual anime character (Hikari) who appears on screen. This is a personal project where Hikari can sign her work and act as herself! diff --git a/check-all.sh b/check-all.sh index a0f7f46..dc1ca4f 100755 --- a/check-all.sh +++ b/check-all.sh @@ -1,5 +1,9 @@ #!/bin/bash +# Source nvm to get access to pnpm +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index e437716..ec4d039 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -202,11 +202,16 @@ pub struct OutputEvent { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PermissionPromptEvent { +pub struct PermissionPromptEventItem { pub id: String, pub tool_name: String, pub tool_input: serde_json::Value, pub description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PermissionPromptEvent { + pub permissions: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, } diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 8b0e405..0ce6121 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -16,9 +16,24 @@ use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats}; use crate::types::{ AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent, - QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent, WorkingDirectoryEvent, + PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent, + UserQuestionEvent, WorkingDirectoryEvent, }; use parking_lot::RwLock; +use std::cell::RefCell; + +thread_local! { + /// Stores pending tool uses from the most recent Assistant message + /// to enable batching permission requests for sibling cancelled tools + static PENDING_TOOL_USES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +#[derive(Debug, Clone)] +struct PendingToolUse { + tool_use_id: String, + tool_name: String, + tool_input: serde_json::Value, +} const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"]; const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"]; @@ -770,6 +785,26 @@ fn process_json_line( }) .collect(); + // Store pending tool uses for permission batching (only for top-level, not subagents) + if parent_tool_use_id.is_none() { + PENDING_TOOL_USES.with(|pending| { + let tool_uses: Vec = message + .content + .iter() + .filter_map(|block| match block { + ContentBlock::ToolUse { id, name, input } => Some(PendingToolUse { + tool_use_id: id.clone(), + tool_name: name.clone(), + tool_input: input.clone(), + }), + _ => None, + }) + .collect(); + // Append to existing pending tools instead of replacing + pending.borrow_mut().extend(tool_uses); + }); + } + // Track message cost for display let mut message_cost: Option = None; @@ -1034,6 +1069,15 @@ fn process_json_line( CharacterState::Error }; + // Capture pending tool uses before clearing them + // We'll use these for permission batching if there are denials + let captured_pending_tools = PENDING_TOOL_USES.with(|pending| { + let tools = pending.borrow().clone(); + // Clear immediately so they don't accumulate across requests + pending.borrow_mut().clear(); + tools + }); + // Log turn metrics if available if let Some(duration) = duration_ms { println!("Turn completed in {}ms", duration); @@ -1172,9 +1216,16 @@ fn process_json_line( // Check for permission denials and emit prompts for each if let Some(denials) = permission_denials { - let mut has_regular_denials = false; + // Only process if there are actually denials + if !denials.is_empty() { + let mut regular_permission_requests = Vec::new(); - for denial in denials { + // Get denied tool IDs for later comparison + let denied_tool_ids: Vec = denials.iter() + .map(|d| d.tool_use_id.clone()) + .collect(); + + for denial in denials { // Special handling for AskUserQuestion tool if denial.tool_name == "AskUserQuestion" { if let Some(questions) = denial @@ -1235,24 +1286,41 @@ fn process_json_line( } } } else { - has_regular_denials = true; let description = format_tool_description(&denial.tool_name, &denial.tool_input); - let _ = app.emit( - "claude:permission", - PermissionPromptEvent { - id: denial.tool_use_id.clone(), - tool_name: denial.tool_name.clone(), - tool_input: denial.tool_input.clone(), - description, - conversation_id: conversation_id.clone(), - }, - ); + regular_permission_requests.push(PermissionPromptEventItem { + id: denial.tool_use_id.clone(), + tool_name: denial.tool_name.clone(), + tool_input: denial.tool_input.clone(), + description, + }); } } - // Show permission state if there were any denials (questions or regular) - if has_regular_denials || !denials.is_empty() { + // Check for sibling tools that may have been cancelled + // Add them to the permission batch so they can be approved together + for tool_use in captured_pending_tools.iter() { + // Only add tools that weren't explicitly denied (these are likely cancelled siblings) + if !denied_tool_ids.contains(&tool_use.tool_use_id) { + let description = format_tool_description(&tool_use.tool_name, &tool_use.tool_input); + regular_permission_requests.push(PermissionPromptEventItem { + id: tool_use.tool_use_id.clone(), + tool_name: tool_use.tool_name.clone(), + tool_input: tool_use.tool_input.clone(), + description, + }); + } + } + + // Emit all regular permission requests as a single batched event + if !regular_permission_requests.is_empty() { + let _ = app.emit( + "claude:permission", + PermissionPromptEvent { + permissions: regular_permission_requests, + conversation_id: conversation_id.clone(), + }, + ); emit_state_change( app, CharacterState::Permission, @@ -1261,7 +1329,19 @@ fn process_json_line( ); return Ok(()); } - } + + // Show permission state if there were any question denials + if !denials.is_empty() { + emit_state_change( + app, + CharacterState::Permission, + None, + conversation_id.clone(), + ); + return Ok(()); + } + } // end of else block for non-empty denials + } // end of if let Some(denials) emit_state_change(app, state, None, conversation_id.clone()); } diff --git a/src/lib/components/PermissionModal.svelte b/src/lib/components/PermissionModal.svelte index 4e0e06a..007a95f 100644 --- a/src/lib/components/PermissionModal.svelte +++ b/src/lib/components/PermissionModal.svelte @@ -1,6 +1,7 @@