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}
+
+
+
+
+
+
+ Cancel
+
+
+ Minimize to Tray
+
+
+ Close Application
+
+
+
+
+
+{/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 @@
-
-
-
-
- Minimize to system tray
-
-
- Hide to tray instead of closing when you click the X button
-
-
-
diff --git a/src/lib/components/DebugConsole.svelte b/src/lib/components/DebugConsole.svelte
new file mode 100644
index 0000000..1cc1ae4
--- /dev/null
+++ b/src/lib/components/DebugConsole.svelte
@@ -0,0 +1,330 @@
+
+
+{#if isOpen}
+
+
+
+
+ {#if logs.length === 0}
+
No logs yet...
+ {:else}
+ {#each logs as log (log.id)}
+
+ {formatTimestamp(log.timestamp)}
+
+ [{log.level.toUpperCase()}]
+
+
+ {log.source}
+
+ {log.message}
+
+ {/each}
+ {/if}
+
+
+
+{/if}
+
+
diff --git a/src/lib/components/PermissionModal.svelte b/src/lib/components/PermissionModal.svelte
index 4e0e06a..69e1f08 100644
--- a/src/lib/components/PermissionModal.svelte
+++ b/src/lib/components/PermissionModal.svelte
@@ -1,6 +1,7 @@