Compare commits

..

6 Commits

Author SHA1 Message Date
hikari 1d94bdfbb0 feat: add close confirmation modal with minimize to tray option
Implemented a confirmation modal when users try to close the application:
- Modal always shows with three options: Cancel, Minimize to Tray, Close Application
- Detects if Claude is actively running and shows appropriate warning message
- Removed minimize_to_tray config setting (no longer needed)
- Added core:window:allow-hide permission for window hiding
- Created CloseAppConfirmModal component with keyboard shortcuts (Escape to cancel)
- Added close_application command to properly exit the app
- Backend emits window-close-requested event for frontend to handle

This provides better UX by giving users clear choices every time they close,
preventing accidental closures during active work sessions.
2026-02-06 21:49:13 -08:00
hikari f654c3c3ff fix: resolve permission modal issues with successful operations
Fixes #113 - EnterPlanMode/ExitPlanMode infinite permission loops

This commit addresses multiple related issues with the permission system:

1. Added system tool filtering to sibling tools loop to prevent
   EnterPlanMode/ExitPlanMode from appearing in permission modals

2. Skip permission modal processing entirely when operations succeed
   (subtype == "success"), since tools were already approved and executed

3. Emit proper state change before early return to prevent Hikari
   from getting stuck in "typing" state

The early return happens after all stats, costs, and achievements
are processed, so no data tracking is affected.

 This issue was fixed with help from Hikari~ 🌸
2026-02-06 20:50:23 -08:00
hikari 24b2e3eb48 fix: prevent permission prompts for system tools
Fixed infinite permission loop when using ExitPlanMode and other
system tools. These tools are now automatically allowed without
requiring user approval.

The issue occurred because all tool denials triggered permission
prompts, including system tools like ExitPlanMode that should never
require permission. This caused an infinite loop where:
1. Claude Code calls ExitPlanMode
2. Hikari shows permission modal
3. User approves
4. Claude Code retries ExitPlanMode
5. Loop repeats

Solution:
- Added is_system_tool helper function to identify system tools
- System tools (ExitPlanMode, EnterPlanMode) are now skipped in
  permission denial processing
- These tools execute immediately without user intervention

Closes #113
2026-02-06 20:21:05 -08:00
hikari 5b8ae63de1 feat: add debug console for frontend and backend logs
Added a comprehensive debug console feature that captures and displays
logs from both the frontend and backend in a unified interface.

