From bf411adeb79b720da28c37e6d76e3c3dd196a849 Mon Sep 17 00:00:00 2001 From: Hikari Date: Sat, 7 Feb 2026 01:55:49 -0800 Subject: [PATCH] fix: critical permission modal and config issues (#127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR resolves several critical bugs that were blocking the permission modal and causing config loss: - **Permission modal not appearing** - Fixed z-index issues and runtime errors - **Config store race condition** - Resolved critical race condition causing settings to be lost - **Excessive logging** - Removed redundant fmt layer that was writing to hidden stdout - **System tool prompts** - Prevented unnecessary permission prompts for built-in tools - **Permission batching** - Added support for parallel permission requests - **ExitPlanMode tool** - Fixed ExitPlanMode tool not functioning correctly ## Changes Made ### Permission Modal Fixes - Updated z-index to proper value (9999) to ensure modal appears above all other UI elements - Fixed runtime errors that were preventing modal from rendering - Resolved issues with permission grants not being properly applied ### Config Store Race Condition - Fixed critical race condition where multiple rapid config updates would result in lost settings - Ensured config writes are properly sequenced to prevent data loss - Added proper synchronisation for config store operations ### Logging Cleanup - Removed redundant fmt formatting layer that was outputting to hidden stdout - Cleaned up excessive debug logging added during troubleshooting - Removed temporary debugging documentation files ### UX Improvements - Added close confirmation modal with minimise to tray option - Implemented batching for parallel permission requests - Added debug console for viewing frontend and backend logs ### ExitPlanMode Fix - Fixed ExitPlanMode tool not functioning correctly, ensuring proper transitions out of plan mode ## Issues Resolved Closes #112 - Permission flow now properly handles multiple tool requests Closes #113 - ExitPlanMode tool now functions correctly Closes #126 - Debug console feature added (partial - basic implementation complete) ## Test Plan - [x] Permission modal appears and functions correctly - [x] Config settings persist across app restarts - [x] No excessive logging in production builds - [x] System tools don't trigger permission prompts - [x] Parallel permission requests are properly batched - [x] Debug console displays frontend and backend logs - [x] ExitPlanMode properly exits plan mode --- ✨ This PR was created with help from Hikari~ 🌸 Co-authored-by: Naomi Carrigan Reviewed-on: https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/pulls/127 Co-authored-by: Hikari Co-committed-by: Hikari --- CLAUDE.md | 35 ++ check-all.sh | 10 +- src-tauri/Cargo.lock | 74 ++++ src-tauri/Cargo.toml | 2 + src-tauri/capabilities/default.json | 3 +- src-tauri/src/achievements.rs | 24 +- src-tauri/src/commands.rs | 13 + src-tauri/src/config.rs | 7 +- src-tauri/src/debug_logger.rs | 78 +++++ src-tauri/src/discord_rpc.rs | 2 +- src-tauri/src/lib.rs | 36 +- src-tauri/src/stats.rs | 16 +- src-tauri/src/temp_manager.rs | 6 +- src-tauri/src/tray.rs | 20 -- src-tauri/src/types.rs | 7 +- src-tauri/src/wsl_bridge.rs | 245 ++++++++++--- src-tauri/src/wsl_notifications.rs | 8 +- .../components/CloseAppConfirmModal.svelte | 116 ++++++ src/lib/components/ConfigSidebar.svelte | 16 - src/lib/components/DebugConsole.svelte | 330 ++++++++++++++++++ src/lib/components/PermissionModal.svelte | 303 ++++++++++------ src/lib/components/StatusBar.svelte | 16 +- src/lib/stores/claude.ts | 5 +- src/lib/stores/config.test.ts | 328 ++++++++++++++++- src/lib/stores/config.ts | 41 +-- src/lib/stores/conversations.ts | 42 ++- src/lib/stores/debugConsole.ts | 154 ++++++++ src/lib/stores/snippets.test.ts | 67 ++++ src/lib/tauri.ts | 56 +-- src/lib/types/messages.ts | 6 +- src/lib/utils/stateMapper.test.ts | 175 ++++++++++ src/routes/+layout.svelte | 2 + src/routes/+page.svelte | 73 ++++ vitest.setup.ts | 1 - 34 files changed, 2010 insertions(+), 307 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src-tauri/src/debug_logger.rs create mode 100644 src/lib/components/CloseAppConfirmModal.svelte create mode 100644 src/lib/components/DebugConsole.svelte create mode 100644 src/lib/stores/debugConsole.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1fcc3fa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,35 @@ +# Hikari Desktop - Project Instructions + +## 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..d05a9a0 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' @@ -32,11 +36,11 @@ echo -e "${YELLOW}🔍 Running all checks for Hikari Desktop...${NC}" run_check "Frontend lint" "pnpm lint" || failed=1 run_check "Frontend format check" "pnpm format:check" || failed=1 run_check "Frontend type check" "pnpm check" || failed=1 -run_check "Frontend tests" "pnpm test" || failed=1 +run_check "Frontend tests with coverage" "pnpm test:coverage" || failed=1 # Backend checks -run_check "Backend clippy (strict)" "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings" || failed=1 -run_check "Backend tests" "cargo test" || failed=1 +run_check "Backend clippy (strict)" "(cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings)" || failed=1 +run_check "Backend tests with coverage" "(cd src-tauri && cargo llvm-cov --fail-under-lines 50)" || failed=1 # Summary echo -e "\n${YELLOW}========================================${NC}" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9e26d9b..9854341 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1658,6 +1658,8 @@ dependencies = [ "tauri-plugin-store", "tempfile", "tokio", + "tracing", + "tracing-subscriber", "uuid 1.19.0", "windows 0.62.2", ] @@ -2245,6 +2247,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matches" version = "0.1.10" @@ -2401,6 +2412,15 @@ dependencies = [ "zbus", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -3879,6 +3899,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shared_child" version = "1.1.1" @@ -4695,6 +4724,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tiff" version = "0.10.3" @@ -4986,6 +5024,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -5167,6 +5235,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index da7a12c..b80943f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,6 +33,8 @@ semver = "1" chrono = { version = "0.4.43", features = ["serde"] } discord-rich-presence = "0.2" dirs = "5" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } [target.'cfg(windows)'.dependencies] windows = { version = "0.62", features = [ diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index f8363c3..a7a7827 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -30,6 +30,7 @@ }, "core:window:allow-set-size", "core:window:allow-set-always-on-top", - "core:window:allow-inner-size" + "core:window:allow-inner-size", + "core:window:allow-hide" ] } diff --git a/src-tauri/src/achievements.rs b/src-tauri/src/achievements.rs index f4991a4..9e71031 100644 --- a/src-tauri/src/achievements.rs +++ b/src-tauri/src/achievements.rs @@ -1671,7 +1671,7 @@ pub fn check_message_achievements( let mut newly_unlocked = Vec::new(); let message_lower = message.to_lowercase(); - println!("Checking message achievements for: {}", message); + tracing::info!("Checking message achievements for: {}", message); // Relationship & Greetings if message_lower.contains("good morning") && progress.unlock(AchievementId::GoodMorning) { @@ -1863,18 +1863,18 @@ pub fn check_achievements( ) -> Vec { let mut newly_unlocked = Vec::new(); - println!( + tracing::info!( "Checking achievements with stats: messages={}, tokens={}, code_blocks={}", stats.messages_exchanged, stats.total_input_tokens + stats.total_output_tokens, stats.code_blocks_generated ); - println!("Currently unlocked: {:?}", progress.unlocked); + tracing::info!("Currently unlocked: {:?}", progress.unlocked); // Token milestones let total_tokens = stats.total_input_tokens + stats.total_output_tokens; if total_tokens >= 1_000 && progress.unlock(AchievementId::FirstSteps) { - println!("Unlocked FirstSteps achievement!"); + tracing::info!("Unlocked FirstSteps achievement!"); newly_unlocked.push(AchievementId::FirstSteps); } if total_tokens >= 10_000 && progress.unlock(AchievementId::GrowingStrong) { @@ -2244,7 +2244,7 @@ pub async fn save_achievements( // Create a serializable version with just the unlocked achievement IDs let unlocked_list: Vec = progress.unlocked.iter().cloned().collect(); - println!("Saving achievements: {:?}", unlocked_list); + tracing::info!("Saving achievements: {:?}", unlocked_list); store.set( "unlocked", @@ -2252,18 +2252,18 @@ pub async fn save_achievements( ); store.save().map_err(|e| e.to_string())?; - println!("Achievements saved successfully"); + tracing::info!("Achievements saved successfully"); Ok(()) } // Load achievements from persistent store pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress { - println!("Loading achievements from store..."); + tracing::info!("Loading achievements from store..."); let store = match app.store("achievements.json") { Ok(s) => s, Err(e) => { - println!("Failed to open achievements store: {}", e); + tracing::error!("Failed to open achievements store: {}", e); return AchievementProgress::new(); } }; @@ -2272,19 +2272,19 @@ pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress { // Get unlocked achievements if let Some(unlocked_value) = store.get("unlocked") { - println!("Found unlocked value in store: {:?}", unlocked_value); + tracing::info!("Found unlocked value in store: {:?}", unlocked_value); if let Ok(unlocked_list) = serde_json::from_value::>(unlocked_value.clone()) { - println!("Loaded {} achievements", unlocked_list.len()); + tracing::info!("Loaded {} achievements", unlocked_list.len()); for achievement_id in unlocked_list { progress.unlocked.insert(achievement_id); } } else { - println!("Failed to parse unlocked achievements"); + tracing::error!("Failed to parse unlocked achievements"); } } else { - println!("No unlocked achievements found in store"); + tracing::info!("No unlocked achievements found in store"); } progress diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1be5ccd..69c418c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1154,6 +1154,19 @@ pub async fn log_discord_rpc( Ok(()) } +#[tauri::command] +pub async fn close_application(app_handle: AppHandle) -> Result<(), String> { + // Get the main window + if let Some(window) = app_handle.get_webview_window("main") { + // Hide the window first for a smoother close + let _ = window.hide(); + } + + // Exit the application + app_handle.exit(0); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index b8fe217..0e0cfce 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -28,6 +28,7 @@ pub struct ClaudeStartOptions { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct HikariConfig { #[serde(default)] pub model: Option, @@ -71,9 +72,6 @@ pub struct HikariConfig { #[serde(default = "default_font_size")] pub font_size: u32, - #[serde(default)] - pub minimize_to_tray: bool, - #[serde(default)] pub streamer_mode: bool, @@ -134,7 +132,6 @@ impl Default for HikariConfig { update_checks_enabled: true, character_panel_width: None, font_size: 14, - minimize_to_tray: false, streamer_mode: false, streamer_hide_paths: false, compact_mode: false, @@ -242,7 +239,6 @@ mod tests { assert!(config.update_checks_enabled); assert!(config.character_panel_width.is_none()); assert_eq!(config.font_size, 14); - assert!(!config.minimize_to_tray); assert!(!config.streamer_mode); assert!(!config.streamer_hide_paths); assert!(!config.compact_mode); @@ -275,7 +271,6 @@ mod tests { update_checks_enabled: true, character_panel_width: Some(400), font_size: 16, - minimize_to_tray: true, streamer_mode: false, streamer_hide_paths: false, compact_mode: false, diff --git a/src-tauri/src/debug_logger.rs b/src-tauri/src/debug_logger.rs new file mode 100644 index 0000000..2691505 --- /dev/null +++ b/src-tauri/src/debug_logger.rs @@ -0,0 +1,78 @@ +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tauri::{AppHandle, Emitter}; +use tracing::{Level, Subscriber}; +use tracing_subscriber::layer::{Context, Layer}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DebugLogEvent { + pub level: String, + pub message: String, +} + +#[derive(Clone)] +pub struct TauriLogLayer { + app: Arc, +} + +impl TauriLogLayer { + pub fn new(app: AppHandle) -> Self { + Self { + app: Arc::new(app), + } + } +} + +impl Layer for TauriLogLayer +where + S: Subscriber, +{ + fn on_event( + &self, + event: &tracing::Event<'_>, + _ctx: Context<'_, S>, + ) { + let metadata = event.metadata(); + let level = match *metadata.level() { + Level::ERROR => "error", + Level::WARN => "warn", + Level::INFO => "info", + Level::DEBUG => "debug", + Level::TRACE => "debug", + }; + + // Extract message from the event + struct MessageVisitor { + message: String, + } + + impl tracing::field::Visit for MessageVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.message = format!("{:?}", value); + } + } + } + + let mut visitor = MessageVisitor { + message: String::new(), + }; + event.record(&mut visitor); + + // If we couldn't extract a message, try to format the whole event + if visitor.message.is_empty() { + visitor.message = metadata.name().to_string(); + } + + // Strip quotes from the message + let message = visitor.message.trim_matches('"').to_string(); + + let log_event = DebugLogEvent { + level: level.to_string(), + message, + }; + + // Emit to frontend + let _ = self.app.emit("debug:log", log_event); + } +} diff --git a/src-tauri/src/discord_rpc.rs b/src-tauri/src/discord_rpc.rs index a9e61fa..ffdfa3c 100644 --- a/src-tauri/src/discord_rpc.rs +++ b/src-tauri/src/discord_rpc.rs @@ -30,7 +30,7 @@ impl DiscordRpcManager { if let Ok(app_data_dir) = app_handle.path().app_data_dir() { // Ensure the directory exists if let Err(e) = std::fs::create_dir_all(&app_data_dir) { - eprintln!("Failed to create app data directory: {}", e); + tracing::error!("Failed to create app data directory: {}", e); return; } let log_path = app_data_dir.join("hikari_discord_rpc.log"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index be8656d..54a0969 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod clipboard; mod commands; mod config; mod cost_tracking; +mod debug_logger; mod discord_rpc; mod git; mod notifications; @@ -24,6 +25,7 @@ use bridge_manager::create_shared_bridge_manager; use clipboard::*; use commands::load_saved_achievements; use commands::*; +use debug_logger::TauriLogLayer; use discord_rpc::DiscordRpcManager; use git::*; use notifications::*; @@ -31,9 +33,11 @@ use quick_actions::*; use sessions::*; use snippets::*; use std::sync::Arc; -use tauri::Manager; +use tauri::{Emitter, Manager}; use temp_manager::create_shared_temp_manager; -use tray::{setup_tray, should_minimize_to_tray}; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tray::setup_tray; use vbs_notification::*; use windows_toast::*; use wsl_notifications::*; @@ -58,6 +62,14 @@ pub fn run() { .manage(temp_manager.clone()) .manage(discord_rpc.clone()) .setup(move |app| { + // Initialize tracing with custom layer that emits to frontend + // NOTE: We don't use fmt::layer() because in production builds with windows_subsystem = "windows", + // stdout is hidden. Instead, all logs go through TauriLogLayer to the debug console. + let tauri_layer = TauriLogLayer::new(app.handle().clone()); + tracing_subscriber::registry() + .with(tauri_layer) + .init(); + // Initialize the app handle in the bridge manager bridge_manager.lock().set_app_handle(app.handle().clone()); @@ -67,26 +79,29 @@ pub fn run() { // Clean up any orphaned temp files from previous sessions if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() { if count > 0 { - println!("Cleaned up {} orphaned temp files", count); + tracing::info!("Cleaned up {} orphaned temp files", count); } } + tracing::info!("Hikari Desktop started successfully"); + // Set up system tray if let Err(e) = setup_tray(app.handle()) { - eprintln!("Failed to set up system tray: {}", e); + tracing::error!("Failed to set up system tray: {}", e); } - // Handle window close event for minimize to tray + // Handle window close event for minimize to tray and close confirmation let main_window = app.get_webview_window("main").unwrap(); main_window.on_window_event({ let app_handle = app.handle().clone(); move |event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { - if should_minimize_to_tray(&app_handle) { - api.prevent_close(); - if let Some(window) = app_handle.get_webview_window("main") { - let _ = window.hide(); - } + // Always prevent default close - let frontend handle it + api.prevent_close(); + + // Emit event to frontend to show confirmation modal + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.emit("window-close-requested", ()); } } } @@ -181,6 +196,7 @@ pub fn run() { update_discord_rpc, stop_discord_rpc, log_discord_rpc, + close_application, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/stats.rs b/src-tauri/src/stats.rs index cf65a0a..6422bef 100644 --- a/src-tauri/src/stats.rs +++ b/src-tauri/src/stats.rs @@ -618,7 +618,7 @@ pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<() let persisted = PersistedStats::from(stats); - println!("Saving stats: {:?}", persisted); + tracing::info!("Saving stats: {:?}", persisted); store.set( "lifetime_stats", @@ -626,32 +626,32 @@ pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<() ); store.save().map_err(|e| e.to_string())?; - println!("Stats saved successfully"); + tracing::info!("Stats saved successfully"); Ok(()) } /// Load lifetime stats from persistent store pub async fn load_stats(app: &tauri::AppHandle) -> Option { - println!("Loading stats from store..."); + tracing::info!("Loading stats from store..."); let store = match app.store("stats.json") { Ok(s) => s, Err(e) => { - println!("Failed to open stats store: {}", e); + tracing::error!("Failed to open stats store: {}", e); return None; } }; if let Some(stats_value) = store.get("lifetime_stats") { - println!("Found lifetime stats in store: {:?}", stats_value); + tracing::info!("Found lifetime stats in store: {:?}", stats_value); if let Ok(persisted) = serde_json::from_value::(stats_value.clone()) { - println!("Loaded lifetime stats successfully"); + tracing::info!("Loaded lifetime stats successfully"); return Some(persisted); } else { - println!("Failed to parse lifetime stats"); + tracing::error!("Failed to parse lifetime stats"); } } else { - println!("No lifetime stats found in store"); + tracing::info!("No lifetime stats found in store"); } None diff --git a/src-tauri/src/temp_manager.rs b/src-tauri/src/temp_manager.rs index f2d78b5..2280cf9 100644 --- a/src-tauri/src/temp_manager.rs +++ b/src-tauri/src/temp_manager.rs @@ -77,8 +77,8 @@ impl TempFileManager { for file_path in files { if file_path.exists() { if let Err(e) = fs::remove_file(&file_path) { - eprintln!( - "Warning: Failed to remove temp file {:?}: {}", + tracing::warn!( + "Failed to remove temp file {:?}: {}", file_path, e ); } @@ -115,7 +115,7 @@ impl TempFileManager { let path = entry.path(); if path.is_file() && !tracked_files.contains(&path) { if let Err(e) = fs::remove_file(&path) { - eprintln!("Warning: Failed to remove orphaned file {:?}: {}", path, e); + tracing::warn!("Failed to remove orphaned file {:?}: {}", path, e); } else { cleaned_count += 1; } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 9f87b0b..4532ac7 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -4,8 +4,6 @@ use tauri::{ AppHandle, Manager, }; -use crate::config::HikariConfig; - pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { let show_item = MenuItem::with_id(app, "show", "Show Hikari", true, None::<&str>)?; let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; @@ -48,21 +46,3 @@ pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { Ok(()) } - -pub fn should_minimize_to_tray(app: &AppHandle) -> bool { - let config_path = app - .path() - .app_config_dir() - .ok() - .map(|p| p.join("hikari-config.json")); - - if let Some(path) = config_path { - if let Ok(content) = std::fs::read_to_string(&path) { - if let Ok(config) = serde_json::from_str::(&content) { - return config.minimize_to_tray; - } - } - } - - false -} 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..18c8d78 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"]; @@ -118,21 +133,21 @@ impl WslBridge { let app_clone = app.clone(); let stats = self.stats.clone(); tauri::async_runtime::spawn(async move { - println!("Loading saved achievements..."); + tracing::info!("Loading saved achievements..."); let achievements = crate::achievements::load_achievements(&app_clone).await; - println!( + tracing::info!( "Loaded {} unlocked achievements", achievements.unlocked.len() ); - println!("Loading saved stats..."); + tracing::info!("Loading saved stats..."); let persisted_stats = crate::stats::load_stats(&app_clone).await; let mut stats_guard = stats.write(); stats_guard.achievements = achievements; if let Some(persisted) = persisted_stats { - println!("Applying persisted lifetime stats"); + tracing::info!("Applying persisted lifetime stats"); stats_guard.apply_persisted(persisted); } }); @@ -174,8 +189,8 @@ impl WslBridge { // Detect if we're running inside WSL or on Windows let is_wsl = detect_wsl(); - eprintln!("[DEBUG] is_wsl: {}", is_wsl); - eprintln!("[DEBUG] options: {:?}", options); + tracing::debug!("is_wsl: {}", is_wsl); + tracing::debug!("options: {:?}", options); let mut command = if is_wsl { // Running inside WSL - call claude directly @@ -184,8 +199,8 @@ impl WslBridge { "Could not find claude binary. Is Claude Code installed?".to_string() })?; - eprintln!("[DEBUG] Found claude at: {}", claude_path); - eprintln!("[DEBUG] Working dir: {}", working_dir); + tracing::debug!("Found claude at: {}", claude_path); + tracing::debug!("Working dir: {}", working_dir); let mut cmd = Command::new(&claude_path); cmd.args([ @@ -241,7 +256,7 @@ impl WslBridge { cmd } else { // Running on Windows - use wsl with bash login shell to ensure PATH is loaded - eprintln!("[DEBUG] Windows path - using wsl"); + tracing::debug!("Windows path - using wsl"); let mut cmd = Command::new("wsl"); // Build the claude command with all arguments @@ -307,7 +322,7 @@ impl WslBridge { .stderr(Stdio::piped()); let mut child = command.spawn().map_err(|e| { - eprintln!("[DEBUG] Spawn error: {:?}", e); + tracing::error!("Spawn error: {:?}", e); format!("Failed to spawn process: {}", e) })?; @@ -485,7 +500,7 @@ impl WslBridge { (input_chars, stats.current_request_output_chars, stats.current_request_thinking_chars, stats.current_request_tools.clone(), model) }; - println!("[COST ESTIMATION] Estimating cost for interrupted request"); + tracing::info!("[COST ESTIMATION] Estimating cost for interrupted request"); // Use conservative 3.5 chars/token for estimation (vs standard 4) let estimated_input_tokens = (input_chars as f64 / 3.5).ceil() as u64; @@ -503,7 +518,7 @@ impl WslBridge { let avg_tokens = (tool_stats.estimated_input_tokens + tool_stats.estimated_output_tokens) / tool_stats.call_count; tool_overhead_tokens += avg_tokens; - println!("[COST ESTIMATION] Tool {} average: {} tokens", tool_name, avg_tokens); + tracing::info!("[COST ESTIMATION] Tool {} average: {} tokens", tool_name, avg_tokens); } } } @@ -517,9 +532,9 @@ impl WslBridge { let conservative_input = (total_estimated_input as f64 * safety_margin).ceil() as u64; let conservative_output = (total_estimated_output as f64 * safety_margin).ceil() as u64; - println!("[COST ESTIMATION] Input: {} chars → {} tokens (+ {} tool overhead) × 1.2 safety = {} tokens", + tracing::info!("[COST ESTIMATION] Input: {} chars → {} tokens (+ {} tool overhead) × 1.2 safety = {} tokens", input_chars, estimated_input_tokens, tool_overhead_tokens, conservative_input); - println!("[COST ESTIMATION] Output: {} chars → {} tokens × 1.2 safety = {} tokens", + tracing::info!("[COST ESTIMATION] Output: {} chars → {} tokens × 1.2 safety = {} tokens", output_chars + thinking_chars, estimated_output_tokens, conservative_output); @@ -532,7 +547,7 @@ impl WslBridge { None, ); - println!("[COST ESTIMATION] Estimated cost: ${:.4} (conservative)", estimated_cost); + tracing::info!("[COST ESTIMATION] Estimated cost: ${:.4} (conservative)", estimated_cost); // Add to stats with estimated flag { @@ -572,11 +587,11 @@ impl WslBridge { let stats_snapshot = self.stats.read().clone(); let app_clone = app.clone(); tauri::async_runtime::spawn(async move { - println!("Saving stats on session stop..."); + tracing::info!("Saving stats on session stop..."); if let Err(e) = crate::stats::save_stats(&app_clone, &stats_snapshot).await { - eprintln!("Failed to save stats: {}", e); + tracing::error!("Failed to save stats: {}", e); } else { - println!("Stats saved successfully on session stop"); + tracing::info!("Stats saved successfully on session stop"); } }); @@ -621,11 +636,11 @@ fn handle_stdout( match line { Ok(line) if !line.is_empty() => { if let Err(e) = process_json_line(&line, &app, &stats, &conversation_id) { - eprintln!("Error processing line: {}", e); + tracing::error!("Error processing line: {}", e); } } Err(e) => { - eprintln!("Error reading stdout: {}", e); + tracing::error!("Error reading stdout: {}", e); break; } _ => {} @@ -648,7 +663,7 @@ fn handle_stderr( // Check if this is a SubagentStart hook message if line.contains("[SubagentStart Hook]") { if let Some(agent_data) = parse_subagent_start_hook(&line) { - eprintln!("[DEBUG] Parsed SubagentStart hook: agent_id={}, parent={:?}", + tracing::debug!("Parsed SubagentStart hook: agent_id={}, parent={:?}", agent_data.agent_id, agent_data.parent_tool_use_id); // Emit an agent-update event with the agent_id @@ -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; @@ -780,7 +815,7 @@ fn process_json_line( let stats_guard = stats.read(); stats_guard.model.clone() }).unwrap_or_else(|| { - println!("[WARNING] No model info available for cost calculation, using default"); + tracing::warn!("No model info available for cost calculation, using default"); "claude-sonnet-4-5-20250929".to_string() }); @@ -793,7 +828,7 @@ fn process_json_line( usage.cache_read_input_tokens, ); - println!("Assistant message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}, cost: ${:.4}", + tracing::info!("Assistant message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}, cost: ${:.4}", usage.input_tokens, usage.output_tokens, usage.cache_creation_input_tokens, @@ -882,8 +917,8 @@ fn process_json_line( .unwrap_or_default() .as_millis() as u64; - eprintln!( - "[DEBUG] Emitting agent-start: id={}, desc={}, type={}, parent={:?}", + tracing::debug!( + "Emitting agent-start: id={}, desc={}, type={}, parent={:?}", id, description, subagent_type, parent_tool_use_id ); @@ -1028,18 +1063,40 @@ fn process_json_line( duration_ms, num_turns, } => { + tracing::info!( + "Received Result message: subtype={}, has_denials={}, denial_count={:?}", + subtype, + permission_denials.is_some(), + permission_denials.as_ref().map(|d| d.len()) + ); + let state = if subtype == "success" { CharacterState::Success } else { 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 + }); + + tracing::debug!( + "Captured {} pending tool use(s): {:?}", + captured_pending_tools.len(), + captured_pending_tools.iter().map(|t| &t.tool_name).collect::>() + ); + // Log turn metrics if available if let Some(duration) = duration_ms { - println!("Turn completed in {}ms", duration); + tracing::info!("Turn completed in {}ms", duration); } if let Some(turns) = num_turns { - println!("Turn count: {}", turns); + tracing::info!("Turn count: {}", turns); } // Track token usage from Result messages if available @@ -1069,7 +1126,7 @@ fn process_json_line( usage_info.cache_creation_input_tokens, usage_info.cache_read_input_tokens, ); - println!("Result message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}", + tracing::info!("Result message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}", usage_info.input_tokens, usage_info.output_tokens, usage_info.cache_creation_input_tokens, @@ -1099,9 +1156,9 @@ fn process_json_line( let newly_unlocked = { let mut stats_guard = stats.write(); stats_guard.get_session_duration(); - println!("Checking achievements after result message..."); + tracing::info!("Checking achievements after result message..."); let unlocked = stats_guard.check_achievements(); - println!("Newly unlocked achievements: {:?}", unlocked); + tracing::info!("Newly unlocked achievements: {:?}", unlocked); unlocked }; @@ -1116,20 +1173,20 @@ fn process_json_line( // Save achievements after unlocking new ones if !newly_unlocked.is_empty() { - println!("Saving newly unlocked achievements: {:?}", newly_unlocked); + tracing::info!("Saving newly unlocked achievements: {:?}", newly_unlocked); let app_handle = app.clone(); let achievements_progress = stats.read().achievements.clone(); // Use Tauri's async runtime instead of tokio::spawn tauri::async_runtime::spawn(async move { - println!("Spawned save task for achievements"); + tracing::info!("Spawned save task for achievements"); if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress) .await { - eprintln!("Failed to save achievements: {}", e); + tracing::error!("Failed to save achievements: {}", e); } else { - println!("Achievement save task completed successfully"); + tracing::info!("Achievement save task completed successfully"); } }); } @@ -1146,9 +1203,9 @@ fn process_json_line( { let app_handle = app.clone(); tauri::async_runtime::spawn(async move { - println!("Periodic stats save (every 10 messages)..."); + tracing::info!("Periodic stats save (every 10 messages)..."); if let Err(e) = crate::stats::save_stats(&app_handle, ¤t_stats).await { - eprintln!("Failed to save stats: {}", e); + tracing::error!("Failed to save stats: {}", e); } }); } @@ -1172,9 +1229,36 @@ 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(); + + // Get denied tool IDs for later comparison + let denied_tool_ids: Vec = denials.iter() + .map(|d| d.tool_use_id.clone()) + .collect(); + + // Helper function to check if a tool is a system tool that should never require permission + let is_system_tool = |tool_name: &str| -> bool { + matches!(tool_name, "ExitPlanMode" | "EnterPlanMode") + }; + + for denial in denials { + // Skip system tools that should never require permission + if is_system_tool(&denial.tool_name) { + tracing::debug!( + "Skipping system tool: {} (id: {})", + denial.tool_name, + denial.tool_use_id + ); + continue; + } + tracing::debug!( + "Processing permission denial for: {} (id: {})", + denial.tool_name, + denial.tool_use_id + ); - for denial in denials { // Special handling for AskUserQuestion tool if denial.tool_name == "AskUserQuestion" { if let Some(questions) = denial @@ -1235,24 +1319,57 @@ 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() { + // Skip system tools that should never require permission + if is_system_tool(&tool_use.tool_name) { + continue; + } + // 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() { + tracing::info!( + "Emitting permission event for {} tool(s) in conversation {:?}", + regular_permission_requests.len(), + conversation_id + ); + for req in ®ular_permission_requests { + tracing::debug!( + "Permission requested: {} (id: {})", + req.tool_name, + req.id + ); + } + let _ = app.emit( + "claude:permission", + PermissionPromptEvent { + permissions: regular_permission_requests, + conversation_id: conversation_id.clone(), + }, + ); emit_state_change( app, CharacterState::Permission, @@ -1261,7 +1378,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()); } @@ -1311,7 +1440,7 @@ fn process_json_line( // Check achievements after user message let newly_unlocked = { let mut stats_guard = stats.write(); - println!("User sent message, checking achievements..."); + tracing::info!("User sent message, checking achievements..."); // Check message-based achievements let mut unlocked = crate::achievements::check_message_achievements( @@ -1328,7 +1457,7 @@ fn process_json_line( // Emit achievement events for any newly unlocked achievements for achievement_id in &newly_unlocked { - println!("User message unlocked achievement: {:?}", achievement_id); + tracing::info!("User message unlocked achievement: {:?}", achievement_id); let info = get_achievement_info(achievement_id); let _ = app.emit( "achievement:unlocked", @@ -1338,7 +1467,7 @@ fn process_json_line( // Save achievements after unlocking new ones if !newly_unlocked.is_empty() { - println!("Saving newly unlocked achievements from user message"); + tracing::info!("Saving newly unlocked achievements from user message"); let app_handle = app.clone(); let achievements_progress = stats.read().achievements.clone(); tauri::async_runtime::spawn(async move { @@ -1346,9 +1475,9 @@ fn process_json_line( crate::achievements::save_achievements(&app_handle, &achievements_progress) .await { - eprintln!("Failed to save achievements: {}", e); + tracing::error!("Failed to save achievements: {}", e); } else { - println!("Achievements saved after user message"); + tracing::info!("Achievements saved after user message"); } }); } diff --git a/src-tauri/src/wsl_notifications.rs b/src-tauri/src/wsl_notifications.rs index 2d9752c..2b71121 100644 --- a/src-tauri/src/wsl_notifications.rs +++ b/src-tauri/src/wsl_notifications.rs @@ -48,15 +48,15 @@ $notifier.Show($toast) match output { Ok(result) => { if result.status.success() { - println!("WSL notification sent successfully"); + tracing::info!("WSL notification sent successfully"); return Ok(()); } else { let stderr = String::from_utf8_lossy(&result.stderr); - println!("PowerShell toast failed: {}", stderr); + tracing::error!("PowerShell toast failed: {}", stderr); } } Err(e) => { - println!("Failed to run PowerShell: {}", e); + tracing::error!("Failed to run PowerShell: {}", e); } } @@ -74,7 +74,7 @@ $notifier.Show($toast) if let Ok(result) = notify_result { if result.status.success() { - println!("Notification sent via wsl-notify-send"); + tracing::info!("Notification sent via wsl-notify-send"); return Ok(()); } } diff --git a/src/lib/components/CloseAppConfirmModal.svelte b/src/lib/components/CloseAppConfirmModal.svelte new file mode 100644 index 0000000..756f3b0 --- /dev/null +++ b/src/lib/components/CloseAppConfirmModal.svelte @@ -0,0 +1,116 @@ + + + + +{#if isOpen} +
e.key === " " && onCancel()} + > +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="dialog" + aria-labelledby="confirm-title" + aria-describedby="confirm-message" + tabindex="-1" + > +
+
+
+ + + +
+
+

+ Close Hikari Desktop? +

+

+ {#if hasActiveConversation} + You have an active conversation with Claude. Are you sure you want to close the + application? Your conversation history will be saved, but any in-progress tasks will + be interrupted. + {:else} + Are you sure you want to close the application? + {/if} +

+
+
+ +
+ + + +
+
+
+
+{/if} + + diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index cd0f20e..2f3c94b 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -26,7 +26,6 @@ notifications_enabled: true, notification_volume: 0.7, always_on_top: false, - minimize_to_tray: false, update_checks_enabled: true, character_panel_width: null, font_size: 14, @@ -728,21 +727,6 @@

- -
- -

- Hide to tray instead of closing when you click the X button -

-
-