generated from nhcarrigan/template
fix: critical permission modal and config issues (#127)
## 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:
@@ -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
@@ -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}"
|
||||||
|
|||||||
Generated
+74
@@ -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"
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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, ¤t_stats).await {
|
if let Err(e) = crate::stats::save_stats(&app_handle, ¤t_stats).await {
|
||||||
eprintln!("Failed to save stats: {}", e);
|
tracing::error!("Failed to save stats: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1172,9 +1229,36 @@ fn process_json_line(
|
|||||||
|
|
||||||
// Check for permission denials and emit prompts for each
|
// 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 ®ular_permission_requests {
|
||||||
|
tracing::debug!(
|
||||||
|
"Permission requested: {} (id: {})",
|
||||||
|
req.tool_name,
|
||||||
|
req.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:permission",
|
||||||
|
PermissionPromptEvent {
|
||||||
|
permissions: regular_permission_requests,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
emit_state_change(
|
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");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user