Frontend changes:
- Created DebugConsole component with real-time log display
- Added debugConsoleStore for state management and console capture
- Integrated console into main layout
- Added toggle button in StatusBar with console icon
- Implemented Ctrl+` keyboard shortcut to open/close console
- Features: log filtering by level, auto-scroll, timestamps, colour-coding

Backend changes:
- Added tracing and tracing-subscriber dependencies
- Created custom TauriLogLayer to emit Rust logs to frontend
- Integrated tracing subscriber in lib.rs setup
- Logs are forwarded via Tauri events (debug:log)

Key features:
- Circular buffer (max 1000 logs) prevents memory issues
- Frontend logs captured via console method overrides
- Backend logs forwarded from Rust tracing layer
- Log level filtering (debug, info, warn, error, all)
- Source badges distinguish frontend vs backend logs
- Colour-coded log levels for easy identification
- Auto-scroll toggle for inspecting older logs
- Clear logs button for resetting the console
- Beautiful dark-themed UI matching app aesthetic

Closes #126
2026-02-06 20:16:26 -08:00
hikari 82061f125b feat: batch parallel permission requests for improved UX
Implemented intelligent permission batching that detects cancelled sibling
tool calls and presents them together in a single modal. This dramatically
improves the user experience when multiple tools require permission.

Key changes:
- Track pending tool uses from Assistant messages in thread-local storage
- Capture and batch sibling tools that get cancelled due to permission denials
- Clear pending tools on each Result message to prevent accumulation
- Use SvelteSet for reactive permission selection in the modal
- Update permission modal to display count when multiple permissions requested
- Fix check-all.sh to source nvm for pnpm access
- Add git commit instructions to CLAUDE.md for this project

Technical improvements:
- Thread-local storage for cross-message tool tracking
- Proper null checking in TypeScript permission handling
- Clippy-compliant const initialisation for thread_local
- All ESLint, TypeScript, and Rust checks passing

The modal now shows both the explicitly denied tool AND any sibling tools
that were called in parallel, allowing users to approve all permissions
in one go instead of clicking through multiple modals.
2026-02-06 19:54:12 -08:00
naomi 870de7588f feat: add claude file 2026-02-06 18:16:55 -08:00
19 changed files with 105 additions and 721 deletions
+3 -3
View File
@@ -36,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 with coverage" "pnpm test:coverage" || failed=1 run_check "Frontend tests" "pnpm test" || 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 with coverage" "(cd src-tauri && cargo llvm-cov --fail-under-lines 50)" || failed=1 run_check "Backend tests" "cargo test" || failed=1
# Summary # Summary
echo -e "\n${YELLOW}========================================${NC}" echo -e "\n${YELLOW}========================================${NC}"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hikari-desktop", "name": "hikari-desktop",
"version": "1.4.0", "version": "1.3.0",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "hikari-desktop" name = "hikari-desktop"
version = "1.4.0" version = "1.3.0"
description = "Hikari - Claude Code Visual Assistant" description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"] authors = ["Naomi Carrigan"]
edition = "2021" edition = "2021"
+12 -12
View File
@@ -1671,7 +1671,7 @@ pub fn check_message_achievements(
let mut newly_unlocked = Vec::new(); let mut newly_unlocked = Vec::new();
let message_lower = message.to_lowercase(); let message_lower = message.to_lowercase();
tracing::info!("Checking message achievements for: {}", message); println!("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();
tracing::info!( println!(
"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
); );
tracing::info!("Currently unlocked: {:?}", progress.unlocked); println!("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) {
tracing::info!("Unlocked FirstSteps achievement!"); println!("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();
tracing::info!("Saving achievements: {:?}", unlocked_list); println!("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())?;
tracing::info!("Achievements saved successfully"); println!("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 {
tracing::info!("Loading achievements from store..."); println!("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) => {
tracing::error!("Failed to open achievements store: {}", e); println!("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") {
tracing::info!("Found unlocked value in store: {:?}", unlocked_value); println!("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())
{ {
tracing::info!("Loaded {} achievements", unlocked_list.len()); println!("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 {
tracing::error!("Failed to parse unlocked achievements"); println!("Failed to parse unlocked achievements");
} }
} else { } else {
tracing::info!("No unlocked achievements found in store"); println!("No unlocked achievements found in store");
} }
progress progress
-1
View File
@@ -28,7 +28,6 @@ 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>,
+1 -1
View File
@@ -30,7 +30,7 @@ impl DiscordRpcManager {
if let Ok(app_data_dir) = app_handle.path().app_data_dir() { if let Ok(app_data_dir) = app_handle.path().app_data_dir() {
// Ensure the directory exists // Ensure the directory exists
if let Err(e) = std::fs::create_dir_all(&app_data_dir) { if let Err(e) = std::fs::create_dir_all(&app_data_dir) {
tracing::error!("Failed to create app data directory: {}", e); eprintln!("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");
+2 -3
View File
@@ -63,10 +63,9 @@ pub fn run() {
.manage(discord_rpc.clone()) .manage(discord_rpc.clone())
.setup(move |app| { .setup(move |app| {
// Initialize tracing with custom layer that emits to frontend // 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()); let tauri_layer = TauriLogLayer::new(app.handle().clone());
tracing_subscriber::registry() tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.with(tauri_layer) .with(tauri_layer)
.init(); .init();
@@ -87,7 +86,7 @@ pub fn run() {
// Set up system tray // Set up system tray
if let Err(e) = setup_tray(app.handle()) { if let Err(e) = setup_tray(app.handle()) {
tracing::error!("Failed to set up system tray: {}", e); eprintln!("Failed to set up system tray: {}", e);
} }
// Handle window close event for minimize to tray and close confirmation // Handle window close event for minimize to tray and close confirmation
+8 -8
View File
@@ -618,7 +618,7 @@ pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<()
let persisted = PersistedStats::from(stats); let persisted = PersistedStats::from(stats);
tracing::info!("Saving stats: {:?}", persisted); println!("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())?;
tracing::info!("Stats saved successfully"); println!("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> {
tracing::info!("Loading stats from store..."); println!("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) => {
tracing::error!("Failed to open stats store: {}", e); println!("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") {
tracing::info!("Found lifetime stats in store: {:?}", stats_value); println!("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()) {
tracing::info!("Loaded lifetime stats successfully"); println!("Loaded lifetime stats successfully");
return Some(persisted); return Some(persisted);
} else { } else {
tracing::error!("Failed to parse lifetime stats"); println!("Failed to parse lifetime stats");
} }
} else { } else {
tracing::info!("No lifetime stats found in store"); println!("No lifetime stats found in store");
} }
None None
+3 -3
View File
@@ -77,8 +77,8 @@ impl TempFileManager {
for file_path in files { for file_path in files {
if file_path.exists() { if file_path.exists() {
if let Err(e) = fs::remove_file(&file_path) { if let Err(e) = fs::remove_file(&file_path) {
tracing::warn!( eprintln!(
"Failed to remove temp file {:?}: {}", "Warning: 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) {
tracing::warn!("Failed to remove orphaned file {:?}: {}", path, e); eprintln!("Warning: Failed to remove orphaned file {:?}: {}", path, e);
} else { } else {
cleaned_count += 1; cleaned_count += 1;
} }
+47 -76
View File
@@ -133,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 {
tracing::info!("Loading saved achievements..."); println!("Loading saved achievements...");
let achievements = crate::achievements::load_achievements(&app_clone).await; let achievements = crate::achievements::load_achievements(&app_clone).await;
tracing::info!( println!(
"Loaded {} unlocked achievements", "Loaded {} unlocked achievements",
achievements.unlocked.len() achievements.unlocked.len()
); );
tracing::info!("Loading saved stats..."); println!("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 {
tracing::info!("Applying persisted lifetime stats"); println!("Applying persisted lifetime stats");
stats_guard.apply_persisted(persisted); stats_guard.apply_persisted(persisted);
} }
}); });
@@ -189,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();
tracing::debug!("is_wsl: {}", is_wsl); eprintln!("[DEBUG] is_wsl: {}", is_wsl);
tracing::debug!("options: {:?}", options); eprintln!("[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
@@ -199,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()
})?; })?;
tracing::debug!("Found claude at: {}", claude_path); eprintln!("[DEBUG] Found claude at: {}", claude_path);
tracing::debug!("Working dir: {}", working_dir); eprintln!("[DEBUG] Working dir: {}", working_dir);
let mut cmd = Command::new(&claude_path); let mut cmd = Command::new(&claude_path);
cmd.args([ cmd.args([
@@ -256,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
tracing::debug!("Windows path - using wsl"); eprintln!("[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
@@ -322,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| {
tracing::error!("Spawn error: {:?}", e); eprintln!("[DEBUG] Spawn error: {:?}", e);
format!("Failed to spawn process: {}", e) format!("Failed to spawn process: {}", e)
})?; })?;
@@ -500,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)
}; };
tracing::info!("[COST ESTIMATION] Estimating cost for interrupted request"); println!("[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;
@@ -518,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;
tracing::info!("[COST ESTIMATION] Tool {} average: {} tokens", tool_name, avg_tokens); println!("[COST ESTIMATION] Tool {} average: {} tokens", tool_name, avg_tokens);
} }
} }
} }
@@ -532,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;
tracing::info!("[COST ESTIMATION] Input: {} chars → {} tokens (+ {} tool overhead) × 1.2 safety = {} tokens", println!("[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);
tracing::info!("[COST ESTIMATION] Output: {} chars → {} tokens × 1.2 safety = {} tokens", println!("[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);
@@ -547,7 +547,7 @@ impl WslBridge {
None, None,
); );
tracing::info!("[COST ESTIMATION] Estimated cost: ${:.4} (conservative)", estimated_cost); println!("[COST ESTIMATION] Estimated cost: ${:.4} (conservative)", estimated_cost);
// Add to stats with estimated flag // Add to stats with estimated flag
{ {
@@ -587,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 {
tracing::info!("Saving stats on session stop..."); println!("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 {
tracing::error!("Failed to save stats: {}", e); eprintln!("Failed to save stats: {}", e);
} else { } else {
tracing::info!("Stats saved successfully on session stop"); println!("Stats saved successfully on session stop");
} }
}); });
@@ -636,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) {
tracing::error!("Error processing line: {}", e); eprintln!("Error processing line: {}", e);
} }
} }
Err(e) => { Err(e) => {
tracing::error!("Error reading stdout: {}", e); eprintln!("Error reading stdout: {}", e);
break; break;
} }
_ => {} _ => {}
@@ -663,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) {
tracing::debug!("Parsed SubagentStart hook: agent_id={}, parent={:?}", eprintln!("[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
@@ -815,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(|| {
tracing::warn!("No model info available for cost calculation, using default"); println!("[WARNING] No model info available for cost calculation, using default");
"claude-sonnet-4-5-20250929".to_string() "claude-sonnet-4-5-20250929".to_string()
}); });
@@ -828,7 +828,7 @@ fn process_json_line(
usage.cache_read_input_tokens, usage.cache_read_input_tokens,
); );
tracing::info!("Assistant message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}, cost: ${:.4}", println!("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,
@@ -917,8 +917,8 @@ fn process_json_line(
.unwrap_or_default() .unwrap_or_default()
.as_millis() as u64; .as_millis() as u64;
tracing::debug!( eprintln!(
"Emitting agent-start: id={}, desc={}, type={}, parent={:?}", "[DEBUG] Emitting agent-start: id={}, desc={}, type={}, parent={:?}",
id, description, subagent_type, parent_tool_use_id id, description, subagent_type, parent_tool_use_id
); );
@@ -1063,13 +1063,6 @@ 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 {
@@ -1085,18 +1078,12 @@ fn process_json_line(
tools 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 {
tracing::info!("Turn completed in {}ms", duration); println!("Turn completed in {}ms", duration);
} }
if let Some(turns) = num_turns { if let Some(turns) = num_turns {
tracing::info!("Turn count: {}", turns); println!("Turn count: {}", turns);
} }
// Track token usage from Result messages if available // Track token usage from Result messages if available
@@ -1126,7 +1113,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,
); );
tracing::info!("Result message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}", println!("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,
@@ -1156,9 +1143,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();
tracing::info!("Checking achievements after result message..."); println!("Checking achievements after result message...");
let unlocked = stats_guard.check_achievements(); let unlocked = stats_guard.check_achievements();
tracing::info!("Newly unlocked achievements: {:?}", unlocked); println!("Newly unlocked achievements: {:?}", unlocked);
unlocked unlocked
}; };
@@ -1173,20 +1160,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() {
tracing::info!("Saving newly unlocked achievements: {:?}", newly_unlocked); println!("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 {
tracing::info!("Spawned save task for achievements"); println!("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
{ {
tracing::error!("Failed to save achievements: {}", e); eprintln!("Failed to save achievements: {}", e);
} else { } else {
tracing::info!("Achievement save task completed successfully"); println!("Achievement save task completed successfully");
} }
}); });
} }
@@ -1203,9 +1190,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 {
tracing::info!("Periodic stats save (every 10 messages)..."); println!("Periodic stats save (every 10 messages)...");
if let Err(e) = crate::stats::save_stats(&app_handle, &current_stats).await { if let Err(e) = crate::stats::save_stats(&app_handle, &current_stats).await {
tracing::error!("Failed to save stats: {}", e); eprintln!("Failed to save stats: {}", e);
} }
}); });
} }
@@ -1231,6 +1218,12 @@ fn process_json_line(
if let Some(denials) = permission_denials { if let Some(denials) = permission_denials {
// Only process if there are actually denials // Only process if there are actually denials
if !denials.is_empty() { if !denials.is_empty() {
// Skip permission prompts if the result was successful - tools were already approved
if subtype == "success" {
emit_state_change(app, state, None, conversation_id.clone());
return Ok(());
}
let mut regular_permission_requests = Vec::new(); let mut regular_permission_requests = Vec::new();
// Get denied tool IDs for later comparison // Get denied tool IDs for later comparison
@@ -1246,18 +1239,8 @@ fn process_json_line(
for denial in denials { for denial in denials {
// Skip system tools that should never require permission // Skip system tools that should never require permission
if is_system_tool(&denial.tool_name) { if is_system_tool(&denial.tool_name) {
tracing::debug!(
"Skipping system tool: {} (id: {})",
denial.tool_name,
denial.tool_use_id
);
continue; continue;
} }
tracing::debug!(
"Processing permission denial for: {} (id: {})",
denial.tool_name,
denial.tool_use_id
);
// Special handling for AskUserQuestion tool // Special handling for AskUserQuestion tool
if denial.tool_name == "AskUserQuestion" { if denial.tool_name == "AskUserQuestion" {
@@ -1351,18 +1334,6 @@ fn process_json_line(
// Emit all regular permission requests as a single batched event // Emit all regular permission requests as a single batched event
if !regular_permission_requests.is_empty() { if !regular_permission_requests.is_empty() {
tracing::info!(
"Emitting permission event for {} tool(s) in conversation {:?}",
regular_permission_requests.len(),
conversation_id
);
for req in &regular_permission_requests {
tracing::debug!(
"Permission requested: {} (id: {})",
req.tool_name,
req.id
);
}
let _ = app.emit( let _ = app.emit(
"claude:permission", "claude:permission",
PermissionPromptEvent { PermissionPromptEvent {
@@ -1440,7 +1411,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();
tracing::info!("User sent message, checking achievements..."); println!("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(
@@ -1457,7 +1428,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 {
tracing::info!("User message unlocked achievement: {:?}", achievement_id); println!("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",
@@ -1467,7 +1438,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() {
tracing::info!("Saving newly unlocked achievements from user message"); println!("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 {
@@ -1475,9 +1446,9 @@ fn process_json_line(
crate::achievements::save_achievements(&app_handle, &achievements_progress) crate::achievements::save_achievements(&app_handle, &achievements_progress)
.await .await
{ {
tracing::error!("Failed to save achievements: {}", e); eprintln!("Failed to save achievements: {}", e);
} else { } else {
tracing::info!("Achievements saved after user message"); println!("Achievements saved after user message");
} }
}); });
} }
+4 -4
View File
@@ -48,15 +48,15 @@ $notifier.Show($toast)
match output { match output {
Ok(result) => { Ok(result) => {
if result.status.success() { if result.status.success() {
tracing::info!("WSL notification sent successfully"); println!("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);
tracing::error!("PowerShell toast failed: {}", stderr); println!("PowerShell toast failed: {}", stderr);
} }
} }
Err(e) => { Err(e) => {
tracing::error!("Failed to run PowerShell: {}", e); println!("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() {
tracing::info!("Notification sent via wsl-notify-send"); println!("Notification sent via wsl-notify-send");
return Ok(()); return Ok(());
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop", "productName": "hikari-desktop",
"version": "1.4.0", "version": "1.3.0",
"identifier": "com.naomi.hikari-desktop", "identifier": "com.naomi.hikari-desktop",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
+1 -1
View File
@@ -182,7 +182,7 @@ Please continue where we left off and retry those actions now that you have perm
{#if permissions.length > 0} {#if permissions.length > 0}
<div <div
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-[60] backdrop-blur-sm" class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
> >
<div <div
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" 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"
-326
View File
@@ -14,7 +14,6 @@ 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", () => ({
@@ -488,329 +487,4 @@ 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);
});
});
}); });
+21 -18
View File
@@ -92,14 +92,6 @@ 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 {
@@ -127,7 +119,8 @@ function createConfigStore() {
} }
async function updateConfig(updates: Partial<HikariConfig>) { async function updateConfig(updates: Partial<HikariConfig>) {
const currentConfig = getCurrentConfig(); let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const newConfig = { ...currentConfig, ...updates }; const newConfig = { ...currentConfig, ...updates };
await saveConfig(newConfig); await saveConfig(newConfig);
} }
@@ -152,13 +145,15 @@ function createConfigStore() {
updates.custom_theme_colors = customColors; updates.custom_theme_colors = customColors;
} }
await updateConfig(updates); await updateConfig(updates);
const currentConfig = getCurrentConfig(); let currentConfig: HikariConfig = defaultConfig;
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 });
const currentConfig = getCurrentConfig(); let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
if (currentConfig.theme === "custom") { if (currentConfig.theme === "custom") {
applyCustomThemeColors(colors); applyCustomThemeColors(colors);
} }
@@ -171,14 +166,16 @@ function createConfigStore() {
}, },
increaseFontSize: async () => { increaseFontSize: async () => {
const currentConfig = getCurrentConfig(); let currentConfig: HikariConfig = defaultConfig;
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 () => {
const currentConfig = getCurrentConfig(); let currentConfig: HikariConfig = defaultConfig;
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);
@@ -190,7 +187,8 @@ function createConfigStore() {
}, },
addAutoGrantedTool: async (tool: string) => { addAutoGrantedTool: async (tool: string) => {
const currentConfig = getCurrentConfig(); let currentConfig: HikariConfig = defaultConfig;
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,22 +196,27 @@ function createConfigStore() {
}, },
removeAutoGrantedTool: async (tool: string) => { removeAutoGrantedTool: async (tool: string) => {
const currentConfig = getCurrentConfig(); let currentConfig: HikariConfig = defaultConfig;
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 => {
return getCurrentConfig(); let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
return currentConfig;
}, },
toggleStreamerMode: async () => { toggleStreamerMode: async () => {
const currentConfig = getCurrentConfig(); let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
await updateConfig({ streamer_mode: !currentConfig.streamer_mode }); await updateConfig({ streamer_mode: !currentConfig.streamer_mode });
}, },
toggleCompactMode: async () => { toggleCompactMode: async () => {
const currentConfig = getCurrentConfig(); let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
await updateConfig({ compact_mode: !currentConfig.compact_mode }); await updateConfig({ compact_mode: !currentConfig.compact_mode });
}, },
-14
View File
@@ -87,20 +87,6 @@ function createDebugConsoleStore() {
originalConsole.debug(...args); originalConsole.debug(...args);
addLog("debug", args.map((arg) => String(arg)).join(" "), "frontend"); 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() { function restoreConsole() {
-67
View File
@@ -285,73 +285,6 @@ 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", () => {
-6
View File
@@ -328,15 +328,9 @@ 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 { permissions, conversation_id } = event.payload; const { permissions, conversation_id } = event.payload;
console.log(
`[Permission] Event received: ${permissions.length} permission(s) for conversation ${conversation_id || "active"}`,
{ permissions, conversation_id }
);
// Store each permission request for the specific conversation // Store each permission request for the specific conversation
for (const permission of permissions) { for (const permission of permissions) {
const { id, tool_name, tool_input, description } = permission; const { id, tool_name, tool_input, description } = permission;
-175
View File
@@ -135,151 +135,6 @@ 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", () => {
@@ -337,36 +192,6 @@ 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", () => {