fix: critical permission modal and config issues (#127)
CI / Lint & Test (push) Has started running
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled

## 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 <commits@nhcarrigan.com>
Reviewed-on: #127
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #127.
This commit is contained in:
2026-02-07 01:55:49 -08:00
committed by Naomi Carrigan
parent 97a93c31c2
commit bf411adeb7
34 changed files with 2010 additions and 307 deletions
+35
View File
@@ -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 <hikari@nhcarrigan.com>"`
- **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 <hikari@nhcarrigan.com>" --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!
+7 -3
View File
@@ -1,5 +1,9 @@
#!/bin/bash #!/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 # Colors for output
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' 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 lint" "pnpm lint" || failed=1
run_check "Frontend format check" "pnpm format:check" || failed=1 run_check "Frontend format check" "pnpm format:check" || failed=1
run_check "Frontend type check" "pnpm 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 # Backend checks
run_check "Backend clippy (strict)" "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings" || failed=1 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 tests with coverage" "(cd src-tauri && cargo llvm-cov --fail-under-lines 50)" || failed=1
# Summary # Summary
echo -e "\n${YELLOW}========================================${NC}" echo -e "\n${YELLOW}========================================${NC}"
+74
View File
@@ -1658,6 +1658,8 @@ dependencies = [
"tauri-plugin-store", "tauri-plugin-store",
"tempfile", "tempfile",
"tokio", "tokio",
"tracing",
"tracing-subscriber",
"uuid 1.19.0", "uuid 1.19.0",
"windows 0.62.2", "windows 0.62.2",
] ]
@@ -2245,6 +2247,15 @@ dependencies = [
"syn 2.0.114", "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]] [[package]]
name = "matches" name = "matches"
version = "0.1.10" version = "0.1.10"
@@ -2401,6 +2412,15 @@ dependencies = [
"zbus", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@@ -3879,6 +3899,15 @@ dependencies = [
"digest", "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]] [[package]]
name = "shared_child" name = "shared_child"
version = "1.1.1" version = "1.1.1"
@@ -4695,6 +4724,15 @@ dependencies = [
"syn 2.0.114", "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]] [[package]]
name = "tiff" name = "tiff"
version = "0.10.3" version = "0.10.3"
@@ -4986,6 +5024,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [ dependencies = [
"once_cell", "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]] [[package]]
@@ -5167,6 +5235,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
+2
View File
@@ -33,6 +33,8 @@ semver = "1"
chrono = { version = "0.4.43", features = ["serde"] } chrono = { version = "0.4.43", features = ["serde"] }
discord-rich-presence = "0.2" discord-rich-presence = "0.2"
dirs = "5" dirs = "5"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = [ windows = { version = "0.62", features = [
+2 -1
View File
@@ -30,6 +30,7 @@
}, },
"core:window:allow-set-size", "core:window:allow-set-size",
"core:window:allow-set-always-on-top", "core:window:allow-set-always-on-top",
"core:window:allow-inner-size" "core:window:allow-inner-size",
"core:window:allow-hide"
] ]
} }
+12 -12
View File
@@ -1671,7 +1671,7 @@ pub fn check_message_achievements(
let mut newly_unlocked = Vec::new(); let mut newly_unlocked = Vec::new();
let message_lower = message.to_lowercase(); let message_lower = message.to_lowercase();
println!("Checking message achievements for: {}", message); tracing::info!("Checking message achievements for: {}", message);
// Relationship & Greetings // Relationship & Greetings
if message_lower.contains("good morning") && progress.unlock(AchievementId::GoodMorning) { if message_lower.contains("good morning") && progress.unlock(AchievementId::GoodMorning) {
@@ -1863,18 +1863,18 @@ pub fn check_achievements(
) -> Vec<AchievementId> { ) -> Vec<AchievementId> {
let mut newly_unlocked = Vec::new(); let mut newly_unlocked = Vec::new();
println!( tracing::info!(
"Checking achievements with stats: messages={}, tokens={}, code_blocks={}", "Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
stats.messages_exchanged, stats.messages_exchanged,
stats.total_input_tokens + stats.total_output_tokens, stats.total_input_tokens + stats.total_output_tokens,
stats.code_blocks_generated stats.code_blocks_generated
); );
println!("Currently unlocked: {:?}", progress.unlocked); tracing::info!("Currently unlocked: {:?}", progress.unlocked);
// Token milestones // Token milestones
let total_tokens = stats.total_input_tokens + stats.total_output_tokens; let total_tokens = stats.total_input_tokens + stats.total_output_tokens;
if total_tokens >= 1_000 && progress.unlock(AchievementId::FirstSteps) { if total_tokens >= 1_000 && progress.unlock(AchievementId::FirstSteps) {
println!("Unlocked FirstSteps achievement!"); tracing::info!("Unlocked FirstSteps achievement!");
newly_unlocked.push(AchievementId::FirstSteps); newly_unlocked.push(AchievementId::FirstSteps);
} }
if total_tokens >= 10_000 && progress.unlock(AchievementId::GrowingStrong) { 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 // Create a serializable version with just the unlocked achievement IDs
let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect(); let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect();
println!("Saving achievements: {:?}", unlocked_list); tracing::info!("Saving achievements: {:?}", unlocked_list);
store.set( store.set(
"unlocked", "unlocked",
@@ -2252,18 +2252,18 @@ pub async fn save_achievements(
); );
store.save().map_err(|e| e.to_string())?; store.save().map_err(|e| e.to_string())?;
println!("Achievements saved successfully"); tracing::info!("Achievements saved successfully");
Ok(()) Ok(())
} }
// Load achievements from persistent store // Load achievements from persistent store
pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress { 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") { let store = match app.store("achievements.json") {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
println!("Failed to open achievements store: {}", e); tracing::error!("Failed to open achievements store: {}", e);
return AchievementProgress::new(); return AchievementProgress::new();
} }
}; };
@@ -2272,19 +2272,19 @@ pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
// Get unlocked achievements // Get unlocked achievements
if let Some(unlocked_value) = store.get("unlocked") { 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) = if let Ok(unlocked_list) =
serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone()) serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone())
{ {
println!("Loaded {} achievements", unlocked_list.len()); tracing::info!("Loaded {} achievements", unlocked_list.len());
for achievement_id in unlocked_list { for achievement_id in unlocked_list {
progress.unlocked.insert(achievement_id); progress.unlocked.insert(achievement_id);
} }
} else { } else {
println!("Failed to parse unlocked achievements"); tracing::error!("Failed to parse unlocked achievements");
} }
} else { } else {
println!("No unlocked achievements found in store"); tracing::info!("No unlocked achievements found in store");
} }
progress progress
+13
View File
@@ -1154,6 +1154,19 @@ pub async fn log_discord_rpc(
Ok(()) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+1 -6
View File
@@ -28,6 +28,7 @@ pub struct ClaudeStartOptions {
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HikariConfig { pub struct HikariConfig {
#[serde(default)] #[serde(default)]
pub model: Option<String>, pub model: Option<String>,
@@ -71,9 +72,6 @@ pub struct HikariConfig {
#[serde(default = "default_font_size")] #[serde(default = "default_font_size")]
pub font_size: u32, pub font_size: u32,
#[serde(default)]
pub minimize_to_tray: bool,
#[serde(default)] #[serde(default)]
pub streamer_mode: bool, pub streamer_mode: bool,
@@ -134,7 +132,6 @@ impl Default for HikariConfig {
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: None, character_panel_width: None,
font_size: 14, font_size: 14,
minimize_to_tray: false,
streamer_mode: false, streamer_mode: false,
streamer_hide_paths: false, streamer_hide_paths: false,
compact_mode: false, compact_mode: false,
@@ -242,7 +239,6 @@ mod tests {
assert!(config.update_checks_enabled); assert!(config.update_checks_enabled);
assert!(config.character_panel_width.is_none()); assert!(config.character_panel_width.is_none());
assert_eq!(config.font_size, 14); assert_eq!(config.font_size, 14);
assert!(!config.minimize_to_tray);
assert!(!config.streamer_mode); assert!(!config.streamer_mode);
assert!(!config.streamer_hide_paths); assert!(!config.streamer_hide_paths);
assert!(!config.compact_mode); assert!(!config.compact_mode);
@@ -275,7 +271,6 @@ mod tests {
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: Some(400), character_panel_width: Some(400),
font_size: 16, font_size: 16,
minimize_to_tray: true,
streamer_mode: false, streamer_mode: false,
streamer_hide_paths: false, streamer_hide_paths: false,
compact_mode: false, compact_mode: false,
+78
View File
@@ -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<AppHandle>,
}
impl TauriLogLayer {
pub fn new(app: AppHandle) -> Self {
Self {
app: Arc::new(app),
}
}
}
impl<S> Layer<S> 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);
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ impl DiscordRpcManager {
if let Ok(app_data_dir) = app_handle.path().app_data_dir() { if let Ok(app_data_dir) = app_handle.path().app_data_dir() {
// Ensure the directory exists // Ensure the directory exists
if let Err(e) = std::fs::create_dir_all(&app_data_dir) { 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; return;
} }
let log_path = app_data_dir.join("hikari_discord_rpc.log"); let log_path = app_data_dir.join("hikari_discord_rpc.log");
+26 -10
View File
@@ -4,6 +4,7 @@ mod clipboard;
mod commands; mod commands;
mod config; mod config;
mod cost_tracking; mod cost_tracking;
mod debug_logger;
mod discord_rpc; mod discord_rpc;
mod git; mod git;
mod notifications; mod notifications;
@@ -24,6 +25,7 @@ use bridge_manager::create_shared_bridge_manager;
use clipboard::*; use clipboard::*;
use commands::load_saved_achievements; use commands::load_saved_achievements;
use commands::*; use commands::*;
use debug_logger::TauriLogLayer;
use discord_rpc::DiscordRpcManager; use discord_rpc::DiscordRpcManager;
use git::*; use git::*;
use notifications::*; use notifications::*;
@@ -31,9 +33,11 @@ use quick_actions::*;
use sessions::*; use sessions::*;
use snippets::*; use snippets::*;
use std::sync::Arc; use std::sync::Arc;
use tauri::Manager; use tauri::{Emitter, Manager};
use temp_manager::create_shared_temp_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 vbs_notification::*;
use windows_toast::*; use windows_toast::*;
use wsl_notifications::*; use wsl_notifications::*;
@@ -58,6 +62,14 @@ pub fn run() {
.manage(temp_manager.clone()) .manage(temp_manager.clone())
.manage(discord_rpc.clone()) .manage(discord_rpc.clone())
.setup(move |app| { .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 // Initialize the app handle in the bridge manager
bridge_manager.lock().set_app_handle(app.handle().clone()); 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 // Clean up any orphaned temp files from previous sessions
if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() { if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() {
if count > 0 { 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 // Set up system tray
if let Err(e) = setup_tray(app.handle()) { 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(); let main_window = app.get_webview_window("main").unwrap();
main_window.on_window_event({ main_window.on_window_event({
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
move |event| { move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event { if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if should_minimize_to_tray(&app_handle) { // Always prevent default close - let frontend handle it
api.prevent_close(); api.prevent_close();
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.hide(); // 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, update_discord_rpc,
stop_discord_rpc, stop_discord_rpc,
log_discord_rpc, log_discord_rpc,
close_application,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+8 -8
View File
@@ -618,7 +618,7 @@ pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<()
let persisted = PersistedStats::from(stats); let persisted = PersistedStats::from(stats);
println!("Saving stats: {:?}", persisted); tracing::info!("Saving stats: {:?}", persisted);
store.set( store.set(
"lifetime_stats", "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())?; store.save().map_err(|e| e.to_string())?;
println!("Stats saved successfully"); tracing::info!("Stats saved successfully");
Ok(()) Ok(())
} }
/// Load lifetime stats from persistent store /// Load lifetime stats from persistent store
pub async fn load_stats(app: &tauri::AppHandle) -> Option<PersistedStats> { pub async fn load_stats(app: &tauri::AppHandle) -> Option<PersistedStats> {
println!("Loading stats from store..."); tracing::info!("Loading stats from store...");
let store = match app.store("stats.json") { let store = match app.store("stats.json") {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
println!("Failed to open stats store: {}", e); tracing::error!("Failed to open stats store: {}", e);
return None; return None;
} }
}; };
if let Some(stats_value) = store.get("lifetime_stats") { 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::<PersistedStats>(stats_value.clone()) { if let Ok(persisted) = serde_json::from_value::<PersistedStats>(stats_value.clone()) {
println!("Loaded lifetime stats successfully"); tracing::info!("Loaded lifetime stats successfully");
return Some(persisted); return Some(persisted);
} else { } else {
println!("Failed to parse lifetime stats"); tracing::error!("Failed to parse lifetime stats");
} }
} else { } else {
println!("No lifetime stats found in store"); tracing::info!("No lifetime stats found in store");
} }
None None
+3 -3
View File
@@ -77,8 +77,8 @@ impl TempFileManager {
for file_path in files { for file_path in files {
if file_path.exists() { if file_path.exists() {
if let Err(e) = fs::remove_file(&file_path) { if let Err(e) = fs::remove_file(&file_path) {
eprintln!( tracing::warn!(
"Warning: Failed to remove temp file {:?}: {}", "Failed to remove temp file {:?}: {}",
file_path, e file_path, e
); );
} }
@@ -115,7 +115,7 @@ impl TempFileManager {
let path = entry.path(); let path = entry.path();
if path.is_file() && !tracked_files.contains(&path) { if path.is_file() && !tracked_files.contains(&path) {
if let Err(e) = fs::remove_file(&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 { } else {
cleaned_count += 1; cleaned_count += 1;
} }
-20
View File
@@ -4,8 +4,6 @@ use tauri::{
AppHandle, Manager, AppHandle, Manager,
}; };
use crate::config::HikariConfig;
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
let show_item = MenuItem::with_id(app, "show", "Show Hikari", true, None::<&str>)?; 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>)?; 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(()) 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::<HikariConfig>(&content) {
return config.minimize_to_tray;
}
}
}
false
}
+6 -1
View File
@@ -202,11 +202,16 @@ pub struct OutputEvent {
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionPromptEvent { pub struct PermissionPromptEventItem {
pub id: String, pub id: String,
pub tool_name: String, pub tool_name: String,
pub tool_input: serde_json::Value, pub tool_input: serde_json::Value,
pub description: String, pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionPromptEvent {
pub permissions: Vec<PermissionPromptEventItem>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>, pub conversation_id: Option<String>,
} }
+187 -58
View File
@@ -16,9 +16,24 @@ use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
use crate::types::{ use crate::types::{
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent, ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent,
QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent, WorkingDirectoryEvent, PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent,
UserQuestionEvent, WorkingDirectoryEvent,
}; };
use parking_lot::RwLock; 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<Vec<PendingToolUse>> = 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 SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"]; const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
@@ -118,21 +133,21 @@ impl WslBridge {
let app_clone = app.clone(); let app_clone = app.clone();
let stats = self.stats.clone(); let stats = self.stats.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
println!("Loading saved achievements..."); tracing::info!("Loading saved achievements...");
let achievements = crate::achievements::load_achievements(&app_clone).await; let achievements = crate::achievements::load_achievements(&app_clone).await;
println!( tracing::info!(
"Loaded {} unlocked achievements", "Loaded {} unlocked achievements",
achievements.unlocked.len() achievements.unlocked.len()
); );
println!("Loading saved stats..."); tracing::info!("Loading saved stats...");
let persisted_stats = crate::stats::load_stats(&app_clone).await; let persisted_stats = crate::stats::load_stats(&app_clone).await;
let mut stats_guard = stats.write(); let mut stats_guard = stats.write();
stats_guard.achievements = achievements; stats_guard.achievements = achievements;
if let Some(persisted) = persisted_stats { if let Some(persisted) = persisted_stats {
println!("Applying persisted lifetime stats"); tracing::info!("Applying persisted lifetime stats");
stats_guard.apply_persisted(persisted); stats_guard.apply_persisted(persisted);
} }
}); });
@@ -174,8 +189,8 @@ impl WslBridge {
// Detect if we're running inside WSL or on Windows // Detect if we're running inside WSL or on Windows
let is_wsl = detect_wsl(); let is_wsl = detect_wsl();
eprintln!("[DEBUG] is_wsl: {}", is_wsl); tracing::debug!("is_wsl: {}", is_wsl);
eprintln!("[DEBUG] options: {:?}", options); tracing::debug!("options: {:?}", options);
let mut command = if is_wsl { let mut command = if is_wsl {
// Running inside WSL - call claude directly // Running inside WSL - call claude directly
@@ -184,8 +199,8 @@ impl WslBridge {
"Could not find claude binary. Is Claude Code installed?".to_string() "Could not find claude binary. Is Claude Code installed?".to_string()
})?; })?;
eprintln!("[DEBUG] Found claude at: {}", claude_path); tracing::debug!("Found claude at: {}", claude_path);
eprintln!("[DEBUG] Working dir: {}", working_dir); tracing::debug!("Working dir: {}", working_dir);
let mut cmd = Command::new(&claude_path); let mut cmd = Command::new(&claude_path);
cmd.args([ cmd.args([
@@ -241,7 +256,7 @@ impl WslBridge {
cmd cmd
} else { } else {
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded // 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"); let mut cmd = Command::new("wsl");
// Build the claude command with all arguments // Build the claude command with all arguments
@@ -307,7 +322,7 @@ impl WslBridge {
.stderr(Stdio::piped()); .stderr(Stdio::piped());
let mut child = command.spawn().map_err(|e| { let mut child = command.spawn().map_err(|e| {
eprintln!("[DEBUG] Spawn error: {:?}", e); tracing::error!("Spawn error: {:?}", e);
format!("Failed to spawn process: {}", 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) (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) // Use conservative 3.5 chars/token for estimation (vs standard 4)
let estimated_input_tokens = (input_chars as f64 / 3.5).ceil() as u64; 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) let avg_tokens = (tool_stats.estimated_input_tokens + tool_stats.estimated_output_tokens)
/ tool_stats.call_count; / tool_stats.call_count;
tool_overhead_tokens += avg_tokens; 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_input = (total_estimated_input as f64 * safety_margin).ceil() as u64;
let conservative_output = (total_estimated_output 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); 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, output_chars + thinking_chars,
estimated_output_tokens, conservative_output); estimated_output_tokens, conservative_output);
@@ -532,7 +547,7 @@ impl WslBridge {
None, 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 // Add to stats with estimated flag
{ {
@@ -572,11 +587,11 @@ impl WslBridge {
let stats_snapshot = self.stats.read().clone(); let stats_snapshot = self.stats.read().clone();
let app_clone = app.clone(); let app_clone = app.clone();
tauri::async_runtime::spawn(async move { 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 { 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 { } 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 { match line {
Ok(line) if !line.is_empty() => { Ok(line) if !line.is_empty() => {
if let Err(e) = process_json_line(&line, &app, &stats, &conversation_id) { 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) => { Err(e) => {
eprintln!("Error reading stdout: {}", e); tracing::error!("Error reading stdout: {}", e);
break; break;
} }
_ => {} _ => {}
@@ -648,7 +663,7 @@ fn handle_stderr(
// Check if this is a SubagentStart hook message // Check if this is a SubagentStart hook message
if line.contains("[SubagentStart Hook]") { if line.contains("[SubagentStart Hook]") {
if let Some(agent_data) = parse_subagent_start_hook(&line) { 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); agent_data.agent_id, agent_data.parent_tool_use_id);
// Emit an agent-update event with the agent_id // Emit an agent-update event with the agent_id
@@ -770,6 +785,26 @@ fn process_json_line(
}) })
.collect(); .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<PendingToolUse> = 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 // Track message cost for display
let mut message_cost: Option<MessageCost> = None; let mut message_cost: Option<MessageCost> = None;
@@ -780,7 +815,7 @@ fn process_json_line(
let stats_guard = stats.read(); let stats_guard = stats.read();
stats_guard.model.clone() stats_guard.model.clone()
}).unwrap_or_else(|| { }).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() "claude-sonnet-4-5-20250929".to_string()
}); });
@@ -793,7 +828,7 @@ fn process_json_line(
usage.cache_read_input_tokens, 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.input_tokens,
usage.output_tokens, usage.output_tokens,
usage.cache_creation_input_tokens, usage.cache_creation_input_tokens,
@@ -882,8 +917,8 @@ fn process_json_line(
.unwrap_or_default() .unwrap_or_default()
.as_millis() as u64; .as_millis() as u64;
eprintln!( tracing::debug!(
"[DEBUG] Emitting agent-start: id={}, desc={}, type={}, parent={:?}", "Emitting agent-start: id={}, desc={}, type={}, parent={:?}",
id, description, subagent_type, parent_tool_use_id id, description, subagent_type, parent_tool_use_id
); );
@@ -1028,18 +1063,40 @@ fn process_json_line(
duration_ms, duration_ms,
num_turns, 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" { let state = if subtype == "success" {
CharacterState::Success CharacterState::Success
} else { } else {
CharacterState::Error 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::<Vec<_>>()
);
// Log turn metrics if available // Log turn metrics if available
if let Some(duration) = duration_ms { 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 { if let Some(turns) = num_turns {
println!("Turn count: {}", turns); tracing::info!("Turn count: {}", turns);
} }
// Track token usage from Result messages if available // 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_creation_input_tokens,
usage_info.cache_read_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.input_tokens,
usage_info.output_tokens, usage_info.output_tokens,
usage_info.cache_creation_input_tokens, usage_info.cache_creation_input_tokens,
@@ -1099,9 +1156,9 @@ fn process_json_line(
let newly_unlocked = { let newly_unlocked = {
let mut stats_guard = stats.write(); let mut stats_guard = stats.write();
stats_guard.get_session_duration(); stats_guard.get_session_duration();
println!("Checking achievements after result message..."); tracing::info!("Checking achievements after result message...");
let unlocked = stats_guard.check_achievements(); let unlocked = stats_guard.check_achievements();
println!("Newly unlocked achievements: {:?}", unlocked); tracing::info!("Newly unlocked achievements: {:?}", unlocked);
unlocked unlocked
}; };
@@ -1116,20 +1173,20 @@ fn process_json_line(
// Save achievements after unlocking new ones // Save achievements after unlocking new ones
if !newly_unlocked.is_empty() { 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 app_handle = app.clone();
let achievements_progress = stats.read().achievements.clone(); let achievements_progress = stats.read().achievements.clone();
// Use Tauri's async runtime instead of tokio::spawn // Use Tauri's async runtime instead of tokio::spawn
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
println!("Spawned save task for achievements"); tracing::info!("Spawned save task for achievements");
if let Err(e) = if let Err(e) =
crate::achievements::save_achievements(&app_handle, &achievements_progress) crate::achievements::save_achievements(&app_handle, &achievements_progress)
.await .await
{ {
eprintln!("Failed to save achievements: {}", e); tracing::error!("Failed to save achievements: {}", e);
} else { } 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(); let app_handle = app.clone();
tauri::async_runtime::spawn(async move { 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, &current_stats).await { if let Err(e) = crate::stats::save_stats(&app_handle, &current_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 // Check for permission denials and emit prompts for each
if let Some(denials) = permission_denials { 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<String> = 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 // Special handling for AskUserQuestion tool
if denial.tool_name == "AskUserQuestion" { if denial.tool_name == "AskUserQuestion" {
if let Some(questions) = denial if let Some(questions) = denial
@@ -1235,24 +1319,57 @@ fn process_json_line(
} }
} }
} else { } else {
has_regular_denials = true;
let description = let description =
format_tool_description(&denial.tool_name, &denial.tool_input); format_tool_description(&denial.tool_name, &denial.tool_input);
let _ = app.emit( regular_permission_requests.push(PermissionPromptEventItem {
"claude:permission", id: denial.tool_use_id.clone(),
PermissionPromptEvent { tool_name: denial.tool_name.clone(),
id: denial.tool_use_id.clone(), tool_input: denial.tool_input.clone(),
tool_name: denial.tool_name.clone(), description,
tool_input: denial.tool_input.clone(), });
description,
conversation_id: conversation_id.clone(),
},
);
} }
} }
// Show permission state if there were any denials (questions or regular) // Check for sibling tools that may have been cancelled
if has_regular_denials || !denials.is_empty() { // 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 &regular_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( emit_state_change(
app, app,
CharacterState::Permission, CharacterState::Permission,
@@ -1261,7 +1378,19 @@ fn process_json_line(
); );
return Ok(()); 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()); emit_state_change(app, state, None, conversation_id.clone());
} }
@@ -1311,7 +1440,7 @@ fn process_json_line(
// Check achievements after user message // Check achievements after user message
let newly_unlocked = { let newly_unlocked = {
let mut stats_guard = stats.write(); let mut stats_guard = stats.write();
println!("User sent message, checking achievements..."); tracing::info!("User sent message, checking achievements...");
// Check message-based achievements // Check message-based achievements
let mut unlocked = crate::achievements::check_message_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 // Emit achievement events for any newly unlocked achievements
for achievement_id in &newly_unlocked { 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 info = get_achievement_info(achievement_id);
let _ = app.emit( let _ = app.emit(
"achievement:unlocked", "achievement:unlocked",
@@ -1338,7 +1467,7 @@ fn process_json_line(
// Save achievements after unlocking new ones // Save achievements after unlocking new ones
if !newly_unlocked.is_empty() { 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 app_handle = app.clone();
let achievements_progress = stats.read().achievements.clone(); let achievements_progress = stats.read().achievements.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
@@ -1346,9 +1475,9 @@ fn process_json_line(
crate::achievements::save_achievements(&app_handle, &achievements_progress) crate::achievements::save_achievements(&app_handle, &achievements_progress)
.await .await
{ {
eprintln!("Failed to save achievements: {}", e); tracing::error!("Failed to save achievements: {}", e);
} else { } else {
println!("Achievements saved after user message"); tracing::info!("Achievements saved after user message");
} }
}); });
} }
+4 -4
View File
@@ -48,15 +48,15 @@ $notifier.Show($toast)
match output { match output {
Ok(result) => { Ok(result) => {
if result.status.success() { if result.status.success() {
println!("WSL notification sent successfully"); tracing::info!("WSL notification sent successfully");
return Ok(()); return Ok(());
} else { } else {
let stderr = String::from_utf8_lossy(&result.stderr); let stderr = String::from_utf8_lossy(&result.stderr);
println!("PowerShell toast failed: {}", stderr); tracing::error!("PowerShell toast failed: {}", stderr);
} }
} }
Err(e) => { 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 let Ok(result) = notify_result {
if result.status.success() { if result.status.success() {
println!("Notification sent via wsl-notify-send"); tracing::info!("Notification sent via wsl-notify-send");
return Ok(()); return Ok(());
} }
} }
@@ -0,0 +1,116 @@
<script lang="ts">
interface Props {
isOpen: boolean;
hasActiveConversation: boolean;
onClose: () => void;
onMinimize: () => void;
onCancel: () => void;
}
const { isOpen, hasActiveConversation, onClose, onMinimize, onCancel }: Props = $props();
function handleKeydown(event: KeyboardEvent) {
if (!isOpen) return;
if (event.key === "Escape") {
event.preventDefault();
onCancel();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onCancel}
role="button"
tabindex="0"
onkeydown={(e) => e.key === " " && onCancel()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-md w-full"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="confirm-title"
aria-describedby="confirm-message"
tabindex="-1"
>
<div class="p-6">
<div class="flex items-start gap-4">
<div
class="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center flex-shrink-0"
>
<svg
class="w-6 h-6 text-yellow-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div class="flex-1">
<h3 id="confirm-title" class="text-lg font-semibold text-[var(--text-primary)] mb-1">
Close Hikari Desktop?
</h3>
<p id="confirm-message" class="text-sm text-[var(--text-secondary)]">
{#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}
</p>
</div>
</div>
<div class="flex gap-3 mt-6 justify-end">
<button
onclick={onCancel}
class="px-4 py-2 text-sm font-medium text-gray-300 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Cancel
</button>
<button
onclick={onMinimize}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
Minimize to Tray
</button>
<button
onclick={onClose}
class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
>
Close Application
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
</style>
-16
View File
@@ -26,7 +26,6 @@
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false, always_on_top: false,
minimize_to_tray: false,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null, character_panel_width: null,
font_size: 14, font_size: 14,
@@ -728,21 +727,6 @@
</p> </p>
</div> </div>
<!-- Minimize to Tray Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.minimize_to_tray}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Minimize to system tray</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Hide to tray instead of closing when you click the X button
</p>
</div>
<!-- Update Checks Toggle --> <!-- Update Checks Toggle -->
<div class="mb-4"> <div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer"> <label class="flex items-center gap-3 cursor-pointer">
+330
View File
@@ -0,0 +1,330 @@
<script lang="ts">
import { onMount } from "svelte";
import { debugConsoleStore, filteredLogs, type LogLevel } from "$lib/stores/debugConsole";
let isOpen = $state(false);
let logs = $state($filteredLogs);
let filterLevel = $state<LogLevel | "all">("all");
let autoScroll = $state(true);
let logContainerElement: HTMLDivElement | undefined = $state();
// Watch for log changes and auto-scroll
$effect(() => {
logs = $filteredLogs;
// Auto-scroll to bottom when logs change
if (autoScroll && logContainerElement) {
setTimeout(() => {
if (logContainerElement) {
logContainerElement.scrollTop = logContainerElement.scrollHeight;
}
}, 0);
}
});
onMount(() => {
// Set up console capture and backend listener
debugConsoleStore.setupConsoleCapture();
debugConsoleStore.setupBackendLogsListener();
// Subscribe to store
const unsubscribe = debugConsoleStore.subscribe((state) => {
isOpen = state.isOpen;
filterLevel = state.filterLevel;
autoScroll = state.autoScroll;
});
return () => {
unsubscribe();
debugConsoleStore.restoreConsole();
};
});
function handleClose() {
debugConsoleStore.close();
}
function handleClear() {
debugConsoleStore.clear();
}
function handleFilterChange(level: LogLevel | "all") {
debugConsoleStore.setFilterLevel(level);
}
function handleAutoScrollToggle() {
debugConsoleStore.setAutoScroll(!autoScroll);
}
function formatTimestamp(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
});
}
function getLevelColor(level: LogLevel): string {
switch (level) {
case "debug":
return "#9CA3AF"; // gray
case "info":
return "#3B82F6"; // blue
case "warn":
return "#F59E0B"; // amber
case "error":
return "#EF4444"; // red
}
}
function getSourceBadgeColor(source: "frontend" | "backend"): string {
return source === "frontend" ? "#8B5CF6" : "#10B981"; // purple for frontend, green for backend
}
</script>
{#if isOpen}
<div class="debug-console-overlay">
<div class="debug-console">
<div class="debug-console-header">
<h2>Debug Console</h2>
<div class="debug-console-controls">
<div class="filter-buttons">
<button
class="filter-btn"
class:active={filterLevel === "all"}
onclick={() => handleFilterChange("all")}
>
All
</button>
<button
class="filter-btn"
class:active={filterLevel === "debug"}
onclick={() => handleFilterChange("debug")}
style="color: {getLevelColor('debug')}"
>
Debug
</button>
<button
class="filter-btn"
class:active={filterLevel === "info"}
onclick={() => handleFilterChange("info")}
style="color: {getLevelColor('info')}"
>
Info
</button>
<button
class="filter-btn"
class:active={filterLevel === "warn"}
onclick={() => handleFilterChange("warn")}
style="color: {getLevelColor('warn')}"
>
Warn
</button>
<button
class="filter-btn"
class:active={filterLevel === "error"}
onclick={() => handleFilterChange("error")}
style="color: {getLevelColor('error')}"
>
Error
</button>
</div>
<button
class="auto-scroll-btn"
class:active={autoScroll}
onclick={handleAutoScrollToggle}
>
{autoScroll ? "🔒" : "🔓"} Auto-scroll
</button>
<button class="clear-btn" onclick={handleClear}> 🗑️ Clear </button>
<button class="close-btn" onclick={handleClose}> </button>
</div>
</div>
<div class="debug-console-content" bind:this={logContainerElement}>
{#if logs.length === 0}
<div class="empty-state">No logs yet...</div>
{:else}
{#each logs as log (log.id)}
<div class="log-entry" data-level={log.level}>
<span class="log-timestamp">{formatTimestamp(log.timestamp)}</span>
<span class="log-level" style="color: {getLevelColor(log.level)}">
[{log.level.toUpperCase()}]
</span>
<span class="log-source" style="background-color: {getSourceBadgeColor(log.source)}">
{log.source}
</span>
<span class="log-message">{log.message}</span>
</div>
{/each}
{/if}
</div>
</div>
</div>
{/if}
<style>
.debug-console-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.debug-console {
width: 90%;
height: 80%;
max-width: 1400px;
background-color: #1a1a1a;
border-radius: 8px;
border: 1px solid #333;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.debug-console-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background-color: #252525;
border-bottom: 1px solid #333;
}
.debug-console-header h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #fff;
}
.debug-console-controls {
display: flex;
gap: 8px;
align-items: center;
}
.filter-buttons {
display: flex;
gap: 4px;
}
.filter-btn {
padding: 4px 12px;
background-color: transparent;
border: 1px solid #444;
border-radius: 4px;
color: #999;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.filter-btn:hover {
background-color: #333;
}
.filter-btn.active {
background-color: #444;
border-color: currentColor;
}
.auto-scroll-btn,
.clear-btn {
padding: 4px 12px;
background-color: #333;
border: 1px solid #444;
border-radius: 4px;
color: #fff;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.auto-scroll-btn:hover,
.clear-btn:hover {
background-color: #444;
}
.auto-scroll-btn.active {
background-color: #10b981;
border-color: #10b981;
}
.close-btn {
padding: 4px 12px;
background-color: #ef4444;
border: none;
border-radius: 4px;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.close-btn:hover {
background-color: #dc2626;
}
.debug-console-content {
flex: 1;
overflow-y: auto;
padding: 16px;
background-color: #0f0f0f;
font-family: "Fira Code", "Consolas", monospace;
font-size: 13px;
line-height: 1.5;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
font-style: italic;
}
.log-entry {
display: flex;
gap: 8px;
padding: 4px 0;
border-bottom: 1px solid #1a1a1a;
}
.log-entry:hover {
background-color: #1a1a1a;
}
.log-timestamp {
color: #666;
flex-shrink: 0;
}
.log-level {
font-weight: 600;
flex-shrink: 0;
min-width: 60px;
}
.log-source {
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
color: #fff;
flex-shrink: 0;
}
.log-message {
color: #e5e5e5;
word-break: break-word;
}
</style>
+198 -105
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { SvelteSet } from "svelte/reactivity";
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character"; import { characterState } from "$lib/stores/character";
import type { PermissionRequest } from "$lib/types/messages"; import type { PermissionRequest } from "$lib/types/messages";
@@ -8,13 +9,16 @@
import { conversationsStore } from "$lib/stores/conversations"; import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config"; import { configStore } from "$lib/stores/config";
let permission: PermissionRequest | null = $state(null); let permissions: PermissionRequest[] = $state([]);
let selectedPermissions = new SvelteSet<string>();
let grantedToolsList: string[] = $state([]); let grantedToolsList: string[] = $state([]);
let workingDirectory = $state(""); let workingDirectory = $state("");
claudeStore.pendingPermission.subscribe((perm) => { conversationsStore.pendingPermissions.subscribe((perms) => {
permission = perm; permissions = perms;
if (perm) { // When new permissions arrive, select all by default
if (perms.length > 0) {
selectedPermissions = new SvelteSet(perms.map((p) => p.id));
characterState.setState("permission"); characterState.setState("permission");
} }
}); });
@@ -28,84 +32,103 @@
}); });
async function handleApproveAndReconnect() { async function handleApproveAndReconnect() {
if (permission) { const selectedPerms = permissions.filter((p) => selectedPermissions.has(p.id));
// Capture conversation history before clearing/reconnecting
const conversationHistory = claudeStore.getConversationHistory();
const approvedTool = permission.tool;
const toolInput = permission.input;
claudeStore.grantTool(approvedTool); if (selectedPerms.length === 0) {
const newGrantedTools = [...grantedToolsList, approvedTool]; claudeStore.addLine("system", "No permissions selected to approve");
claudeStore.addLine(
"system",
`Permission granted for: ${approvedTool}. Reconnecting with context...`
);
claudeStore.clearPermission(); claudeStore.clearPermission();
characterState.setTemporaryState("idle", 1000);
return;
}
// Stop current session and reconnect with new permissions // Capture conversation history before clearing/reconnecting
try { const conversationHistory = claudeStore.getConversationHistory();
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
// Prevent stats reset on reconnection // Grant all selected tools
setSkipNextGreeting(true); const newlyGrantedTools: string[] = [];
for (const perm of selectedPerms) {
if (!grantedToolsList.includes(perm.tool)) {
claudeStore.grantTool(perm.tool);
newlyGrantedTools.push(perm.tool);
}
}
await invoke("stop_claude", { conversationId }); const newGrantedTools = [...grantedToolsList, ...newlyGrantedTools];
const toolNames = selectedPerms.map((p) => p.tool).join(", ");
// Small delay to ensure clean shutdown claudeStore.addLine(
await new Promise((resolve) => setTimeout(resolve, 500)); "system",
`Permission granted for ${selectedPerms.length} tool(s): ${toolNames}. Reconnecting with context...`
);
claudeStore.clearPermission();
const config = configStore.getConfig(); // Stop current session and reconnect with new permissions
await invoke("start_claude", { try {
conversationId, const conversationId = get(claudeStore.activeConversationId);
options: { if (!conversationId) {
working_dir: workingDirectory || "/home/naomi", throw new Error("No active conversation");
model: config.model || null, }
api_key: config.api_key || null,
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: newGrantedTools,
},
});
// Update Discord RPC when reconnecting after permission grant // Prevent stats reset on reconnection
const activeConversation = get(conversationsStore.activeConversation); setSkipNextGreeting(true);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
// Wait for connection to establish await invoke("stop_claude", { conversationId });
await new Promise((resolve) => setTimeout(resolve, 1000));
// Send conversation context to restore state // Small delay to ensure clean shutdown
if (conversationHistory) { await new Promise((resolve) => setTimeout(resolve, 500));
const contextMessage = `[CONTEXT RESTORATION]
I just granted you permission to use the ${approvedTool} tool. Here's our conversation so far: const config = configStore.getConfig();
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDirectory || "/home/naomi",
model: config.model || null,
api_key: config.api_key || null,
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: newGrantedTools,
},
});
// Update Discord RPC when reconnecting after permission grant
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
// Wait for connection to establish
await new Promise((resolve) => setTimeout(resolve, 1000));
// Send conversation context to restore state
if (conversationHistory) {
const blockedActions = selectedPerms
.map((p) => `- ${p.tool} with input:\n${JSON.stringify(p.input, null, 2)}`)
.join("\n\n");
const contextMessage = `[CONTEXT RESTORATION]
I just granted you permission to use ${selectedPerms.length} tool(s): ${toolNames}. Here's our conversation so far:
${conversationHistory} ${conversationHistory}
The last action that was blocked was: ${approvedTool} with input: The actions that were blocked:
${JSON.stringify(toolInput, null, 2)} ${blockedActions}
Please continue where we left off and retry that action now that you have permission.`; Please continue where we left off and retry those actions now that you have permission.`;
await invoke("send_prompt", { await invoke("send_prompt", {
conversationId, conversationId,
message: contextMessage, message: contextMessage,
}); });
}
} catch (error) {
console.error("Failed to reconnect:", error);
claudeStore.addLine("error", `Reconnect failed: ${error}`);
} }
characterState.setTemporaryState("success", 2000);
} catch (error) {
console.error("Failed to reconnect:", error);
claudeStore.addLine("error", `Reconnect failed: ${error}`);
} }
characterState.setTemporaryState("success", 2000);
} }
function handleDismiss() { function handleDismiss() {
@@ -126,8 +149,24 @@ Please continue where we left off and retry that action now that you have permis
return grantedToolsList.includes(toolName); return grantedToolsList.includes(toolName);
} }
function togglePermission(toolRequestId: string) {
if (selectedPermissions.has(toolRequestId)) {
selectedPermissions.delete(toolRequestId);
} else {
selectedPermissions.add(toolRequestId);
}
}
function selectAll() {
selectedPermissions = new SvelteSet(permissions.map((p) => p.id));
}
function selectNone() {
selectedPermissions = new SvelteSet();
}
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
if (!permission) return; if (permissions.length === 0) return;
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
@@ -141,72 +180,126 @@ Please continue where we left off and retry that action now that you have permis
<svelte:window onkeydown={handleKeydown} /> <svelte:window onkeydown={handleKeydown} />
{#if permission} {#if permissions.length > 0}
<div <div
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm" class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-[60] backdrop-blur-sm"
> >
<div <div
class="permission-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl" class="permission-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-2xl w-full mx-4 shadow-2xl max-h-[90vh] overflow-y-auto"
> >
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center"> <div class="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center">
<span class="text-xl">🔐</span> <span class="text-xl">🔐</span>
</div> </div>
<div> <div class="flex-1">
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Permission Blocked</h2> <h2 class="text-lg font-semibold text-[var(--text-primary)]">
<p class="text-sm text-[var(--text-secondary)]">Hikari tried to use a restricted tool</p> {permissions.length === 1
? "Permission Required"
: `${permissions.length} Permissions Required`}
</h2>
<p class="text-sm text-[var(--text-secondary)]">
Hikari tried to use {permissions.length === 1
? "a restricted tool"
: "restricted tools"}
</p>
</div>
<div class="flex gap-2 text-xs">
<button
onclick={selectAll}
class="px-2 py-1 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded transition-colors"
>
Select All
</button>
<button
onclick={selectNone}
class="px-2 py-1 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded transition-colors"
>
Select None
</button>
</div> </div>
</div> </div>
<div class="mb-4 px-3 py-2 bg-amber-500/10 border border-amber-500/30 rounded-md"> <div class="mb-4 px-3 py-2 bg-amber-500/10 border border-amber-500/30 rounded-md">
<p class="text-sm text-amber-300"> <p class="text-sm text-amber-300">
This action was automatically blocked. Approve to allow this tool for future requests. {permissions.length === 1
? "This action was automatically blocked. Select which permissions to grant."
: "These actions were automatically blocked. Select which permissions to grant."}
</p> </p>
</div> </div>
<div class="mb-4"> <div class="space-y-3 mb-6">
<div class="text-sm text-[var(--text-secondary)] mb-1">Tool</div> {#each permissions as perm (perm.id)}
<div <div
class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--accent-primary)] font-mono flex items-center justify-between" class="border border-[var(--border-color)] rounded-lg p-4 cursor-pointer transition-colors {selectedPermissions.has(
> perm.id
<span>{permission.tool}</span> )
{#if isToolAlreadyGranted(permission.tool)} ? 'bg-green-500/10 border-green-500/30'
<span class="text-xs text-green-400 bg-green-500/20 px-2 py-0.5 rounded" : 'bg-[var(--bg-secondary)] hover:bg-[var(--bg-secondary)]/80'}"
>Already Granted</span role="button"
> tabindex="0"
{/if} onclick={() => togglePermission(perm.id)}
</div> onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
togglePermission(perm.id);
}
}}
>
<div class="flex items-start gap-3">
<div class="mt-1">
<input
type="checkbox"
checked={selectedPermissions.has(perm.id)}
onchange={() => togglePermission(perm.id)}
class="w-4 h-4 accent-green-500"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
<span class="text-[var(--accent-primary)] font-mono text-sm font-medium">
{perm.tool}
</span>
{#if isToolAlreadyGranted(perm.tool)}
<span class="text-xs text-green-400 bg-green-500/20 px-2 py-0.5 rounded">
Already Granted
</span>
{/if}
</div>
<div class="text-sm text-[var(--text-secondary)] mb-2">
{perm.description}
</div>
{#if Object.keys(perm.input).length > 0}
<details class="text-xs">
<summary
class="cursor-pointer text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
View details
</summary>
<pre
class="mt-2 px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] overflow-x-auto max-h-32">{formatInput(
perm.input
)}</pre>
</details>
{/if}
</div>
</div>
</div>
{/each}
</div> </div>
<div class="mb-4">
<div class="text-sm text-[var(--text-secondary)] mb-1">Description</div>
<div class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--text-primary)]">
{permission.description}
</div>
</div>
{#if Object.keys(permission.input).length > 0}
<div class="mb-6">
<div class="text-sm text-[var(--text-secondary)] mb-1">Details</div>
<pre
class="px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] text-xs overflow-x-auto max-h-32">{formatInput(
permission.input
)}</pre>
</div>
{/if}
<div class="flex gap-3"> <div class="flex gap-3">
<button <button
onclick={handleDismiss} onclick={handleDismiss}
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium" class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
> >
Dismiss Dismiss All
</button> </button>
<button <button
onclick={handleApproveAndReconnect} onclick={handleApproveAndReconnect}
class="flex-1 px-4 py-2 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded-lg transition-colors font-medium" disabled={selectedPermissions.size === 0}
class="flex-1 px-4 py-2 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
> >
Allow & Reconnect Approve Selected ({selectedPermissions.size})
</button> </button>
</div> </div>
</div> </div>
+15 -1
View File
@@ -33,6 +33,7 @@
sanitizeForJson, sanitizeForJson,
} from "$lib/utils/conversationUtils"; } from "$lib/utils/conversationUtils";
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri"; import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
import { debugConsoleStore } from "$lib/stores/debugConsole";
const DISCORD_URL = "https://chat.nhcarrigan.com"; const DISCORD_URL = "https://chat.nhcarrigan.com";
const DONATE_URL = "https://donate.nhcarrigan.com"; const DONATE_URL = "https://donate.nhcarrigan.com";
@@ -69,7 +70,6 @@
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null, character_panel_width: null,
font_size: 14, font_size: 14,
minimize_to_tray: false,
streamer_mode: false, streamer_mode: false,
streamer_hide_paths: false, streamer_hide_paths: false,
compact_mode: false, compact_mode: false,
@@ -507,6 +507,20 @@
/> />
</svg> </svg>
</button> </button>
<button
onclick={() => debugConsoleStore.toggle()}
class="p-1 text-gray-500 icon-trans-hover"
title="Debug Console (Ctrl+`)"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</button>
<button <button
onclick={configStore.openSidebar} onclick={configStore.openSidebar}
class="p-1 text-gray-500 icon-trans-hover" class="p-1 text-gray-500 icon-trans-hover"
+4 -1
View File
@@ -101,7 +101,10 @@ export const claudeStore = {
export const hasPermissionPending = derived( export const hasPermissionPending = derived(
claudeStore.activeConversation, claudeStore.activeConversation,
($conversation) => $conversation?.pendingPermission !== null ($conversation) =>
$conversation?.pendingPermissions !== null &&
$conversation?.pendingPermissions !== undefined &&
$conversation.pendingPermissions.length > 0
); );
export const hasQuestionPending = derived( export const hasQuestionPending = derived(
+326 -2
View File
@@ -14,6 +14,7 @@ import {
type Theme, type Theme,
type CustomThemeColors, type CustomThemeColors,
} from "./config"; } from "./config";
import { invoke } from "@tauri-apps/api/core";
// Mock Tauri APIs // Mock Tauri APIs
vi.mock("@tauri-apps/api/core", () => ({ vi.mock("@tauri-apps/api/core", () => ({
@@ -167,7 +168,6 @@ describe("config store", () => {
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false, always_on_top: false,
minimize_to_tray: true,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: 300, character_panel_width: 300,
font_size: 14, font_size: 14,
@@ -213,7 +213,6 @@ describe("config store", () => {
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false, always_on_top: false,
minimize_to_tray: false,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null, character_panel_width: null,
font_size: 14, font_size: 14,
@@ -489,4 +488,329 @@ describe("config store", () => {
expect(typeof configStore.saveError.subscribe).toBe("function"); expect(typeof configStore.saveError.subscribe).toBe("function");
}); });
}); });
describe("Race Condition Tests", () => {
beforeEach(async () => {
// Setup mock to return a default config for load_config
const mockInvokeImpl = vi.mocked(invoke);
mockInvokeImpl.mockResolvedValue({
model: null,
api_key: null,
custom_instructions: null,
mcp_servers_json: null,
auto_granted_tools: [],
theme: "dark",
greeting_enabled: false,
greeting_custom_prompt: null,
notifications_enabled: false,
notification_volume: 0.7,
always_on_top: false,
update_checks_enabled: false,
character_panel_width: null,
font_size: 14,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
profile_name: null,
profile_avatar_path: null,
profile_bio: null,
custom_theme_colors: {
bg_primary: null,
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
},
budget_enabled: false,
session_token_budget: null,
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
discord_rpc_enabled: false,
});
// Load initial config
await configStore.loadConfig();
vi.clearAllMocks();
});
it("handles rapid sequential config updates correctly", async () => {
// This test validates the fix for the config race condition that caused data loss
const mockInvokeImpl = vi.mocked(invoke);
const invokeCalls: Array<{ command: string; config: HikariConfig }> = [];
mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => {
if (command === "save_config" && args && typeof args === "object" && "config" in args) {
invokeCalls.push({ command, config: args.config as HikariConfig });
// Simulate small delay in saving
await new Promise((resolve) => setTimeout(resolve, 10));
}
return null;
});
// Perform rapid updates
await Promise.all([
configStore.updateConfig({ font_size: 16 }),
configStore.updateConfig({ theme: "light" }),
configStore.updateConfig({ compact_mode: true }),
]);
// All three updates should have been saved
expect(invokeCalls.length).toBe(3);
// Get final config
const finalConfig = configStore.getConfig();
// Final config should have all updates
// Note: The last update wins for each field, but all fields should be preserved
expect(finalConfig.compact_mode).toBe(true);
});
it("preserves previous field values during concurrent updates", async () => {
// Set initial values
await configStore.updateConfig({
font_size: 16,
theme: "dark",
compact_mode: false,
streamer_mode: false,
});
vi.clearAllMocks();
const mockInvokeImpl = vi.mocked(invoke);
const invokeCalls: Array<{ command: string; config: HikariConfig }> = [];
mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => {
if (command === "save_config" && args && typeof args === "object" && "config" in args) {
invokeCalls.push({ command, config: args.config as HikariConfig });
await new Promise((resolve) => setTimeout(resolve, 5));
}
return null;
});
// Update different fields concurrently
await Promise.all([
configStore.updateConfig({ font_size: 18 }),
configStore.updateConfig({ theme: "light" }),
configStore.updateConfig({ compact_mode: true }),
]);
// Check that each save included all previous config values
invokeCalls.forEach((call) => {
// Each save should have a complete config, not just the updated field
expect(call.config).toHaveProperty("font_size");
expect(call.config).toHaveProperty("theme");
expect(call.config).toHaveProperty("compact_mode");
expect(call.config).toHaveProperty("streamer_mode");
expect(call.config).toHaveProperty("model");
expect(call.config).toHaveProperty("api_key");
});
});
it("handles update during save operation", async () => {
const mockInvokeImpl = vi.mocked(invoke);
let firstSaveStarted = false;
let firstSaveCompleted = false;
mockInvokeImpl.mockImplementation(async (command: string) => {
if (command === "save_config") {
if (!firstSaveStarted) {
firstSaveStarted = true;
// Simulate slow save
await new Promise((resolve) => setTimeout(resolve, 50));
firstSaveCompleted = true;
} else {
// Second save starts while first is in progress
expect(firstSaveStarted).toBe(true);
// First save might not be complete yet (race condition scenario)
}
}
return null;
});
// Start first update
const firstUpdate = configStore.updateConfig({ font_size: 16 });
// Wait a bit then start second update whilst first is still saving
await new Promise((resolve) => setTimeout(resolve, 10));
const secondUpdate = configStore.updateConfig({ theme: "light" });
// Wait for both to complete
await Promise.all([firstUpdate, secondUpdate]);
// Both should complete successfully without errors
expect(firstSaveCompleted).toBe(true);
});
it("getConfig returns most recently set configuration", async () => {
await configStore.updateConfig({ font_size: 14 });
expect(configStore.getConfig().font_size).toBe(14);
await configStore.updateConfig({ font_size: 16 });
expect(configStore.getConfig().font_size).toBe(16);
await configStore.updateConfig({ font_size: 18 });
expect(configStore.getConfig().font_size).toBe(18);
});
it("updates do not lose data from previous operations", async () => {
// Set multiple fields
await configStore.updateConfig({
font_size: 16,
theme: "dark",
compact_mode: true,
streamer_mode: true,
model: "claude-sonnet-4",
});
// Update just one field
await configStore.updateConfig({ theme: "light" });
// Other fields should be preserved
const config = configStore.getConfig();
expect(config.theme).toBe("light");
expect(config.font_size).toBe(16);
expect(config.compact_mode).toBe(true);
expect(config.streamer_mode).toBe(true);
expect(config.model).toBe("claude-sonnet-4");
});
it("auto granted tools are not lost during other updates", async () => {
// Add some tools
await configStore.addAutoGrantedTool("Read");
await configStore.addAutoGrantedTool("Write");
expect(configStore.getConfig().auto_granted_tools).toContain("Read");
expect(configStore.getConfig().auto_granted_tools).toContain("Write");
// Update another field
await configStore.updateConfig({ theme: "light" });
// Tools should still be there
expect(configStore.getConfig().auto_granted_tools).toContain("Read");
expect(configStore.getConfig().auto_granted_tools).toContain("Write");
});
it("custom theme colors persist across other config updates", async () => {
const customColors: CustomThemeColors = {
bg_primary: "#1a1a2e",
bg_secondary: "#16213e",
bg_terminal: "#0f0f23",
accent_primary: "#e94560",
accent_secondary: "#533483",
text_primary: "#eaeaea",
text_secondary: "#a0a0a0",
border_color: "#333355",
};
await configStore.setCustomThemeColors(customColors);
// Update another field
await configStore.updateConfig({ font_size: 18 });
// Colors should still be there
const config = configStore.getConfig();
expect(config.custom_theme_colors.bg_primary).toBe("#1a1a2e");
expect(config.custom_theme_colors.accent_primary).toBe("#e94560");
});
it("handles save errors gracefully without losing data", async () => {
const mockInvokeImpl = vi.mocked(invoke);
// Set initial config
await configStore.updateConfig({ font_size: 14 });
// Make next save fail
mockInvokeImpl.mockRejectedValueOnce(new Error("Save failed"));
// Try to update - should throw
await expect(configStore.updateConfig({ theme: "light" })).rejects.toThrow();
// Original config should still be accessible
expect(configStore.getConfig().font_size).toBe(14);
});
});
describe("Config Persistence Tests", () => {
it("loadConfig retrieves saved configuration", async () => {
const mockConfig: HikariConfig = {
model: "claude-sonnet-4",
api_key: "test-key",
custom_instructions: "Be helpful",
mcp_servers_json: "{}",
auto_granted_tools: ["Read", "Write"],
theme: "light",
greeting_enabled: false,
greeting_custom_prompt: null,
notifications_enabled: false,
notification_volume: 0.5,
always_on_top: true,
update_checks_enabled: false,
character_panel_width: 400,
font_size: 18,
streamer_mode: true,
streamer_hide_paths: true,
compact_mode: true,
profile_name: "Test User",
profile_avatar_path: "/test/avatar.png",
profile_bio: "Test bio",
custom_theme_colors: {
bg_primary: null,
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
},
budget_enabled: true,
session_token_budget: 100000,
session_cost_budget: 1.5,
budget_action: "block",
budget_warning_threshold: 0.9,
discord_rpc_enabled: false,
};
const mockInvokeImpl = vi.mocked(invoke);
mockInvokeImpl.mockResolvedValueOnce(mockConfig);
await configStore.loadConfig();
const loadedConfig = configStore.getConfig();
expect(loadedConfig.model).toBe("claude-sonnet-4");
expect(loadedConfig.theme).toBe("light");
expect(loadedConfig.font_size).toBe(18);
expect(loadedConfig.auto_granted_tools).toEqual(["Read", "Write"]);
});
it("saveConfig persists configuration to backend", async () => {
const mockInvokeImpl = vi.mocked(invoke);
const savedConfigs: HikariConfig[] = [];
mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => {
if (command === "save_config" && args && typeof args === "object" && "config" in args) {
savedConfigs.push(args.config as HikariConfig);
}
return null;
});
const configToSave: Partial<HikariConfig> = {
model: "claude-sonnet-4",
theme: "dark",
font_size: 16,
};
await configStore.updateConfig(configToSave);
expect(savedConfigs.length).toBeGreaterThan(0);
const lastSaved = savedConfigs[savedConfigs.length - 1];
expect(lastSaved.model).toBe("claude-sonnet-4");
expect(lastSaved.theme).toBe("dark");
expect(lastSaved.font_size).toBe(16);
});
});
}); });
+18 -23
View File
@@ -27,7 +27,6 @@ export interface HikariConfig {
notifications_enabled: boolean; notifications_enabled: boolean;
notification_volume: number; notification_volume: number;
always_on_top: boolean; always_on_top: boolean;
minimize_to_tray: boolean;
update_checks_enabled: boolean; update_checks_enabled: boolean;
character_panel_width: number | null; character_panel_width: number | null;
font_size: number; font_size: number;
@@ -60,7 +59,6 @@ const defaultConfig: HikariConfig = {
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false, always_on_top: false,
minimize_to_tray: false,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null, character_panel_width: null,
font_size: 14, font_size: 14,
@@ -94,6 +92,14 @@ function createConfigStore() {
const isSidebarOpen = writable<boolean>(false); const isSidebarOpen = writable<boolean>(false);
const saveError = writable<string | null>(null); const saveError = writable<string | null>(null);
// Internal function to get current config synchronously
function getCurrentConfig(): HikariConfig {
let currentConfig: HikariConfig = defaultConfig;
const unsubscribe = config.subscribe((c) => (currentConfig = c));
unsubscribe();
return currentConfig;
}
async function loadConfig() { async function loadConfig() {
isLoading.set(true); isLoading.set(true);
try { try {
@@ -121,8 +127,7 @@ function createConfigStore() {
} }
async function updateConfig(updates: Partial<HikariConfig>) { async function updateConfig(updates: Partial<HikariConfig>) {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
const newConfig = { ...currentConfig, ...updates }; const newConfig = { ...currentConfig, ...updates };
await saveConfig(newConfig); await saveConfig(newConfig);
} }
@@ -147,15 +152,13 @@ function createConfigStore() {
updates.custom_theme_colors = customColors; updates.custom_theme_colors = customColors;
} }
await updateConfig(updates); await updateConfig(updates);
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
applyTheme(theme, currentConfig.custom_theme_colors); applyTheme(theme, currentConfig.custom_theme_colors);
}, },
setCustomThemeColors: async (colors: CustomThemeColors) => { setCustomThemeColors: async (colors: CustomThemeColors) => {
await updateConfig({ custom_theme_colors: colors }); await updateConfig({ custom_theme_colors: colors });
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
if (currentConfig.theme === "custom") { if (currentConfig.theme === "custom") {
applyCustomThemeColors(colors); applyCustomThemeColors(colors);
} }
@@ -168,16 +171,14 @@ function createConfigStore() {
}, },
increaseFontSize: async () => { increaseFontSize: async () => {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2); const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2);
await updateConfig({ font_size: newSize }); await updateConfig({ font_size: newSize });
applyFontSize(newSize); applyFontSize(newSize);
}, },
decreaseFontSize: async () => { decreaseFontSize: async () => {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2); const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2);
await updateConfig({ font_size: newSize }); await updateConfig({ font_size: newSize });
applyFontSize(newSize); applyFontSize(newSize);
@@ -189,8 +190,7 @@ function createConfigStore() {
}, },
addAutoGrantedTool: async (tool: string) => { addAutoGrantedTool: async (tool: string) => {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
if (!currentConfig.auto_granted_tools.includes(tool)) { if (!currentConfig.auto_granted_tools.includes(tool)) {
const newTools = [...currentConfig.auto_granted_tools, tool]; const newTools = [...currentConfig.auto_granted_tools, tool];
await updateConfig({ auto_granted_tools: newTools }); await updateConfig({ auto_granted_tools: newTools });
@@ -198,27 +198,22 @@ function createConfigStore() {
}, },
removeAutoGrantedTool: async (tool: string) => { removeAutoGrantedTool: async (tool: string) => {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
const newTools = currentConfig.auto_granted_tools.filter((t) => t !== tool); const newTools = currentConfig.auto_granted_tools.filter((t) => t !== tool);
await updateConfig({ auto_granted_tools: newTools }); await updateConfig({ auto_granted_tools: newTools });
}, },
getConfig: (): HikariConfig => { getConfig: (): HikariConfig => {
let currentConfig: HikariConfig = defaultConfig; return getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
return currentConfig;
}, },
toggleStreamerMode: async () => { toggleStreamerMode: async () => {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
await updateConfig({ streamer_mode: !currentConfig.streamer_mode }); await updateConfig({ streamer_mode: !currentConfig.streamer_mode });
}, },
toggleCompactMode: async () => { toggleCompactMode: async () => {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
await updateConfig({ compact_mode: !currentConfig.compact_mode }); await updateConfig({ compact_mode: !currentConfig.compact_mode });
}, },
+35 -7
View File
@@ -28,7 +28,7 @@ export interface Conversation {
characterState: CharacterState; characterState: CharacterState;
isProcessing: boolean; isProcessing: boolean;
grantedTools: Set<string>; grantedTools: Set<string>;
pendingPermission: PermissionRequest | null; pendingPermissions: PermissionRequest[];
pendingQuestion: UserQuestionEvent | null; pendingQuestion: UserQuestionEvent | null;
scrollPosition: number; scrollPosition: number;
createdAt: Date; createdAt: Date;
@@ -66,7 +66,7 @@ function createConversationsStore() {
characterState: "idle", characterState: "idle",
isProcessing: false, isProcessing: false,
grantedTools: new Set(), grantedTools: new Set(),
pendingPermission: null, pendingPermissions: [],
pendingQuestion: null, pendingQuestion: null,
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll) scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
createdAt: new Date(), createdAt: new Date(),
@@ -120,7 +120,11 @@ function createConversationsStore() {
); );
const pendingPermission = derived( const pendingPermission = derived(
activeConversation, activeConversation,
($conv) => $conv?.pendingPermission || null ($conv) => $conv?.pendingPermissions[0] || null
);
const pendingPermissions = derived(
activeConversation,
($conv) => $conv?.pendingPermissions || []
); );
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null); const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1); const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
@@ -133,6 +137,7 @@ function createConversationsStore() {
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe }, currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
terminalLines: { subscribe: terminalLines.subscribe }, terminalLines: { subscribe: terminalLines.subscribe },
pendingPermission: { subscribe: pendingPermission.subscribe }, pendingPermission: { subscribe: pendingPermission.subscribe },
pendingPermissions: { subscribe: pendingPermissions.subscribe },
pendingQuestion: { subscribe: pendingQuestion.subscribe }, pendingQuestion: { subscribe: pendingQuestion.subscribe },
isProcessing: { subscribe: isProcessing.subscribe }, isProcessing: { subscribe: isProcessing.subscribe },
grantedTools: { subscribe: grantedTools.subscribe }, grantedTools: { subscribe: grantedTools.subscribe },
@@ -190,7 +195,7 @@ function createConversationsStore() {
conversations.update((convs) => { conversations.update((convs) => {
const conv = convs.get(activeId); const conv = convs.get(activeId);
if (conv) { if (conv) {
conv.pendingPermission = request; conv.pendingPermissions.push(request);
conv.lastActivityAt = new Date(); conv.lastActivityAt = new Date();
} }
return convs; return convs;
@@ -203,7 +208,7 @@ function createConversationsStore() {
conversations.update((convs) => { conversations.update((convs) => {
const conv = convs.get(activeId); const conv = convs.get(activeId);
if (conv) { if (conv) {
conv.pendingPermission = null; conv.pendingPermissions = [];
conv.lastActivityAt = new Date(); conv.lastActivityAt = new Date();
} }
return convs; return convs;
@@ -213,7 +218,7 @@ function createConversationsStore() {
conversations.update((convs) => { conversations.update((convs) => {
const conv = convs.get(conversationId); const conv = convs.get(conversationId);
if (conv) { if (conv) {
conv.pendingPermission = request; conv.pendingPermissions.push(request);
conv.lastActivityAt = new Date(); conv.lastActivityAt = new Date();
} }
return convs; return convs;
@@ -223,7 +228,30 @@ function createConversationsStore() {
conversations.update((convs) => { conversations.update((convs) => {
const conv = convs.get(conversationId); const conv = convs.get(conversationId);
if (conv) { if (conv) {
conv.pendingPermission = null; conv.pendingPermissions = [];
conv.lastActivityAt = new Date();
}
return convs;
});
},
removePermission: (id: string) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingPermissions = conv.pendingPermissions.filter((p) => p.id !== id);
conv.lastActivityAt = new Date();
}
return convs;
});
},
removePermissionForConversation: (conversationId: string, id: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.pendingPermissions = conv.pendingPermissions.filter((p) => p.id !== id);
conv.lastActivityAt = new Date(); conv.lastActivityAt = new Date();
} }
return convs; return convs;
+154
View File
@@ -0,0 +1,154 @@
import { writable, derived } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
export type LogLevel = "debug" | "info" | "warn" | "error";
export interface LogEntry {
id: string;
timestamp: Date;
level: LogLevel;
message: string;
source: "frontend" | "backend";
}
interface DebugConsoleState {
logs: LogEntry[];
isOpen: boolean;
maxLogs: number;
filterLevel: LogLevel | "all";
autoScroll: boolean;
}
const MAX_LOGS = 1000; // Circular buffer size
function createDebugConsoleStore() {
const { subscribe, update } = writable<DebugConsoleState>({
logs: [],
isOpen: false,
maxLogs: MAX_LOGS,
filterLevel: "all",
autoScroll: true,
});
let logCounter = 0;
function addLog(level: LogLevel, message: string, source: "frontend" | "backend") {
update((state) => {
const newLog: LogEntry = {
id: `log-${Date.now()}-${logCounter++}`,
timestamp: new Date(),
level,
message,
source,
};
const updatedLogs = [...state.logs, newLog];
// Implement circular buffer - remove oldest if exceeding max
if (updatedLogs.length > state.maxLogs) {
updatedLogs.shift();
}
return { ...state, logs: updatedLogs };
});
}
// Override console methods to capture frontend logs
const originalConsole = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
debug: console.debug,
};
function setupConsoleCapture() {
console.log = (...args: unknown[]) => {
originalConsole.log(...args);
addLog("info", args.map((arg) => String(arg)).join(" "), "frontend");
};
console.info = (...args: unknown[]) => {
originalConsole.info(...args);
addLog("info", args.map((arg) => String(arg)).join(" "), "frontend");
};
console.warn = (...args: unknown[]) => {
originalConsole.warn(...args);
addLog("warn", args.map((arg) => String(arg)).join(" "), "frontend");
};
console.error = (...args: unknown[]) => {
originalConsole.error(...args);
addLog("error", args.map((arg) => String(arg)).join(" "), "frontend");
};
console.debug = (...args: unknown[]) => {
originalConsole.debug(...args);
addLog("debug", args.map((arg) => String(arg)).join(" "), "frontend");
};
// Capture unhandled errors
window.addEventListener("error", (event) => {
addLog(
"error",
`[Unhandled Error] ${event.message} at ${event.filename}:${event.lineno}:${event.colno}`,
"frontend"
);
});
// Capture unhandled promise rejections
window.addEventListener("unhandledrejection", (event) => {
addLog("error", `[Unhandled Promise Rejection] ${event.reason}`, "frontend");
});
}
function restoreConsole() {
console.log = originalConsole.log;
console.info = originalConsole.info;
console.warn = originalConsole.warn;
console.error = originalConsole.error;
console.debug = originalConsole.debug;
}
// Listen for backend logs
async function setupBackendLogsListener() {
await listen<{ level: LogLevel; message: string }>("debug:log", (event) => {
addLog(event.payload.level, event.payload.message, "backend");
});
}
return {
subscribe,
toggle: () => update((state) => ({ ...state, isOpen: !state.isOpen })),
open: () => update((state) => ({ ...state, isOpen: true })),
close: () => update((state) => ({ ...state, isOpen: false })),
clear: () => update((state) => ({ ...state, logs: [] })),
setFilterLevel: (level: LogLevel | "all") =>
update((state) => ({ ...state, filterLevel: level })),
setAutoScroll: (enabled: boolean) => update((state) => ({ ...state, autoScroll: enabled })),
setupConsoleCapture,
restoreConsole,
setupBackendLogsListener,
};
}
export const debugConsoleStore = createDebugConsoleStore();
// Derived store for filtered logs
export const filteredLogs = derived(debugConsoleStore, ($debugConsole) => {
if ($debugConsole.filterLevel === "all") {
return $debugConsole.logs;
}
const levelPriority: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
const minPriority = levelPriority[$debugConsole.filterLevel];
return $debugConsole.logs.filter((log) => levelPriority[log.level] >= minPriority);
});
+67
View File
@@ -285,6 +285,73 @@ describe("snippetsStore", () => {
expect(get(snippetsStore.selectedCategory)).toBeNull(); expect(get(snippetsStore.selectedCategory)).toBeNull();
}); });
}); });
describe("filteredSnippets", () => {
it("returns all snippets when no category selected", async () => {
const mockSnippets: Snippet[] = [
{
id: "snippet-1",
name: "Git Status",
content: "git status",
category: "Git",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
{
id: "snippet-2",
name: "Docker PS",
content: "docker ps",
category: "Docker",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
];
setMockInvokeResult("list_snippets", mockSnippets);
setMockInvokeResult("get_snippet_categories", ["Git", "Docker"]);
await snippetsStore.loadSnippets();
snippetsStore.setSelectedCategory(null);
const filtered = get(snippetsStore.filteredSnippets);
expect(filtered).toHaveLength(2);
});
it("filters snippets by selected category", async () => {
const mockSnippets: Snippet[] = [
{
id: "snippet-1",
name: "Git Status",
content: "git status",
category: "Git",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
{
id: "snippet-2",
name: "Docker PS",
content: "docker ps",
category: "Docker",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
];
setMockInvokeResult("list_snippets", mockSnippets);
setMockInvokeResult("get_snippet_categories", ["Git", "Docker"]);
await snippetsStore.loadSnippets();
snippetsStore.setSelectedCategory("Git");
const filtered = get(snippetsStore.filteredSnippets);
expect(filtered).toHaveLength(1);
expect(filtered[0].category).toBe("Git");
});
});
}); });
describe("snippet ID generation", () => { describe("snippet ID generation", () => {
+33 -23
View File
@@ -328,31 +328,41 @@ export async function initializeTauriListeners() {
}); });
unlisteners.push(cwdUnlisten); unlisteners.push(cwdUnlisten);
console.log("[Tauri Listener] Setting up claude:permission listener");
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => { const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
const { id, tool_name, tool_input, description, conversation_id } = event.payload; const { permissions, conversation_id } = event.payload;
// Store permission request for the specific conversation console.log(
if (conversation_id) { `[Permission] Event received: ${permissions.length} permission(s) for conversation ${conversation_id || "active"}`,
claudeStore.requestPermissionForConversation(conversation_id, { { permissions, conversation_id }
id, );
tool: tool_name,
description, // Store each permission request for the specific conversation
input: tool_input, for (const permission of permissions) {
}); const { id, tool_name, tool_input, description } = permission;
claudeStore.addLineToConversation(
conversation_id, if (conversation_id) {
"system", claudeStore.requestPermissionForConversation(conversation_id, {
`Permission requested for: ${tool_name}` id,
); tool: tool_name,
} else { description,
// Fallback to active conversation if no conversation_id input: tool_input,
claudeStore.requestPermission({ });
id, claudeStore.addLineToConversation(
tool: tool_name, conversation_id,
description, "system",
input: tool_input, `Permission requested for: ${tool_name}`
}); );
claudeStore.addLine("system", `Permission requested for: ${tool_name}`); } else {
// Fallback to active conversation if no conversation_id
claudeStore.requestPermission({
id,
tool: tool_name,
description,
input: tool_input,
});
claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
}
} }
}); });
unlisteners.push(permissionUnlisten); unlisteners.push(permissionUnlisten);
+5 -1
View File
@@ -126,11 +126,15 @@ export interface PermissionRequest {
input: Record<string, unknown>; input: Record<string, unknown>;
} }
export interface PermissionPromptEvent { export interface PermissionPromptEventItem {
id: string; id: string;
tool_name: string; tool_name: string;
tool_input: Record<string, unknown>; tool_input: Record<string, unknown>;
description: string; description: string;
}
export interface PermissionPromptEvent {
permissions: PermissionPromptEventItem[];
conversation_id?: string; conversation_id?: string;
} }
+175
View File
@@ -135,6 +135,151 @@ describe("stateMapper", () => {
}; };
expect(mapMessageToState(message)).toBeNull(); expect(mapMessageToState(message)).toBeNull();
}); });
it("returns typing for unknown tool", () => {
const message: ClaudeStreamMessage = {
type: "assistant",
message: {
content: [
{
type: "tool_use",
id: "tool-1",
name: "SomeUnknownTool",
input: {},
},
],
model: "claude-3",
stop_reason: "tool_use",
},
};
expect(mapMessageToState(message)).toBe("typing");
});
it("returns thinking for thinking content block", () => {
const message: ClaudeStreamMessage = {
type: "assistant",
message: {
content: [{ type: "thinking", thinking: "Analyzing the problem..." }],
model: "claude-3",
stop_reason: "end_turn",
},
};
expect(mapMessageToState(message)).toBe("thinking");
});
it("returns null for assistant message with no recognizable content", () => {
const message: ClaudeStreamMessage = {
type: "assistant",
message: {
content: [],
model: "claude-3",
stop_reason: "end_turn",
},
};
expect(mapMessageToState(message)).toBeNull();
});
it("returns thinking for thinking_delta stream event", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
delta: {
type: "thinking_delta",
thinking: "Thinking...",
},
},
};
expect(mapMessageToState(message)).toBe("thinking");
});
it("returns typing for text_delta stream event", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
delta: {
type: "text_delta",
text: "Hello",
},
},
};
expect(mapMessageToState(message)).toBe("typing");
});
it("returns thinking for thinking content_block_start", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "thinking",
thinking: "",
},
},
};
expect(mapMessageToState(message)).toBe("thinking");
});
it("returns typing for text content_block_start", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "text",
text: "",
},
},
};
expect(mapMessageToState(message)).toBe("typing");
});
it("returns correct state for tool_use content_block_start", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "tool-1",
name: "Read",
input: {},
},
},
};
expect(mapMessageToState(message)).toBe("searching");
});
it("returns null for stream_event with unrecognized type", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "message_start",
},
};
expect(mapMessageToState(message)).toBeNull();
});
it("returns null for result with unknown subtype", () => {
const message = {
type: "result",
subtype: "unknown_type",
} as unknown as ClaudeStreamMessage;
expect(mapMessageToState(message)).toBeNull();
});
it("returns null for unknown message type", () => {
const message = {
type: "unknown_type",
} as unknown as ClaudeStreamMessage;
expect(mapMessageToState(message)).toBeNull();
});
}); });
describe("extractTextFromMessage", () => { describe("extractTextFromMessage", () => {
@@ -192,6 +337,36 @@ describe("stateMapper", () => {
}; };
expect(extractTextFromMessage(message)).toBe("Completed successfully"); expect(extractTextFromMessage(message)).toBe("Completed successfully");
}); });
it("returns null for result without result field", () => {
const message: ClaudeStreamMessage = {
type: "result",
subtype: "success",
};
expect(extractTextFromMessage(message)).toBeNull();
});
it("returns null for stream_event without delta text", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "text",
text: "",
},
},
};
expect(extractTextFromMessage(message)).toBeNull();
});
it("returns null for unknown message type", () => {
const message = {
type: "unknown",
} as unknown as ClaudeStreamMessage;
expect(extractTextFromMessage(message)).toBeNull();
});
}); });
describe("extractToolInfo", () => { describe("extractToolInfo", () => {
+2
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import "../app.css"; import "../app.css";
import DebugConsole from "$lib/components/DebugConsole.svelte";
let { children } = $props(); let { children } = $props();
@@ -14,4 +15,5 @@
<div id="app"> <div id="app">
{@render children()} {@render children()}
<DebugConsole />
</div> </div>
+73
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { import {
initializeTauriListeners, initializeTauriListeners,
@@ -31,12 +32,16 @@
import AchievementNotification from "$lib/components/AchievementNotification.svelte"; import AchievementNotification from "$lib/components/AchievementNotification.svelte";
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte"; import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
import UpdateNotification from "$lib/components/UpdateNotification.svelte"; import UpdateNotification from "$lib/components/UpdateNotification.svelte";
import CloseAppConfirmModal from "$lib/components/CloseAppConfirmModal.svelte";
import { debugConsoleStore } from "$lib/stores/debugConsole";
let initialized = false; let initialized = false;
let updateNotification: UpdateNotification | undefined = $state(undefined); let updateNotification: UpdateNotification | undefined = $state(undefined);
let achievementPanelOpen = $state(false); let achievementPanelOpen = $state(false);
let currentCharacterState: CharacterState = $state("idle"); let currentCharacterState: CharacterState = $state("idle");
let compactModeActive = $state(false); let compactModeActive = $state(false);
let closeConfirmModalOpen = $state(false);
let hasActiveConversation = $state(false);
// Editor state // Editor state
const isEditorVisible = editorStore.isEditorVisible; const isEditorVisible = editorStore.isEditorVisible;
@@ -230,6 +235,13 @@
return; return;
} }
// Ctrl+` - Toggle debug console
if (event.ctrlKey && event.key === "`") {
event.preventDefault();
debugConsoleStore.toggle();
return;
}
// Ctrl+E - Toggle editor panel (only when connected) // Ctrl+E - Toggle editor panel (only when connected)
if (event.ctrlKey && event.key === "e") { if (event.ctrlKey && event.key === "e") {
event.preventDefault(); event.preventDefault();
@@ -342,6 +354,50 @@
} }
} }
async function handleCloseRequest() {
// Check if there's an active conversation with Claude running
const activeId = get(claudeStore.activeConversationId);
if (activeId) {
try {
const isRunning = await invoke<boolean>("is_claude_running", {
conversationId: activeId,
});
hasActiveConversation = isRunning;
} catch (error) {
console.error("Failed to check Claude status:", error);
hasActiveConversation = false;
}
} else {
hasActiveConversation = false;
}
// Always show confirmation modal
closeConfirmModalOpen = true;
}
async function handleConfirmClose() {
closeConfirmModalOpen = false;
try {
await invoke("close_application");
} catch (error) {
console.error("Failed to close application:", error);
}
}
async function handleMinimizeToTray() {
closeConfirmModalOpen = false;
try {
const window = getCurrentWindow();
await window.hide();
} catch (error) {
console.error("Failed to minimize to tray:", error);
}
}
function handleCancelClose() {
closeConfirmModalOpen = false;
}
onMount(async () => { onMount(async () => {
if (!initialized) { if (!initialized) {
initialized = true; initialized = true;
@@ -387,6 +443,16 @@
// Initialize Discord RPC // Initialize Discord RPC
await initializeDiscordRpc(); await initializeDiscordRpc();
// Listen for window close requests
const unlisten = await listen("window-close-requested", () => {
handleCloseRequest();
});
// Store the unlisten function for cleanup
window.addEventListener("beforeunload", () => {
unlisten();
});
} }
}); });
@@ -453,6 +519,13 @@
onClose={() => (achievementPanelOpen = false)} onClose={() => (achievementPanelOpen = false)}
/> />
<UpdateNotification bind:this={updateNotification} /> <UpdateNotification bind:this={updateNotification} />
<CloseAppConfirmModal
isOpen={closeConfirmModalOpen}
{hasActiveConversation}
onClose={handleConfirmClose}
onMinimize={handleMinimizeToTray}
onCancel={handleCancelClose}
/>
</div> </div>
{/if} {/if}
-1
View File
@@ -39,7 +39,6 @@ vi.mock("@tauri-apps/api/core", () => ({
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null, character_panel_width: null,
font_size: 14, font_size: 14,
minimize_to_tray: false,
streamer_mode: false, streamer_mode: false,
streamer_hide_paths: false, streamer_hide_paths: false,
compact_mode: false, compact_mode: false,