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
+74
View File
@@ -1658,6 +1658,8 @@ dependencies = [
"tauri-plugin-store",
"tempfile",
"tokio",
"tracing",
"tracing-subscriber",
"uuid 1.19.0",
"windows 0.62.2",
]
@@ -2245,6 +2247,15 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "matches"
version = "0.1.10"
@@ -2401,6 +2412,15 @@ dependencies = [
"zbus",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-conv"
version = "0.1.0"
@@ -3879,6 +3899,15 @@ dependencies = [
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shared_child"
version = "1.1.1"
@@ -4695,6 +4724,15 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "tiff"
version = "0.10.3"
@@ -4986,6 +5024,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
@@ -5167,6 +5235,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version-compare"
version = "0.2.1"
+2
View File
@@ -33,6 +33,8 @@ semver = "1"
chrono = { version = "0.4.43", features = ["serde"] }
discord-rich-presence = "0.2"
dirs = "5"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = [
+2 -1
View File
@@ -30,6 +30,7 @@
},
"core:window:allow-set-size",
"core:window:allow-set-always-on-top",
"core:window:allow-inner-size"
"core:window:allow-inner-size",
"core:window:allow-hide"
]
}
+12 -12
View File
@@ -1671,7 +1671,7 @@ pub fn check_message_achievements(
let mut newly_unlocked = Vec::new();
let message_lower = message.to_lowercase();
println!("Checking message achievements for: {}", message);
tracing::info!("Checking message achievements for: {}", message);
// Relationship & Greetings
if message_lower.contains("good morning") && progress.unlock(AchievementId::GoodMorning) {
@@ -1863,18 +1863,18 @@ pub fn check_achievements(
) -> Vec<AchievementId> {
let mut newly_unlocked = Vec::new();
println!(
tracing::info!(
"Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
stats.messages_exchanged,
stats.total_input_tokens + stats.total_output_tokens,
stats.code_blocks_generated
);
println!("Currently unlocked: {:?}", progress.unlocked);
tracing::info!("Currently unlocked: {:?}", progress.unlocked);
// Token milestones
let total_tokens = stats.total_input_tokens + stats.total_output_tokens;
if total_tokens >= 1_000 && progress.unlock(AchievementId::FirstSteps) {
println!("Unlocked FirstSteps achievement!");
tracing::info!("Unlocked FirstSteps achievement!");
newly_unlocked.push(AchievementId::FirstSteps);
}
if total_tokens >= 10_000 && progress.unlock(AchievementId::GrowingStrong) {
@@ -2244,7 +2244,7 @@ pub async fn save_achievements(
// Create a serializable version with just the unlocked achievement IDs
let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect();
println!("Saving achievements: {:?}", unlocked_list);
tracing::info!("Saving achievements: {:?}", unlocked_list);
store.set(
"unlocked",
@@ -2252,18 +2252,18 @@ pub async fn save_achievements(
);
store.save().map_err(|e| e.to_string())?;
println!("Achievements saved successfully");
tracing::info!("Achievements saved successfully");
Ok(())
}
// Load achievements from persistent store
pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
println!("Loading achievements from store...");
tracing::info!("Loading achievements from store...");
let store = match app.store("achievements.json") {
Ok(s) => s,
Err(e) => {
println!("Failed to open achievements store: {}", e);
tracing::error!("Failed to open achievements store: {}", e);
return AchievementProgress::new();
}
};
@@ -2272,19 +2272,19 @@ pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
// Get unlocked achievements
if let Some(unlocked_value) = store.get("unlocked") {
println!("Found unlocked value in store: {:?}", unlocked_value);
tracing::info!("Found unlocked value in store: {:?}", unlocked_value);
if let Ok(unlocked_list) =
serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone())
{
println!("Loaded {} achievements", unlocked_list.len());
tracing::info!("Loaded {} achievements", unlocked_list.len());
for achievement_id in unlocked_list {
progress.unlocked.insert(achievement_id);
}
} else {
println!("Failed to parse unlocked achievements");
tracing::error!("Failed to parse unlocked achievements");
}
} else {
println!("No unlocked achievements found in store");
tracing::info!("No unlocked achievements found in store");
}
progress
+13
View File
@@ -1154,6 +1154,19 @@ pub async fn log_discord_rpc(
Ok(())
}
#[tauri::command]
pub async fn close_application(app_handle: AppHandle) -> Result<(), String> {
// Get the main window
if let Some(window) = app_handle.get_webview_window("main") {
// Hide the window first for a smoother close
let _ = window.hide();
}
// Exit the application
app_handle.exit(0);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
+1 -6
View File
@@ -28,6 +28,7 @@ pub struct ClaudeStartOptions {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HikariConfig {
#[serde(default)]
pub model: Option<String>,
@@ -71,9 +72,6 @@ pub struct HikariConfig {
#[serde(default = "default_font_size")]
pub font_size: u32,
#[serde(default)]
pub minimize_to_tray: bool,
#[serde(default)]
pub streamer_mode: bool,
@@ -134,7 +132,6 @@ impl Default for HikariConfig {
update_checks_enabled: true,
character_panel_width: None,
font_size: 14,
minimize_to_tray: false,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
@@ -242,7 +239,6 @@ mod tests {
assert!(config.update_checks_enabled);
assert!(config.character_panel_width.is_none());
assert_eq!(config.font_size, 14);
assert!(!config.minimize_to_tray);
assert!(!config.streamer_mode);
assert!(!config.streamer_hide_paths);
assert!(!config.compact_mode);
@@ -275,7 +271,6 @@ mod tests {
update_checks_enabled: true,
character_panel_width: Some(400),
font_size: 16,
minimize_to_tray: true,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
+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() {
// Ensure the directory exists
if let Err(e) = std::fs::create_dir_all(&app_data_dir) {
eprintln!("Failed to create app data directory: {}", e);
tracing::error!("Failed to create app data directory: {}", e);
return;
}
let log_path = app_data_dir.join("hikari_discord_rpc.log");
+26 -10
View File
@@ -4,6 +4,7 @@ mod clipboard;
mod commands;
mod config;
mod cost_tracking;
mod debug_logger;
mod discord_rpc;
mod git;
mod notifications;
@@ -24,6 +25,7 @@ use bridge_manager::create_shared_bridge_manager;
use clipboard::*;
use commands::load_saved_achievements;
use commands::*;
use debug_logger::TauriLogLayer;
use discord_rpc::DiscordRpcManager;
use git::*;
use notifications::*;
@@ -31,9 +33,11 @@ use quick_actions::*;
use sessions::*;
use snippets::*;
use std::sync::Arc;
use tauri::Manager;
use tauri::{Emitter, Manager};
use temp_manager::create_shared_temp_manager;
use tray::{setup_tray, should_minimize_to_tray};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tray::setup_tray;
use vbs_notification::*;
use windows_toast::*;
use wsl_notifications::*;
@@ -58,6 +62,14 @@ pub fn run() {
.manage(temp_manager.clone())
.manage(discord_rpc.clone())
.setup(move |app| {
// Initialize tracing with custom layer that emits to frontend
// NOTE: We don't use fmt::layer() because in production builds with windows_subsystem = "windows",
// stdout is hidden. Instead, all logs go through TauriLogLayer to the debug console.
let tauri_layer = TauriLogLayer::new(app.handle().clone());
tracing_subscriber::registry()
.with(tauri_layer)
.init();
// Initialize the app handle in the bridge manager
bridge_manager.lock().set_app_handle(app.handle().clone());
@@ -67,26 +79,29 @@ pub fn run() {
// Clean up any orphaned temp files from previous sessions
if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() {
if count > 0 {
println!("Cleaned up {} orphaned temp files", count);
tracing::info!("Cleaned up {} orphaned temp files", count);
}
}
tracing::info!("Hikari Desktop started successfully");
// Set up system tray
if let Err(e) = setup_tray(app.handle()) {
eprintln!("Failed to set up system tray: {}", e);
tracing::error!("Failed to set up system tray: {}", e);
}
// Handle window close event for minimize to tray
// Handle window close event for minimize to tray and close confirmation
let main_window = app.get_webview_window("main").unwrap();
main_window.on_window_event({
let app_handle = app.handle().clone();
move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if should_minimize_to_tray(&app_handle) {
api.prevent_close();
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.hide();
}
// Always prevent default close - let frontend handle it
api.prevent_close();
// Emit event to frontend to show confirmation modal
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.emit("window-close-requested", ());
}
}
}
@@ -181,6 +196,7 @@ pub fn run() {
update_discord_rpc,
stop_discord_rpc,
log_discord_rpc,
close_application,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+8 -8
View File
@@ -618,7 +618,7 @@ pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<()
let persisted = PersistedStats::from(stats);
println!("Saving stats: {:?}", persisted);
tracing::info!("Saving stats: {:?}", persisted);
store.set(
"lifetime_stats",
@@ -626,32 +626,32 @@ pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<()
);
store.save().map_err(|e| e.to_string())?;
println!("Stats saved successfully");
tracing::info!("Stats saved successfully");
Ok(())
}
/// Load lifetime stats from persistent store
pub async fn load_stats(app: &tauri::AppHandle) -> Option<PersistedStats> {
println!("Loading stats from store...");
tracing::info!("Loading stats from store...");
let store = match app.store("stats.json") {
Ok(s) => s,
Err(e) => {
println!("Failed to open stats store: {}", e);
tracing::error!("Failed to open stats store: {}", e);
return None;
}
};
if let Some(stats_value) = store.get("lifetime_stats") {
println!("Found lifetime stats in store: {:?}", stats_value);
tracing::info!("Found lifetime stats in store: {:?}", stats_value);
if let Ok(persisted) = serde_json::from_value::<PersistedStats>(stats_value.clone()) {
println!("Loaded lifetime stats successfully");
tracing::info!("Loaded lifetime stats successfully");
return Some(persisted);
} else {
println!("Failed to parse lifetime stats");
tracing::error!("Failed to parse lifetime stats");
}
} else {
println!("No lifetime stats found in store");
tracing::info!("No lifetime stats found in store");
}
None
+3 -3
View File
@@ -77,8 +77,8 @@ impl TempFileManager {
for file_path in files {
if file_path.exists() {
if let Err(e) = fs::remove_file(&file_path) {
eprintln!(
"Warning: Failed to remove temp file {:?}: {}",
tracing::warn!(
"Failed to remove temp file {:?}: {}",
file_path, e
);
}
@@ -115,7 +115,7 @@ impl TempFileManager {
let path = entry.path();
if path.is_file() && !tracked_files.contains(&path) {
if let Err(e) = fs::remove_file(&path) {
eprintln!("Warning: Failed to remove orphaned file {:?}: {}", path, e);
tracing::warn!("Failed to remove orphaned file {:?}: {}", path, e);
} else {
cleaned_count += 1;
}
-20
View File
@@ -4,8 +4,6 @@ use tauri::{
AppHandle, Manager,
};
use crate::config::HikariConfig;
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
let show_item = MenuItem::with_id(app, "show", "Show Hikari", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
@@ -48,21 +46,3 @@ pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
Ok(())
}
pub fn should_minimize_to_tray(app: &AppHandle) -> bool {
let config_path = app
.path()
.app_config_dir()
.ok()
.map(|p| p.join("hikari-config.json"));
if let Some(path) = config_path {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(config) = serde_json::from_str::<HikariConfig>(&content) {
return config.minimize_to_tray;
}
}
}
false
}
+6 -1
View File
@@ -202,11 +202,16 @@ pub struct OutputEvent {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionPromptEvent {
pub struct PermissionPromptEventItem {
pub id: String,
pub tool_name: String,
pub tool_input: serde_json::Value,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionPromptEvent {
pub permissions: Vec<PermissionPromptEventItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
+187 -58
View File
@@ -16,9 +16,24 @@ use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
use crate::types::{
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent,
QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent, WorkingDirectoryEvent,
PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent,
UserQuestionEvent, WorkingDirectoryEvent,
};
use parking_lot::RwLock;
use std::cell::RefCell;
thread_local! {
/// Stores pending tool uses from the most recent Assistant message
/// to enable batching permission requests for sibling cancelled tools
static PENDING_TOOL_USES: RefCell<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 CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
@@ -118,21 +133,21 @@ impl WslBridge {
let app_clone = app.clone();
let stats = self.stats.clone();
tauri::async_runtime::spawn(async move {
println!("Loading saved achievements...");
tracing::info!("Loading saved achievements...");
let achievements = crate::achievements::load_achievements(&app_clone).await;
println!(
tracing::info!(
"Loaded {} unlocked achievements",
achievements.unlocked.len()
);
println!("Loading saved stats...");
tracing::info!("Loading saved stats...");
let persisted_stats = crate::stats::load_stats(&app_clone).await;
let mut stats_guard = stats.write();
stats_guard.achievements = achievements;
if let Some(persisted) = persisted_stats {
println!("Applying persisted lifetime stats");
tracing::info!("Applying persisted lifetime stats");
stats_guard.apply_persisted(persisted);
}
});
@@ -174,8 +189,8 @@ impl WslBridge {
// Detect if we're running inside WSL or on Windows
let is_wsl = detect_wsl();
eprintln!("[DEBUG] is_wsl: {}", is_wsl);
eprintln!("[DEBUG] options: {:?}", options);
tracing::debug!("is_wsl: {}", is_wsl);
tracing::debug!("options: {:?}", options);
let mut command = if is_wsl {
// Running inside WSL - call claude directly
@@ -184,8 +199,8 @@ impl WslBridge {
"Could not find claude binary. Is Claude Code installed?".to_string()
})?;
eprintln!("[DEBUG] Found claude at: {}", claude_path);
eprintln!("[DEBUG] Working dir: {}", working_dir);
tracing::debug!("Found claude at: {}", claude_path);
tracing::debug!("Working dir: {}", working_dir);
let mut cmd = Command::new(&claude_path);
cmd.args([
@@ -241,7 +256,7 @@ impl WslBridge {
cmd
} else {
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
eprintln!("[DEBUG] Windows path - using wsl");
tracing::debug!("Windows path - using wsl");
let mut cmd = Command::new("wsl");
// Build the claude command with all arguments
@@ -307,7 +322,7 @@ impl WslBridge {
.stderr(Stdio::piped());
let mut child = command.spawn().map_err(|e| {
eprintln!("[DEBUG] Spawn error: {:?}", e);
tracing::error!("Spawn error: {:?}", e);
format!("Failed to spawn process: {}", e)
})?;
@@ -485,7 +500,7 @@ impl WslBridge {
(input_chars, stats.current_request_output_chars, stats.current_request_thinking_chars, stats.current_request_tools.clone(), model)
};
println!("[COST ESTIMATION] Estimating cost for interrupted request");
tracing::info!("[COST ESTIMATION] Estimating cost for interrupted request");
// Use conservative 3.5 chars/token for estimation (vs standard 4)
let estimated_input_tokens = (input_chars as f64 / 3.5).ceil() as u64;
@@ -503,7 +518,7 @@ impl WslBridge {
let avg_tokens = (tool_stats.estimated_input_tokens + tool_stats.estimated_output_tokens)
/ tool_stats.call_count;
tool_overhead_tokens += avg_tokens;
println!("[COST ESTIMATION] Tool {} average: {} tokens", tool_name, avg_tokens);
tracing::info!("[COST ESTIMATION] Tool {} average: {} tokens", tool_name, avg_tokens);
}
}
}
@@ -517,9 +532,9 @@ impl WslBridge {
let conservative_input = (total_estimated_input as f64 * safety_margin).ceil() as u64;
let conservative_output = (total_estimated_output as f64 * safety_margin).ceil() as u64;
println!("[COST ESTIMATION] Input: {} chars → {} tokens (+ {} tool overhead) × 1.2 safety = {} tokens",
tracing::info!("[COST ESTIMATION] Input: {} chars → {} tokens (+ {} tool overhead) × 1.2 safety = {} tokens",
input_chars, estimated_input_tokens, tool_overhead_tokens, conservative_input);
println!("[COST ESTIMATION] Output: {} chars → {} tokens × 1.2 safety = {} tokens",
tracing::info!("[COST ESTIMATION] Output: {} chars → {} tokens × 1.2 safety = {} tokens",
output_chars + thinking_chars,
estimated_output_tokens, conservative_output);
@@ -532,7 +547,7 @@ impl WslBridge {
None,
);
println!("[COST ESTIMATION] Estimated cost: ${:.4} (conservative)", estimated_cost);
tracing::info!("[COST ESTIMATION] Estimated cost: ${:.4} (conservative)", estimated_cost);
// Add to stats with estimated flag
{
@@ -572,11 +587,11 @@ impl WslBridge {
let stats_snapshot = self.stats.read().clone();
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
println!("Saving stats on session stop...");
tracing::info!("Saving stats on session stop...");
if let Err(e) = crate::stats::save_stats(&app_clone, &stats_snapshot).await {
eprintln!("Failed to save stats: {}", e);
tracing::error!("Failed to save stats: {}", e);
} else {
println!("Stats saved successfully on session stop");
tracing::info!("Stats saved successfully on session stop");
}
});
@@ -621,11 +636,11 @@ fn handle_stdout(
match line {
Ok(line) if !line.is_empty() => {
if let Err(e) = process_json_line(&line, &app, &stats, &conversation_id) {
eprintln!("Error processing line: {}", e);
tracing::error!("Error processing line: {}", e);
}
}
Err(e) => {
eprintln!("Error reading stdout: {}", e);
tracing::error!("Error reading stdout: {}", e);
break;
}
_ => {}
@@ -648,7 +663,7 @@ fn handle_stderr(
// Check if this is a SubagentStart hook message
if line.contains("[SubagentStart Hook]") {
if let Some(agent_data) = parse_subagent_start_hook(&line) {
eprintln!("[DEBUG] Parsed SubagentStart hook: agent_id={}, parent={:?}",
tracing::debug!("Parsed SubagentStart hook: agent_id={}, parent={:?}",
agent_data.agent_id, agent_data.parent_tool_use_id);
// Emit an agent-update event with the agent_id
@@ -770,6 +785,26 @@ fn process_json_line(
})
.collect();
// Store pending tool uses for permission batching (only for top-level, not subagents)
if parent_tool_use_id.is_none() {
PENDING_TOOL_USES.with(|pending| {
let tool_uses: Vec<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
let mut message_cost: Option<MessageCost> = None;
@@ -780,7 +815,7 @@ fn process_json_line(
let stats_guard = stats.read();
stats_guard.model.clone()
}).unwrap_or_else(|| {
println!("[WARNING] No model info available for cost calculation, using default");
tracing::warn!("No model info available for cost calculation, using default");
"claude-sonnet-4-5-20250929".to_string()
});
@@ -793,7 +828,7 @@ fn process_json_line(
usage.cache_read_input_tokens,
);
println!("Assistant message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}, cost: ${:.4}",
tracing::info!("Assistant message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}, cost: ${:.4}",
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
@@ -882,8 +917,8 @@ fn process_json_line(
.unwrap_or_default()
.as_millis() as u64;
eprintln!(
"[DEBUG] Emitting agent-start: id={}, desc={}, type={}, parent={:?}",
tracing::debug!(
"Emitting agent-start: id={}, desc={}, type={}, parent={:?}",
id, description, subagent_type, parent_tool_use_id
);
@@ -1028,18 +1063,40 @@ fn process_json_line(
duration_ms,
num_turns,
} => {
tracing::info!(
"Received Result message: subtype={}, has_denials={}, denial_count={:?}",
subtype,
permission_denials.is_some(),
permission_denials.as_ref().map(|d| d.len())
);
let state = if subtype == "success" {
CharacterState::Success
} else {
CharacterState::Error
};
// Capture pending tool uses before clearing them
// We'll use these for permission batching if there are denials
let captured_pending_tools = PENDING_TOOL_USES.with(|pending| {
let tools = pending.borrow().clone();
// Clear immediately so they don't accumulate across requests
pending.borrow_mut().clear();
tools
});
tracing::debug!(
"Captured {} pending tool use(s): {:?}",
captured_pending_tools.len(),
captured_pending_tools.iter().map(|t| &t.tool_name).collect::<Vec<_>>()
);
// Log turn metrics if available
if let Some(duration) = duration_ms {
println!("Turn completed in {}ms", duration);
tracing::info!("Turn completed in {}ms", duration);
}
if let Some(turns) = num_turns {
println!("Turn count: {}", turns);
tracing::info!("Turn count: {}", turns);
}
// Track token usage from Result messages if available
@@ -1069,7 +1126,7 @@ fn process_json_line(
usage_info.cache_creation_input_tokens,
usage_info.cache_read_input_tokens,
);
println!("Result message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}",
tracing::info!("Result message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}",
usage_info.input_tokens,
usage_info.output_tokens,
usage_info.cache_creation_input_tokens,
@@ -1099,9 +1156,9 @@ fn process_json_line(
let newly_unlocked = {
let mut stats_guard = stats.write();
stats_guard.get_session_duration();
println!("Checking achievements after result message...");
tracing::info!("Checking achievements after result message...");
let unlocked = stats_guard.check_achievements();
println!("Newly unlocked achievements: {:?}", unlocked);
tracing::info!("Newly unlocked achievements: {:?}", unlocked);
unlocked
};
@@ -1116,20 +1173,20 @@ fn process_json_line(
// Save achievements after unlocking new ones
if !newly_unlocked.is_empty() {
println!("Saving newly unlocked achievements: {:?}", newly_unlocked);
tracing::info!("Saving newly unlocked achievements: {:?}", newly_unlocked);
let app_handle = app.clone();
let achievements_progress = stats.read().achievements.clone();
// Use Tauri's async runtime instead of tokio::spawn
tauri::async_runtime::spawn(async move {
println!("Spawned save task for achievements");
tracing::info!("Spawned save task for achievements");
if let Err(e) =
crate::achievements::save_achievements(&app_handle, &achievements_progress)
.await
{
eprintln!("Failed to save achievements: {}", e);
tracing::error!("Failed to save achievements: {}", e);
} else {
println!("Achievement save task completed successfully");
tracing::info!("Achievement save task completed successfully");
}
});
}
@@ -1146,9 +1203,9 @@ fn process_json_line(
{
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
println!("Periodic stats save (every 10 messages)...");
tracing::info!("Periodic stats save (every 10 messages)...");
if let Err(e) = crate::stats::save_stats(&app_handle, &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
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
if denial.tool_name == "AskUserQuestion" {
if let Some(questions) = denial
@@ -1235,24 +1319,57 @@ fn process_json_line(
}
}
} else {
has_regular_denials = true;
let description =
format_tool_description(&denial.tool_name, &denial.tool_input);
let _ = app.emit(
"claude:permission",
PermissionPromptEvent {
id: denial.tool_use_id.clone(),
tool_name: denial.tool_name.clone(),
tool_input: denial.tool_input.clone(),
description,
conversation_id: conversation_id.clone(),
},
);
regular_permission_requests.push(PermissionPromptEventItem {
id: denial.tool_use_id.clone(),
tool_name: denial.tool_name.clone(),
tool_input: denial.tool_input.clone(),
description,
});
}
}
// Show permission state if there were any denials (questions or regular)
if has_regular_denials || !denials.is_empty() {
// Check for sibling tools that may have been cancelled
// Add them to the permission batch so they can be approved together
for tool_use in captured_pending_tools.iter() {
// Skip system tools that should never require permission
if is_system_tool(&tool_use.tool_name) {
continue;
}
// Only add tools that weren't explicitly denied (these are likely cancelled siblings)
if !denied_tool_ids.contains(&tool_use.tool_use_id) {
let description = format_tool_description(&tool_use.tool_name, &tool_use.tool_input);
regular_permission_requests.push(PermissionPromptEventItem {
id: tool_use.tool_use_id.clone(),
tool_name: tool_use.tool_name.clone(),
tool_input: tool_use.tool_input.clone(),
description,
});
}
}
// Emit all regular permission requests as a single batched event
if !regular_permission_requests.is_empty() {
tracing::info!(
"Emitting permission event for {} tool(s) in conversation {:?}",
regular_permission_requests.len(),
conversation_id
);
for req in &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(
app,
CharacterState::Permission,
@@ -1261,7 +1378,19 @@ fn process_json_line(
);
return Ok(());
}
}
// Show permission state if there were any question denials
if !denials.is_empty() {
emit_state_change(
app,
CharacterState::Permission,
None,
conversation_id.clone(),
);
return Ok(());
}
} // end of else block for non-empty denials
} // end of if let Some(denials)
emit_state_change(app, state, None, conversation_id.clone());
}
@@ -1311,7 +1440,7 @@ fn process_json_line(
// Check achievements after user message
let newly_unlocked = {
let mut stats_guard = stats.write();
println!("User sent message, checking achievements...");
tracing::info!("User sent message, checking achievements...");
// Check message-based achievements
let mut unlocked = crate::achievements::check_message_achievements(
@@ -1328,7 +1457,7 @@ fn process_json_line(
// Emit achievement events for any newly unlocked achievements
for achievement_id in &newly_unlocked {
println!("User message unlocked achievement: {:?}", achievement_id);
tracing::info!("User message unlocked achievement: {:?}", achievement_id);
let info = get_achievement_info(achievement_id);
let _ = app.emit(
"achievement:unlocked",
@@ -1338,7 +1467,7 @@ fn process_json_line(
// Save achievements after unlocking new ones
if !newly_unlocked.is_empty() {
println!("Saving newly unlocked achievements from user message");
tracing::info!("Saving newly unlocked achievements from user message");
let app_handle = app.clone();
let achievements_progress = stats.read().achievements.clone();
tauri::async_runtime::spawn(async move {
@@ -1346,9 +1475,9 @@ fn process_json_line(
crate::achievements::save_achievements(&app_handle, &achievements_progress)
.await
{
eprintln!("Failed to save achievements: {}", e);
tracing::error!("Failed to save achievements: {}", e);
} else {
println!("Achievements saved after user message");
tracing::info!("Achievements saved after user message");
}
});
}
+4 -4
View File
@@ -48,15 +48,15 @@ $notifier.Show($toast)
match output {
Ok(result) => {
if result.status.success() {
println!("WSL notification sent successfully");
tracing::info!("WSL notification sent successfully");
return Ok(());
} else {
let stderr = String::from_utf8_lossy(&result.stderr);
println!("PowerShell toast failed: {}", stderr);
tracing::error!("PowerShell toast failed: {}", stderr);
}
}
Err(e) => {
println!("Failed to run PowerShell: {}", e);
tracing::error!("Failed to run PowerShell: {}", e);
}
}
@@ -74,7 +74,7 @@ $notifier.Show($toast)
if let Ok(result) = notify_result {
if result.status.success() {
println!("Notification sent via wsl-notify-send");
tracing::info!("Notification sent via wsl-notify-send");
return Ok(());
}
}