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 @@