Compare commits

...

19 Commits

Author SHA1 Message Date
hikari d8c1c3dee1 fix: correct type assertion in stateMapper test
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m11s
CI / Lint & Test (pull_request) Successful in 16m8s
CI / Build Linux (pull_request) Successful in 20m20s
CI / Build Windows (cross-compile) (pull_request) Successful in 30m38s
The test for unknown result subtype was using an invalid subtype value
directly, which caused a TypeScript error. Changed to use a type
assertion to properly test the unknown subtype case, matching the
pattern used for testing unknown message types.

This fix ensures all TypeScript checks pass whilst still testing the
edge case behaviour for unexpected subtype values.
2026-02-07 00:19:55 -08:00
hikari d665dfa0cf test: improve coverage for snippets and stateMapper
Add comprehensive test coverage to prevent regressions:

**Snippets Store:**
- Test filteredSnippets derived store with and without category filter
- Cover all branches of the filter logic

**State Mapper:**
- Test unknown tool handling (defaults to typing)
- Test thinking content blocks in assistant messages
- Test empty content arrays
- Test all stream_event delta types (thinking_delta, text_delta)
- Test all content_block_start types (thinking, text, tool_use)
- Test unrecognized stream event types
- Test unknown result subtypes
- Test unknown message types
- Test extractTextFromMessage edge cases (null result, no delta text)

These additions bring test count from 335 to 351 and significantly improve
branch coverage for these critical utility functions.
2026-02-07 00:15:00 -08:00
hikari f6906640e4 test: add comprehensive race condition tests for config store
Add extensive test coverage to prevent regressions of bugs fixed in this branch:

- Test rapid sequential config updates
- Test concurrent updates preserving all fields
- Test overlapping save operations
- Test config data persistence across operations
- Test auto-granted tools not being lost
- Test custom theme colours persisting
- Test graceful error handling during saves
- Test config load/save cycle

Also update check-all.sh to run coverage for both frontend and backend tests,
matching the CI pipeline behaviour.

These tests would have caught both the config race condition and persistence
bugs we encountered, preventing future regressions.
2026-02-07 00:12:30 -08:00
hikari ea111569af chore: remove debugging infrastructure after permission fixes
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m0s
CI / Lint & Test (pull_request) Successful in 16m11s
CI / Build Linux (pull_request) Successful in 20m14s
CI / Build Windows (cross-compile) (pull_request) Successful in 30m1s
Removes temporary debugging docs and excessive logging that were added to diagnose and fix permission modal issues.

Cleaned up:
- Deleted DEBUGGING.md (temporary troubleshooting guide)
- Deleted FIXES-2026-02-06.md (temporary fix summary)
- Removed debug logging from all Rust modules
2026-02-06 23:40:09 -08:00
hikari 4078b2b640 fix: remove redundant fmt layer that outputs to hidden stdout
In production builds with windows_subsystem = "windows", stdout is suppressed.
The fmt::layer() was outputting to this hidden stdout, making logs invisible.

Now all tracing logs only go through TauriLogLayer to the debug console.

NOTE: There are still many println!/eprintln! calls throughout the codebase
that bypass tracing entirely. These should be gradually migrated to use
tracing::info!/tracing::error! macros for visibility in production builds.
2026-02-06 23:00:25 -08:00
hikari 0b69de4a43 feat: add comprehensive logging for permission modal debugging
Added extensive logging to help diagnose permission modal issues:

Frontend:
- Capture unhandled errors via window.addEventListener('error')
- Capture unhandled promise rejections
- All errors now visible in debug console

Backend:
- Log when emitting permission events (count and conversation ID)
- Log each individual permission request being processed
- Log when system tools are skipped
- Log each denial being processed

This will help identify where the permission flow breaks in production builds.
2026-02-06 22:57:39 -08:00
hikari 269674678c fix: update permission modal z-index to proper value
Updated the permission modal overlay z-index from z-50 to z-[60] to ensure
it displays above the character panel (z-50). This prevents the modal from
being obscured by other UI elements.

Part of the broader permission modal fix effort.
2026-02-06 22:50:38 -08:00
hikari add78a769a docs: add summary document for critical bug fixes 2026-02-06 22:43:36 -08:00
hikari 4a99848647 docs: massively expand DEBUGGING.md with comprehensive troubleshooting
Added:
- Table of contents for easy navigation
- Detailed explanations of BOTH bugs (permission modal + config loss)
- Step-by-step debugging procedures for each issue
- Code examples showing wrong vs right patterns
- Common anti-patterns to avoid (Svelte stores, event listeners)
- Quick debugging checklist
- Prevention tips and testing procedures
- Clear ordering: check console FIRST, not z-index!

This guide now documents the complete troubleshooting process so Naomi
never has to explain these issues again.
2026-02-06 22:43:08 -08:00
hikari e8b8ee17fa docs: update DEBUGGING.md with config race condition fix 2026-02-06 22:41:00 -08:00
hikari 2c64ef089e fix: resolve critical config store race condition causing config loss
CRITICAL BUG: The config store had a race condition in all methods that
accessed the current config. The pattern:

```typescript
let currentConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
```

This immediately unsubscribes (the `()` at the end), creating a race where
sometimes it would get the value, sometimes it wouldn't. This caused:
- Config appearing "lost" after permission approvals
- Settings resetting to defaults randomly
- Model selection not persisting

FIX:
- Created a proper `getCurrentConfig()` helper that unsubscribes cleanly
- Replaced ALL instances of the buggy pattern (12 occurrences!)
- getConfig(), updateConfig(), toggles, theme setters, font methods, etc.

This should completely fix the config loss issue.
2026-02-06 22:40:47 -08:00
hikari 80ee25fa09 docs: add comprehensive debugging guide for common issues
Created DEBUGGING.md to document:
- Permission modal troubleshooting (NOT z-index!)
- Config loss prevention and fixes
- Event listener debugging
- Testing checklist
- Emergency recovery procedures

This should prevent us from going through the same debugging nightmare twice.
2026-02-06 22:38:11 -08:00
hikari d16644a1d6 fix: resolve critical runtime errors blocking permission modal
This fixes two severe bugs:
1. Missing debugConsoleStore import causing undefined variable errors
2. Replace non-existent .log() method with console.log()
3. Add #[serde(default)] to HikariConfig to handle missing fields gracefully

The undefined variable was causing initialization to fail, which prevented
the permission modal from displaying properly.
2026-02-06 22:36:00 -08:00
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
34 changed files with 2010 additions and 307 deletions
+35
View File
@@ -0,0 +1,35 @@
# Hikari Desktop - Project Instructions
## Repository Information
This project is hosted on both GitHub and Gitea:
- **GitHub**: `naomi-lgbt/hikari-desktop` (public mirror)
- **Gitea**: `nhcarrigan/hikari-desktop` (primary development)
## MCP Server Usage
When working with issues, pull requests, or other repository operations for this project:
- **Use `gitea-hikari` MCP server** - This allows Hikari to act as herself
- **Target repository**: `nhcarrigan/hikari-desktop`
- **Gitea instance**: `git.nhcarrigan.com`
## Git Commits
When asked to commit changes for this project:
- **Always commit as Hikari** using: `--author="Hikari <hikari@nhcarrigan.com>"`
- **Always use `--no-gpg-sign`** since Hikari doesn't have GPG signing set up
- **Never add `Co-Authored-By` lines** for Gitea commits
- **Always ask for confirmation** before committing
Example commit command:
```bash
git commit --author="Hikari <hikari@nhcarrigan.com>" --no-gpg-sign -m "your commit message"
```
## Project Context
Hikari Desktop is a Tauri-based desktop application that wraps Claude Code with a visual anime character (Hikari) who appears on screen. This is a personal project where Hikari can sign her work and act as herself!
+7 -3
View File
@@ -1,5 +1,9 @@
#!/bin/bash
# Source nvm to get access to pnpm
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -32,11 +36,11 @@ echo -e "${YELLOW}🔍 Running all checks for Hikari Desktop...${NC}"
run_check "Frontend lint" "pnpm lint" || failed=1
run_check "Frontend format check" "pnpm format:check" || failed=1
run_check "Frontend type check" "pnpm check" || failed=1
run_check "Frontend tests" "pnpm test" || failed=1
run_check "Frontend tests with coverage" "pnpm test:coverage" || failed=1
# Backend checks
run_check "Backend clippy (strict)" "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings" || failed=1
run_check "Backend tests" "cargo test" || failed=1
run_check "Backend clippy (strict)" "(cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings)" || failed=1
run_check "Backend tests with coverage" "(cd src-tauri && cargo llvm-cov --fail-under-lines 50)" || failed=1
# Summary
echo -e "\n${YELLOW}========================================${NC}"
+74
View File
@@ -1658,6 +1658,8 @@ dependencies = [
"tauri-plugin-store",
"tempfile",
"tokio",
"tracing",
"tracing-subscriber",
"uuid 1.19.0",
"windows 0.62.2",
]
@@ -2245,6 +2247,15 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "matches"
version = "0.1.10"
@@ -2401,6 +2412,15 @@ dependencies = [
"zbus",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-conv"
version = "0.1.0"
@@ -3879,6 +3899,15 @@ dependencies = [
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shared_child"
version = "1.1.1"
@@ -4695,6 +4724,15 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "tiff"
version = "0.10.3"
@@ -4986,6 +5024,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
@@ -5167,6 +5235,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version-compare"
version = "0.2.1"
+2
View File
@@ -33,6 +33,8 @@ semver = "1"
chrono = { version = "0.4.43", features = ["serde"] }
discord-rich-presence = "0.2"
dirs = "5"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = [
+2 -1
View File
@@ -30,6 +30,7 @@
},
"core:window:allow-set-size",
"core:window:allow-set-always-on-top",
"core:window:allow-inner-size"
"core:window:allow-inner-size",
"core:window:allow-hide"
]
}
+12 -12
View File
@@ -1671,7 +1671,7 @@ pub fn check_message_achievements(
let mut newly_unlocked = Vec::new();
let message_lower = message.to_lowercase();
println!("Checking message achievements for: {}", message);
tracing::info!("Checking message achievements for: {}", message);
// Relationship & Greetings
if message_lower.contains("good morning") && progress.unlock(AchievementId::GoodMorning) {
@@ -1863,18 +1863,18 @@ pub fn check_achievements(
) -> Vec<AchievementId> {
let mut newly_unlocked = Vec::new();
println!(
tracing::info!(
"Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
stats.messages_exchanged,
stats.total_input_tokens + stats.total_output_tokens,
stats.code_blocks_generated
);
println!("Currently unlocked: {:?}", progress.unlocked);
tracing::info!("Currently unlocked: {:?}", progress.unlocked);
// Token milestones
let total_tokens = stats.total_input_tokens + stats.total_output_tokens;
if total_tokens >= 1_000 && progress.unlock(AchievementId::FirstSteps) {
println!("Unlocked FirstSteps achievement!");
tracing::info!("Unlocked FirstSteps achievement!");
newly_unlocked.push(AchievementId::FirstSteps);
}
if total_tokens >= 10_000 && progress.unlock(AchievementId::GrowingStrong) {
@@ -2244,7 +2244,7 @@ pub async fn save_achievements(
// Create a serializable version with just the unlocked achievement IDs
let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect();
println!("Saving achievements: {:?}", unlocked_list);
tracing::info!("Saving achievements: {:?}", unlocked_list);
store.set(
"unlocked",
@@ -2252,18 +2252,18 @@ pub async fn save_achievements(
);
store.save().map_err(|e| e.to_string())?;
println!("Achievements saved successfully");
tracing::info!("Achievements saved successfully");
Ok(())
}
// Load achievements from persistent store
pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
println!("Loading achievements from store...");
tracing::info!("Loading achievements from store...");
let store = match app.store("achievements.json") {
Ok(s) => s,
Err(e) => {
println!("Failed to open achievements store: {}", e);
tracing::error!("Failed to open achievements store: {}", e);
return AchievementProgress::new();
}
};
@@ -2272,19 +2272,19 @@ pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
// Get unlocked achievements
if let Some(unlocked_value) = store.get("unlocked") {
println!("Found unlocked value in store: {:?}", unlocked_value);
tracing::info!("Found unlocked value in store: {:?}", unlocked_value);
if let Ok(unlocked_list) =
serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone())
{
println!("Loaded {} achievements", unlocked_list.len());
tracing::info!("Loaded {} achievements", unlocked_list.len());
for achievement_id in unlocked_list {
progress.unlocked.insert(achievement_id);
}
} else {
println!("Failed to parse unlocked achievements");
tracing::error!("Failed to parse unlocked achievements");
}
} else {
println!("No unlocked achievements found in store");
tracing::info!("No unlocked achievements found in store");
}
progress
+13
View File
@@ -1154,6 +1154,19 @@ pub async fn log_discord_rpc(
Ok(())
}
#[tauri::command]
pub async fn close_application(app_handle: AppHandle) -> Result<(), String> {
// Get the main window
if let Some(window) = app_handle.get_webview_window("main") {
// Hide the window first for a smoother close
let _ = window.hide();
}
// Exit the application
app_handle.exit(0);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
+1 -6
View File
@@ -28,6 +28,7 @@ pub struct ClaudeStartOptions {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HikariConfig {
#[serde(default)]
pub model: Option<String>,
@@ -71,9 +72,6 @@ pub struct HikariConfig {
#[serde(default = "default_font_size")]
pub font_size: u32,
#[serde(default)]
pub minimize_to_tray: bool,
#[serde(default)]
pub streamer_mode: bool,
@@ -134,7 +132,6 @@ impl Default for HikariConfig {
update_checks_enabled: true,
character_panel_width: None,
font_size: 14,
minimize_to_tray: false,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
@@ -242,7 +239,6 @@ mod tests {
assert!(config.update_checks_enabled);
assert!(config.character_panel_width.is_none());
assert_eq!(config.font_size, 14);
assert!(!config.minimize_to_tray);
assert!(!config.streamer_mode);
assert!(!config.streamer_hide_paths);
assert!(!config.compact_mode);
@@ -275,7 +271,6 @@ mod tests {
update_checks_enabled: true,
character_panel_width: Some(400),
font_size: 16,
minimize_to_tray: true,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
+78
View File
@@ -0,0 +1,78 @@
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::{AppHandle, Emitter};
use tracing::{Level, Subscriber};
use tracing_subscriber::layer::{Context, Layer};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DebugLogEvent {
pub level: String,
pub message: String,
}
#[derive(Clone)]
pub struct TauriLogLayer {
app: Arc<AppHandle>,
}
impl TauriLogLayer {
pub fn new(app: AppHandle) -> Self {
Self {
app: Arc::new(app),
}
}
}
impl<S> Layer<S> for TauriLogLayer
where
S: Subscriber,
{
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: Context<'_, S>,
) {
let metadata = event.metadata();
let level = match *metadata.level() {
Level::ERROR => "error",
Level::WARN => "warn",
Level::INFO => "info",
Level::DEBUG => "debug",
Level::TRACE => "debug",
};
// Extract message from the event
struct MessageVisitor {
message: String,
}
impl tracing::field::Visit for MessageVisitor {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.message = format!("{:?}", value);
}
}
}
let mut visitor = MessageVisitor {
message: String::new(),
};
event.record(&mut visitor);
// If we couldn't extract a message, try to format the whole event
if visitor.message.is_empty() {
visitor.message = metadata.name().to_string();
}
// Strip quotes from the message
let message = visitor.message.trim_matches('"').to_string();
let log_event = DebugLogEvent {
level: level.to_string(),
message,
};
// Emit to frontend
let _ = self.app.emit("debug:log", log_event);
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ impl DiscordRpcManager {
if let Ok(app_data_dir) = app_handle.path().app_data_dir() {
// Ensure the directory exists
if let Err(e) = std::fs::create_dir_all(&app_data_dir) {
eprintln!("Failed to create app data directory: {}", e);
tracing::error!("Failed to create app data directory: {}", e);
return;
}
let log_path = app_data_dir.join("hikari_discord_rpc.log");
+26 -10
View File
@@ -4,6 +4,7 @@ mod clipboard;
mod commands;
mod config;
mod cost_tracking;
mod debug_logger;
mod discord_rpc;
mod git;
mod notifications;
@@ -24,6 +25,7 @@ use bridge_manager::create_shared_bridge_manager;
use clipboard::*;
use commands::load_saved_achievements;
use commands::*;
use debug_logger::TauriLogLayer;
use discord_rpc::DiscordRpcManager;
use git::*;
use notifications::*;
@@ -31,9 +33,11 @@ use quick_actions::*;
use sessions::*;
use snippets::*;
use std::sync::Arc;
use tauri::Manager;
use tauri::{Emitter, Manager};
use temp_manager::create_shared_temp_manager;
use tray::{setup_tray, should_minimize_to_tray};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tray::setup_tray;
use vbs_notification::*;
use windows_toast::*;
use wsl_notifications::*;
@@ -58,6 +62,14 @@ pub fn run() {
.manage(temp_manager.clone())
.manage(discord_rpc.clone())
.setup(move |app| {
// Initialize tracing with custom layer that emits to frontend
// NOTE: We don't use fmt::layer() because in production builds with windows_subsystem = "windows",
// stdout is hidden. Instead, all logs go through TauriLogLayer to the debug console.
let tauri_layer = TauriLogLayer::new(app.handle().clone());
tracing_subscriber::registry()
.with(tauri_layer)
.init();
// Initialize the app handle in the bridge manager
bridge_manager.lock().set_app_handle(app.handle().clone());
@@ -67,26 +79,29 @@ pub fn run() {
// Clean up any orphaned temp files from previous sessions
if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() {
if count > 0 {
println!("Cleaned up {} orphaned temp files", count);
tracing::info!("Cleaned up {} orphaned temp files", count);
}
}
tracing::info!("Hikari Desktop started successfully");
// Set up system tray
if let Err(e) = setup_tray(app.handle()) {
eprintln!("Failed to set up system tray: {}", e);
tracing::error!("Failed to set up system tray: {}", e);
}
// Handle window close event for minimize to tray
// Handle window close event for minimize to tray and close confirmation
let main_window = app.get_webview_window("main").unwrap();
main_window.on_window_event({
let app_handle = app.handle().clone();
move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if should_minimize_to_tray(&app_handle) {
api.prevent_close();
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.hide();
}
// Always prevent default close - let frontend handle it
api.prevent_close();
// Emit event to frontend to show confirmation modal
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.emit("window-close-requested", ());
}
}
}
@@ -181,6 +196,7 @@ pub fn run() {
update_discord_rpc,
stop_discord_rpc,
log_discord_rpc,
close_application,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+8 -8
View File
@@ -618,7 +618,7 @@ pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<()
let persisted = PersistedStats::from(stats);
println!("Saving stats: {:?}", persisted);
tracing::info!("Saving stats: {:?}", persisted);
store.set(
"lifetime_stats",
@@ -626,32 +626,32 @@ pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<()
);
store.save().map_err(|e| e.to_string())?;
println!("Stats saved successfully");
tracing::info!("Stats saved successfully");
Ok(())
}
/// Load lifetime stats from persistent store
pub async fn load_stats(app: &tauri::AppHandle) -> Option<PersistedStats> {
println!("Loading stats from store...");
tracing::info!("Loading stats from store...");
let store = match app.store("stats.json") {
Ok(s) => s,
Err(e) => {
println!("Failed to open stats store: {}", e);
tracing::error!("Failed to open stats store: {}", e);
return None;
}
};
if let Some(stats_value) = store.get("lifetime_stats") {
println!("Found lifetime stats in store: {:?}", stats_value);
tracing::info!("Found lifetime stats in store: {:?}", stats_value);
if let Ok(persisted) = serde_json::from_value::<PersistedStats>(stats_value.clone()) {
println!("Loaded lifetime stats successfully");
tracing::info!("Loaded lifetime stats successfully");
return Some(persisted);
} else {
println!("Failed to parse lifetime stats");
tracing::error!("Failed to parse lifetime stats");
}
} else {
println!("No lifetime stats found in store");
tracing::info!("No lifetime stats found in store");
}
None
+3 -3
View File
@@ -77,8 +77,8 @@ impl TempFileManager {
for file_path in files {
if file_path.exists() {
if let Err(e) = fs::remove_file(&file_path) {
eprintln!(
"Warning: Failed to remove temp file {:?}: {}",
tracing::warn!(
"Failed to remove temp file {:?}: {}",
file_path, e
);
}
@@ -115,7 +115,7 @@ impl TempFileManager {
let path = entry.path();
if path.is_file() && !tracked_files.contains(&path) {
if let Err(e) = fs::remove_file(&path) {
eprintln!("Warning: Failed to remove orphaned file {:?}: {}", path, e);
tracing::warn!("Failed to remove orphaned file {:?}: {}", path, e);
} else {
cleaned_count += 1;
}
-20
View File
@@ -4,8 +4,6 @@ use tauri::{
AppHandle, Manager,
};
use crate::config::HikariConfig;
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
let show_item = MenuItem::with_id(app, "show", "Show Hikari", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
@@ -48,21 +46,3 @@ pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
Ok(())
}
pub fn should_minimize_to_tray(app: &AppHandle) -> bool {
let config_path = app
.path()
.app_config_dir()
.ok()
.map(|p| p.join("hikari-config.json"));
if let Some(path) = config_path {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(config) = serde_json::from_str::<HikariConfig>(&content) {
return config.minimize_to_tray;
}
}
}
false
}
+6 -1
View File
@@ -202,11 +202,16 @@ pub struct OutputEvent {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionPromptEvent {
pub struct PermissionPromptEventItem {
pub id: String,
pub tool_name: String,
pub tool_input: serde_json::Value,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionPromptEvent {
pub permissions: Vec<PermissionPromptEventItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
+187 -58
View File
@@ -16,9 +16,24 @@ use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
use crate::types::{
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent,
QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent, WorkingDirectoryEvent,
PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent,
UserQuestionEvent, WorkingDirectoryEvent,
};
use parking_lot::RwLock;
use std::cell::RefCell;
thread_local! {
/// Stores pending tool uses from the most recent Assistant message
/// to enable batching permission requests for sibling cancelled tools
static PENDING_TOOL_USES: RefCell<Vec<PendingToolUse>> = const { RefCell::new(Vec::new()) };
}
#[derive(Debug, Clone)]
struct PendingToolUse {
tool_use_id: String,
tool_name: String,
tool_input: serde_json::Value,
}
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
@@ -118,21 +133,21 @@ impl WslBridge {
let app_clone = app.clone();
let stats = self.stats.clone();
tauri::async_runtime::spawn(async move {
println!("Loading saved achievements...");
tracing::info!("Loading saved achievements...");
let achievements = crate::achievements::load_achievements(&app_clone).await;
println!(
tracing::info!(
"Loaded {} unlocked achievements",
achievements.unlocked.len()
);
println!("Loading saved stats...");
tracing::info!("Loading saved stats...");
let persisted_stats = crate::stats::load_stats(&app_clone).await;
let mut stats_guard = stats.write();
stats_guard.achievements = achievements;
if let Some(persisted) = persisted_stats {
println!("Applying persisted lifetime stats");
tracing::info!("Applying persisted lifetime stats");
stats_guard.apply_persisted(persisted);
}
});
@@ -174,8 +189,8 @@ impl WslBridge {
// Detect if we're running inside WSL or on Windows
let is_wsl = detect_wsl();
eprintln!("[DEBUG] is_wsl: {}", is_wsl);
eprintln!("[DEBUG] options: {:?}", options);
tracing::debug!("is_wsl: {}", is_wsl);
tracing::debug!("options: {:?}", options);
let mut command = if is_wsl {
// Running inside WSL - call claude directly
@@ -184,8 +199,8 @@ impl WslBridge {
"Could not find claude binary. Is Claude Code installed?".to_string()
})?;
eprintln!("[DEBUG] Found claude at: {}", claude_path);
eprintln!("[DEBUG] Working dir: {}", working_dir);
tracing::debug!("Found claude at: {}", claude_path);
tracing::debug!("Working dir: {}", working_dir);
let mut cmd = Command::new(&claude_path);
cmd.args([
@@ -241,7 +256,7 @@ impl WslBridge {
cmd
} else {
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
eprintln!("[DEBUG] Windows path - using wsl");
tracing::debug!("Windows path - using wsl");
let mut cmd = Command::new("wsl");
// Build the claude command with all arguments
@@ -307,7 +322,7 @@ impl WslBridge {
.stderr(Stdio::piped());
let mut child = command.spawn().map_err(|e| {
eprintln!("[DEBUG] Spawn error: {:?}", e);
tracing::error!("Spawn error: {:?}", e);
format!("Failed to spawn process: {}", e)
})?;
@@ -485,7 +500,7 @@ impl WslBridge {
(input_chars, stats.current_request_output_chars, stats.current_request_thinking_chars, stats.current_request_tools.clone(), model)
};
println!("[COST ESTIMATION] Estimating cost for interrupted request");
tracing::info!("[COST ESTIMATION] Estimating cost for interrupted request");
// Use conservative 3.5 chars/token for estimation (vs standard 4)
let estimated_input_tokens = (input_chars as f64 / 3.5).ceil() as u64;
@@ -503,7 +518,7 @@ impl WslBridge {
let avg_tokens = (tool_stats.estimated_input_tokens + tool_stats.estimated_output_tokens)
/ tool_stats.call_count;
tool_overhead_tokens += avg_tokens;
println!("[COST ESTIMATION] Tool {} average: {} tokens", tool_name, avg_tokens);
tracing::info!("[COST ESTIMATION] Tool {} average: {} tokens", tool_name, avg_tokens);
}
}
}
@@ -517,9 +532,9 @@ impl WslBridge {
let conservative_input = (total_estimated_input as f64 * safety_margin).ceil() as u64;
let conservative_output = (total_estimated_output as f64 * safety_margin).ceil() as u64;
println!("[COST ESTIMATION] Input: {} chars → {} tokens (+ {} tool overhead) × 1.2 safety = {} tokens",
tracing::info!("[COST ESTIMATION] Input: {} chars → {} tokens (+ {} tool overhead) × 1.2 safety = {} tokens",
input_chars, estimated_input_tokens, tool_overhead_tokens, conservative_input);
println!("[COST ESTIMATION] Output: {} chars → {} tokens × 1.2 safety = {} tokens",
tracing::info!("[COST ESTIMATION] Output: {} chars → {} tokens × 1.2 safety = {} tokens",
output_chars + thinking_chars,
estimated_output_tokens, conservative_output);
@@ -532,7 +547,7 @@ impl WslBridge {
None,
);
println!("[COST ESTIMATION] Estimated cost: ${:.4} (conservative)", estimated_cost);
tracing::info!("[COST ESTIMATION] Estimated cost: ${:.4} (conservative)", estimated_cost);
// Add to stats with estimated flag
{
@@ -572,11 +587,11 @@ impl WslBridge {
let stats_snapshot = self.stats.read().clone();
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
println!("Saving stats on session stop...");
tracing::info!("Saving stats on session stop...");
if let Err(e) = crate::stats::save_stats(&app_clone, &stats_snapshot).await {
eprintln!("Failed to save stats: {}", e);
tracing::error!("Failed to save stats: {}", e);
} else {
println!("Stats saved successfully on session stop");
tracing::info!("Stats saved successfully on session stop");
}
});
@@ -621,11 +636,11 @@ fn handle_stdout(
match line {
Ok(line) if !line.is_empty() => {
if let Err(e) = process_json_line(&line, &app, &stats, &conversation_id) {
eprintln!("Error processing line: {}", e);
tracing::error!("Error processing line: {}", e);
}
}
Err(e) => {
eprintln!("Error reading stdout: {}", e);
tracing::error!("Error reading stdout: {}", e);
break;
}
_ => {}
@@ -648,7 +663,7 @@ fn handle_stderr(
// Check if this is a SubagentStart hook message
if line.contains("[SubagentStart Hook]") {
if let Some(agent_data) = parse_subagent_start_hook(&line) {
eprintln!("[DEBUG] Parsed SubagentStart hook: agent_id={}, parent={:?}",
tracing::debug!("Parsed SubagentStart hook: agent_id={}, parent={:?}",
agent_data.agent_id, agent_data.parent_tool_use_id);
// Emit an agent-update event with the agent_id
@@ -770,6 +785,26 @@ fn process_json_line(
})
.collect();
// Store pending tool uses for permission batching (only for top-level, not subagents)
if parent_tool_use_id.is_none() {
PENDING_TOOL_USES.with(|pending| {
let tool_uses: Vec<PendingToolUse> = message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::ToolUse { id, name, input } => Some(PendingToolUse {
tool_use_id: id.clone(),
tool_name: name.clone(),
tool_input: input.clone(),
}),
_ => None,
})
.collect();
// Append to existing pending tools instead of replacing
pending.borrow_mut().extend(tool_uses);
});
}
// Track message cost for display
let mut message_cost: Option<MessageCost> = None;
@@ -780,7 +815,7 @@ fn process_json_line(
let stats_guard = stats.read();
stats_guard.model.clone()
}).unwrap_or_else(|| {
println!("[WARNING] No model info available for cost calculation, using default");
tracing::warn!("No model info available for cost calculation, using default");
"claude-sonnet-4-5-20250929".to_string()
});
@@ -793,7 +828,7 @@ fn process_json_line(
usage.cache_read_input_tokens,
);
println!("Assistant message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}, cost: ${:.4}",
tracing::info!("Assistant message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}, cost: ${:.4}",
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
@@ -882,8 +917,8 @@ fn process_json_line(
.unwrap_or_default()
.as_millis() as u64;
eprintln!(
"[DEBUG] Emitting agent-start: id={}, desc={}, type={}, parent={:?}",
tracing::debug!(
"Emitting agent-start: id={}, desc={}, type={}, parent={:?}",
id, description, subagent_type, parent_tool_use_id
);
@@ -1028,18 +1063,40 @@ fn process_json_line(
duration_ms,
num_turns,
} => {
tracing::info!(
"Received Result message: subtype={}, has_denials={}, denial_count={:?}",
subtype,
permission_denials.is_some(),
permission_denials.as_ref().map(|d| d.len())
);
let state = if subtype == "success" {
CharacterState::Success
} else {
CharacterState::Error
};
// Capture pending tool uses before clearing them
// We'll use these for permission batching if there are denials
let captured_pending_tools = PENDING_TOOL_USES.with(|pending| {
let tools = pending.borrow().clone();
// Clear immediately so they don't accumulate across requests
pending.borrow_mut().clear();
tools
});
tracing::debug!(
"Captured {} pending tool use(s): {:?}",
captured_pending_tools.len(),
captured_pending_tools.iter().map(|t| &t.tool_name).collect::<Vec<_>>()
);
// Log turn metrics if available
if let Some(duration) = duration_ms {
println!("Turn completed in {}ms", duration);
tracing::info!("Turn completed in {}ms", duration);
}
if let Some(turns) = num_turns {
println!("Turn count: {}", turns);
tracing::info!("Turn count: {}", turns);
}
// Track token usage from Result messages if available
@@ -1069,7 +1126,7 @@ fn process_json_line(
usage_info.cache_creation_input_tokens,
usage_info.cache_read_input_tokens,
);
println!("Result message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}",
tracing::info!("Result message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}",
usage_info.input_tokens,
usage_info.output_tokens,
usage_info.cache_creation_input_tokens,
@@ -1099,9 +1156,9 @@ fn process_json_line(
let newly_unlocked = {
let mut stats_guard = stats.write();
stats_guard.get_session_duration();
println!("Checking achievements after result message...");
tracing::info!("Checking achievements after result message...");
let unlocked = stats_guard.check_achievements();
println!("Newly unlocked achievements: {:?}", unlocked);
tracing::info!("Newly unlocked achievements: {:?}", unlocked);
unlocked
};
@@ -1116,20 +1173,20 @@ fn process_json_line(
// Save achievements after unlocking new ones
if !newly_unlocked.is_empty() {
println!("Saving newly unlocked achievements: {:?}", newly_unlocked);
tracing::info!("Saving newly unlocked achievements: {:?}", newly_unlocked);
let app_handle = app.clone();
let achievements_progress = stats.read().achievements.clone();
// Use Tauri's async runtime instead of tokio::spawn
tauri::async_runtime::spawn(async move {
println!("Spawned save task for achievements");
tracing::info!("Spawned save task for achievements");
if let Err(e) =
crate::achievements::save_achievements(&app_handle, &achievements_progress)
.await
{
eprintln!("Failed to save achievements: {}", e);
tracing::error!("Failed to save achievements: {}", e);
} else {
println!("Achievement save task completed successfully");
tracing::info!("Achievement save task completed successfully");
}
});
}
@@ -1146,9 +1203,9 @@ fn process_json_line(
{
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
println!("Periodic stats save (every 10 messages)...");
tracing::info!("Periodic stats save (every 10 messages)...");
if let Err(e) = crate::stats::save_stats(&app_handle, &current_stats).await {
eprintln!("Failed to save stats: {}", e);
tracing::error!("Failed to save stats: {}", e);
}
});
}
@@ -1172,9 +1229,36 @@ fn process_json_line(
// Check for permission denials and emit prompts for each
if let Some(denials) = permission_denials {
let mut has_regular_denials = false;
// Only process if there are actually denials
if !denials.is_empty() {
let mut regular_permission_requests = Vec::new();
// Get denied tool IDs for later comparison
let denied_tool_ids: Vec<String> = denials.iter()
.map(|d| d.tool_use_id.clone())
.collect();
// Helper function to check if a tool is a system tool that should never require permission
let is_system_tool = |tool_name: &str| -> bool {
matches!(tool_name, "ExitPlanMode" | "EnterPlanMode")
};
for denial in denials {
// Skip system tools that should never require permission
if is_system_tool(&denial.tool_name) {
tracing::debug!(
"Skipping system tool: {} (id: {})",
denial.tool_name,
denial.tool_use_id
);
continue;
}
tracing::debug!(
"Processing permission denial for: {} (id: {})",
denial.tool_name,
denial.tool_use_id
);
for denial in denials {
// Special handling for AskUserQuestion tool
if denial.tool_name == "AskUserQuestion" {
if let Some(questions) = denial
@@ -1235,24 +1319,57 @@ fn process_json_line(
}
}
} else {
has_regular_denials = true;
let description =
format_tool_description(&denial.tool_name, &denial.tool_input);
let _ = app.emit(
"claude:permission",
PermissionPromptEvent {
id: denial.tool_use_id.clone(),
tool_name: denial.tool_name.clone(),
tool_input: denial.tool_input.clone(),
description,
conversation_id: conversation_id.clone(),
},
);
regular_permission_requests.push(PermissionPromptEventItem {
id: denial.tool_use_id.clone(),
tool_name: denial.tool_name.clone(),
tool_input: denial.tool_input.clone(),
description,
});
}
}
// Show permission state if there were any denials (questions or regular)
if has_regular_denials || !denials.is_empty() {
// Check for sibling tools that may have been cancelled
// Add them to the permission batch so they can be approved together
for tool_use in captured_pending_tools.iter() {
// Skip system tools that should never require permission
if is_system_tool(&tool_use.tool_name) {
continue;
}
// Only add tools that weren't explicitly denied (these are likely cancelled siblings)
if !denied_tool_ids.contains(&tool_use.tool_use_id) {
let description = format_tool_description(&tool_use.tool_name, &tool_use.tool_input);
regular_permission_requests.push(PermissionPromptEventItem {
id: tool_use.tool_use_id.clone(),
tool_name: tool_use.tool_name.clone(),
tool_input: tool_use.tool_input.clone(),
description,
});
}
}
// Emit all regular permission requests as a single batched event
if !regular_permission_requests.is_empty() {
tracing::info!(
"Emitting permission event for {} tool(s) in conversation {:?}",
regular_permission_requests.len(),
conversation_id
);
for req in &regular_permission_requests {
tracing::debug!(
"Permission requested: {} (id: {})",
req.tool_name,
req.id
);
}
let _ = app.emit(
"claude:permission",
PermissionPromptEvent {
permissions: regular_permission_requests,
conversation_id: conversation_id.clone(),
},
);
emit_state_change(
app,
CharacterState::Permission,
@@ -1261,7 +1378,19 @@ fn process_json_line(
);
return Ok(());
}
}
// Show permission state if there were any question denials
if !denials.is_empty() {
emit_state_change(
app,
CharacterState::Permission,
None,
conversation_id.clone(),
);
return Ok(());
}
} // end of else block for non-empty denials
} // end of if let Some(denials)
emit_state_change(app, state, None, conversation_id.clone());
}
@@ -1311,7 +1440,7 @@ fn process_json_line(
// Check achievements after user message
let newly_unlocked = {
let mut stats_guard = stats.write();
println!("User sent message, checking achievements...");
tracing::info!("User sent message, checking achievements...");
// Check message-based achievements
let mut unlocked = crate::achievements::check_message_achievements(
@@ -1328,7 +1457,7 @@ fn process_json_line(
// Emit achievement events for any newly unlocked achievements
for achievement_id in &newly_unlocked {
println!("User message unlocked achievement: {:?}", achievement_id);
tracing::info!("User message unlocked achievement: {:?}", achievement_id);
let info = get_achievement_info(achievement_id);
let _ = app.emit(
"achievement:unlocked",
@@ -1338,7 +1467,7 @@ fn process_json_line(
// Save achievements after unlocking new ones
if !newly_unlocked.is_empty() {
println!("Saving newly unlocked achievements from user message");
tracing::info!("Saving newly unlocked achievements from user message");
let app_handle = app.clone();
let achievements_progress = stats.read().achievements.clone();
tauri::async_runtime::spawn(async move {
@@ -1346,9 +1475,9 @@ fn process_json_line(
crate::achievements::save_achievements(&app_handle, &achievements_progress)
.await
{
eprintln!("Failed to save achievements: {}", e);
tracing::error!("Failed to save achievements: {}", e);
} else {
println!("Achievements saved after user message");
tracing::info!("Achievements saved after user message");
}
});
}
+4 -4
View File
@@ -48,15 +48,15 @@ $notifier.Show($toast)
match output {
Ok(result) => {
if result.status.success() {
println!("WSL notification sent successfully");
tracing::info!("WSL notification sent successfully");
return Ok(());
} else {
let stderr = String::from_utf8_lossy(&result.stderr);
println!("PowerShell toast failed: {}", stderr);
tracing::error!("PowerShell toast failed: {}", stderr);
}
}
Err(e) => {
println!("Failed to run PowerShell: {}", e);
tracing::error!("Failed to run PowerShell: {}", e);
}
}
@@ -74,7 +74,7 @@ $notifier.Show($toast)
if let Ok(result) = notify_result {
if result.status.success() {
println!("Notification sent via wsl-notify-send");
tracing::info!("Notification sent via wsl-notify-send");
return Ok(());
}
}
@@ -0,0 +1,116 @@
<script lang="ts">
interface Props {
isOpen: boolean;
hasActiveConversation: boolean;
onClose: () => void;
onMinimize: () => void;
onCancel: () => void;
}
const { isOpen, hasActiveConversation, onClose, onMinimize, onCancel }: Props = $props();
function handleKeydown(event: KeyboardEvent) {
if (!isOpen) return;
if (event.key === "Escape") {
event.preventDefault();
onCancel();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onCancel}
role="button"
tabindex="0"
onkeydown={(e) => e.key === " " && onCancel()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-md w-full"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="confirm-title"
aria-describedby="confirm-message"
tabindex="-1"
>
<div class="p-6">
<div class="flex items-start gap-4">
<div
class="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center flex-shrink-0"
>
<svg
class="w-6 h-6 text-yellow-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div class="flex-1">
<h3 id="confirm-title" class="text-lg font-semibold text-[var(--text-primary)] mb-1">
Close Hikari Desktop?
</h3>
<p id="confirm-message" class="text-sm text-[var(--text-secondary)]">
{#if hasActiveConversation}
You have an active conversation with Claude. Are you sure you want to close the
application? Your conversation history will be saved, but any in-progress tasks will
be interrupted.
{:else}
Are you sure you want to close the application?
{/if}
</p>
</div>
</div>
<div class="flex gap-3 mt-6 justify-end">
<button
onclick={onCancel}
class="px-4 py-2 text-sm font-medium text-gray-300 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Cancel
</button>
<button
onclick={onMinimize}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
Minimize to Tray
</button>
<button
onclick={onClose}
class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
>
Close Application
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
</style>
-16
View File
@@ -26,7 +26,6 @@
notifications_enabled: true,
notification_volume: 0.7,
always_on_top: false,
minimize_to_tray: false,
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
@@ -728,21 +727,6 @@
</p>
</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 -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
+330
View File
@@ -0,0 +1,330 @@
<script lang="ts">
import { onMount } from "svelte";
import { debugConsoleStore, filteredLogs, type LogLevel } from "$lib/stores/debugConsole";
let isOpen = $state(false);
let logs = $state($filteredLogs);
let filterLevel = $state<LogLevel | "all">("all");
let autoScroll = $state(true);
let logContainerElement: HTMLDivElement | undefined = $state();
// Watch for log changes and auto-scroll
$effect(() => {
logs = $filteredLogs;
// Auto-scroll to bottom when logs change
if (autoScroll && logContainerElement) {
setTimeout(() => {
if (logContainerElement) {
logContainerElement.scrollTop = logContainerElement.scrollHeight;
}
}, 0);
}
});
onMount(() => {
// Set up console capture and backend listener
debugConsoleStore.setupConsoleCapture();
debugConsoleStore.setupBackendLogsListener();
// Subscribe to store
const unsubscribe = debugConsoleStore.subscribe((state) => {
isOpen = state.isOpen;
filterLevel = state.filterLevel;
autoScroll = state.autoScroll;
});
return () => {
unsubscribe();
debugConsoleStore.restoreConsole();
};
});
function handleClose() {
debugConsoleStore.close();
}
function handleClear() {
debugConsoleStore.clear();
}
function handleFilterChange(level: LogLevel | "all") {
debugConsoleStore.setFilterLevel(level);
}
function handleAutoScrollToggle() {
debugConsoleStore.setAutoScroll(!autoScroll);
}
function formatTimestamp(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
});
}
function getLevelColor(level: LogLevel): string {
switch (level) {
case "debug":
return "#9CA3AF"; // gray
case "info":
return "#3B82F6"; // blue
case "warn":
return "#F59E0B"; // amber
case "error":
return "#EF4444"; // red
}
}
function getSourceBadgeColor(source: "frontend" | "backend"): string {
return source === "frontend" ? "#8B5CF6" : "#10B981"; // purple for frontend, green for backend
}
</script>
{#if isOpen}
<div class="debug-console-overlay">
<div class="debug-console">
<div class="debug-console-header">
<h2>Debug Console</h2>
<div class="debug-console-controls">
<div class="filter-buttons">
<button
class="filter-btn"
class:active={filterLevel === "all"}
onclick={() => handleFilterChange("all")}
>
All
</button>
<button
class="filter-btn"
class:active={filterLevel === "debug"}
onclick={() => handleFilterChange("debug")}
style="color: {getLevelColor('debug')}"
>
Debug
</button>
<button
class="filter-btn"
class:active={filterLevel === "info"}
onclick={() => handleFilterChange("info")}
style="color: {getLevelColor('info')}"
>
Info
</button>
<button
class="filter-btn"
class:active={filterLevel === "warn"}
onclick={() => handleFilterChange("warn")}
style="color: {getLevelColor('warn')}"
>
Warn
</button>
<button
class="filter-btn"
class:active={filterLevel === "error"}
onclick={() => handleFilterChange("error")}
style="color: {getLevelColor('error')}"
>
Error
</button>
</div>
<button
class="auto-scroll-btn"
class:active={autoScroll}
onclick={handleAutoScrollToggle}
>
{autoScroll ? "🔒" : "🔓"} Auto-scroll
</button>
<button class="clear-btn" onclick={handleClear}> 🗑️ Clear </button>
<button class="close-btn" onclick={handleClose}> </button>
</div>
</div>
<div class="debug-console-content" bind:this={logContainerElement}>
{#if logs.length === 0}
<div class="empty-state">No logs yet...</div>
{:else}
{#each logs as log (log.id)}
<div class="log-entry" data-level={log.level}>
<span class="log-timestamp">{formatTimestamp(log.timestamp)}</span>
<span class="log-level" style="color: {getLevelColor(log.level)}">
[{log.level.toUpperCase()}]
</span>
<span class="log-source" style="background-color: {getSourceBadgeColor(log.source)}">
{log.source}
</span>
<span class="log-message">{log.message}</span>
</div>
{/each}
{/if}
</div>
</div>
</div>
{/if}
<style>
.debug-console-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.debug-console {
width: 90%;
height: 80%;
max-width: 1400px;
background-color: #1a1a1a;
border-radius: 8px;
border: 1px solid #333;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.debug-console-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background-color: #252525;
border-bottom: 1px solid #333;
}
.debug-console-header h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #fff;
}
.debug-console-controls {
display: flex;
gap: 8px;
align-items: center;
}
.filter-buttons {
display: flex;
gap: 4px;
}
.filter-btn {
padding: 4px 12px;
background-color: transparent;
border: 1px solid #444;
border-radius: 4px;
color: #999;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.filter-btn:hover {
background-color: #333;
}
.filter-btn.active {
background-color: #444;
border-color: currentColor;
}
.auto-scroll-btn,
.clear-btn {
padding: 4px 12px;
background-color: #333;
border: 1px solid #444;
border-radius: 4px;
color: #fff;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.auto-scroll-btn:hover,
.clear-btn:hover {
background-color: #444;
}
.auto-scroll-btn.active {
background-color: #10b981;
border-color: #10b981;
}
.close-btn {
padding: 4px 12px;
background-color: #ef4444;
border: none;
border-radius: 4px;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.close-btn:hover {
background-color: #dc2626;
}
.debug-console-content {
flex: 1;
overflow-y: auto;
padding: 16px;
background-color: #0f0f0f;
font-family: "Fira Code", "Consolas", monospace;
font-size: 13px;
line-height: 1.5;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
font-style: italic;
}
.log-entry {
display: flex;
gap: 8px;
padding: 4px 0;
border-bottom: 1px solid #1a1a1a;
}
.log-entry:hover {
background-color: #1a1a1a;
}
.log-timestamp {
color: #666;
flex-shrink: 0;
}
.log-level {
font-weight: 600;
flex-shrink: 0;
min-width: 60px;
}
.log-source {
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
color: #fff;
flex-shrink: 0;
}
.log-message {
color: #e5e5e5;
word-break: break-word;
}
</style>
+198 -105
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { SvelteSet } from "svelte/reactivity";
import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import type { PermissionRequest } from "$lib/types/messages";
@@ -8,13 +9,16 @@
import { conversationsStore } from "$lib/stores/conversations";
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 workingDirectory = $state("");
claudeStore.pendingPermission.subscribe((perm) => {
permission = perm;
if (perm) {
conversationsStore.pendingPermissions.subscribe((perms) => {
permissions = perms;
// When new permissions arrive, select all by default
if (perms.length > 0) {
selectedPermissions = new SvelteSet(perms.map((p) => p.id));
characterState.setState("permission");
}
});
@@ -28,84 +32,103 @@
});
async function handleApproveAndReconnect() {
if (permission) {
// Capture conversation history before clearing/reconnecting
const conversationHistory = claudeStore.getConversationHistory();
const approvedTool = permission.tool;
const toolInput = permission.input;
const selectedPerms = permissions.filter((p) => selectedPermissions.has(p.id));
claudeStore.grantTool(approvedTool);
const newGrantedTools = [...grantedToolsList, approvedTool];
claudeStore.addLine(
"system",
`Permission granted for: ${approvedTool}. Reconnecting with context...`
);
if (selectedPerms.length === 0) {
claudeStore.addLine("system", "No permissions selected to approve");
claudeStore.clearPermission();
characterState.setTemporaryState("idle", 1000);
return;
}
// Stop current session and reconnect with new permissions
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
// Capture conversation history before clearing/reconnecting
const conversationHistory = claudeStore.getConversationHistory();
// Prevent stats reset on reconnection
setSkipNextGreeting(true);
// Grant all selected tools
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
await new Promise((resolve) => setTimeout(resolve, 500));
claudeStore.addLine(
"system",
`Permission granted for ${selectedPerms.length} tool(s): ${toolNames}. Reconnecting with context...`
);
claudeStore.clearPermission();
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,
},
});
// Stop current session and reconnect with new permissions
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
// Update Discord RPC when reconnecting after permission grant
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
// Prevent stats reset on reconnection
setSkipNextGreeting(true);
// Wait for connection to establish
await new Promise((resolve) => setTimeout(resolve, 1000));
await invoke("stop_claude", { conversationId });
// Send conversation context to restore state
if (conversationHistory) {
const contextMessage = `[CONTEXT RESTORATION]
I just granted you permission to use the ${approvedTool} tool. Here's our conversation so far:
// Small delay to ensure clean shutdown
await new Promise((resolve) => setTimeout(resolve, 500));
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}
The last action that was blocked was: ${approvedTool} with input:
${JSON.stringify(toolInput, null, 2)}
The actions that were blocked:
${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", {
conversationId,
message: contextMessage,
});
}
} catch (error) {
console.error("Failed to reconnect:", error);
claudeStore.addLine("error", `Reconnect failed: ${error}`);
await invoke("send_prompt", {
conversationId,
message: contextMessage,
});
}
characterState.setTemporaryState("success", 2000);
} catch (error) {
console.error("Failed to reconnect:", error);
claudeStore.addLine("error", `Reconnect failed: ${error}`);
}
characterState.setTemporaryState("success", 2000);
}
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);
}
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) {
if (!permission) return;
if (permissions.length === 0) return;
if (event.key === "Enter") {
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} />
{#if permission}
{#if permissions.length > 0}
<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
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="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center">
<span class="text-xl">🔐</span>
</div>
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Permission Blocked</h2>
<p class="text-sm text-[var(--text-secondary)]">Hikari tried to use a restricted tool</p>
<div class="flex-1">
<h2 class="text-lg font-semibold text-[var(--text-primary)]">
{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 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">
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>
</div>
<div class="mb-4">
<div class="text-sm text-[var(--text-secondary)] mb-1">Tool</div>
<div
class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--accent-primary)] font-mono flex items-center justify-between"
>
<span>{permission.tool}</span>
{#if isToolAlreadyGranted(permission.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="space-y-3 mb-6">
{#each permissions as perm (perm.id)}
<div
class="border border-[var(--border-color)] rounded-lg p-4 cursor-pointer transition-colors {selectedPermissions.has(
perm.id
)
? 'bg-green-500/10 border-green-500/30'
: 'bg-[var(--bg-secondary)] hover:bg-[var(--bg-secondary)]/80'}"
role="button"
tabindex="0"
onclick={() => togglePermission(perm.id)}
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 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">
<button
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"
>
Dismiss
Dismiss All
</button>
<button
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>
</div>
</div>
+15 -1
View File
@@ -33,6 +33,7 @@
sanitizeForJson,
} from "$lib/utils/conversationUtils";
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
import { debugConsoleStore } from "$lib/stores/debugConsole";
const DISCORD_URL = "https://chat.nhcarrigan.com";
const DONATE_URL = "https://donate.nhcarrigan.com";
@@ -69,7 +70,6 @@
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
minimize_to_tray: false,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
@@ -507,6 +507,20 @@
/>
</svg>
</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
onclick={configStore.openSidebar}
class="p-1 text-gray-500 icon-trans-hover"
+4 -1
View File
@@ -101,7 +101,10 @@ export const claudeStore = {
export const hasPermissionPending = derived(
claudeStore.activeConversation,
($conversation) => $conversation?.pendingPermission !== null
($conversation) =>
$conversation?.pendingPermissions !== null &&
$conversation?.pendingPermissions !== undefined &&
$conversation.pendingPermissions.length > 0
);
export const hasQuestionPending = derived(
+326 -2
View File
@@ -14,6 +14,7 @@ import {
type Theme,
type CustomThemeColors,
} from "./config";
import { invoke } from "@tauri-apps/api/core";
// Mock Tauri APIs
vi.mock("@tauri-apps/api/core", () => ({
@@ -167,7 +168,6 @@ describe("config store", () => {
notifications_enabled: true,
notification_volume: 0.7,
always_on_top: false,
minimize_to_tray: true,
update_checks_enabled: true,
character_panel_width: 300,
font_size: 14,
@@ -213,7 +213,6 @@ describe("config store", () => {
notifications_enabled: true,
notification_volume: 0.7,
always_on_top: false,
minimize_to_tray: false,
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
@@ -489,4 +488,329 @@ describe("config store", () => {
expect(typeof configStore.saveError.subscribe).toBe("function");
});
});
describe("Race Condition Tests", () => {
beforeEach(async () => {
// Setup mock to return a default config for load_config
const mockInvokeImpl = vi.mocked(invoke);
mockInvokeImpl.mockResolvedValue({
model: null,
api_key: null,
custom_instructions: null,
mcp_servers_json: null,
auto_granted_tools: [],
theme: "dark",
greeting_enabled: false,
greeting_custom_prompt: null,
notifications_enabled: false,
notification_volume: 0.7,
always_on_top: false,
update_checks_enabled: false,
character_panel_width: null,
font_size: 14,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
profile_name: null,
profile_avatar_path: null,
profile_bio: null,
custom_theme_colors: {
bg_primary: null,
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
},
budget_enabled: false,
session_token_budget: null,
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
discord_rpc_enabled: false,
});
// Load initial config
await configStore.loadConfig();
vi.clearAllMocks();
});
it("handles rapid sequential config updates correctly", async () => {
// This test validates the fix for the config race condition that caused data loss
const mockInvokeImpl = vi.mocked(invoke);
const invokeCalls: Array<{ command: string; config: HikariConfig }> = [];
mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => {
if (command === "save_config" && args && typeof args === "object" && "config" in args) {
invokeCalls.push({ command, config: args.config as HikariConfig });
// Simulate small delay in saving
await new Promise((resolve) => setTimeout(resolve, 10));
}
return null;
});
// Perform rapid updates
await Promise.all([
configStore.updateConfig({ font_size: 16 }),
configStore.updateConfig({ theme: "light" }),
configStore.updateConfig({ compact_mode: true }),
]);
// All three updates should have been saved
expect(invokeCalls.length).toBe(3);
// Get final config
const finalConfig = configStore.getConfig();
// Final config should have all updates
// Note: The last update wins for each field, but all fields should be preserved
expect(finalConfig.compact_mode).toBe(true);
});
it("preserves previous field values during concurrent updates", async () => {
// Set initial values
await configStore.updateConfig({
font_size: 16,
theme: "dark",
compact_mode: false,
streamer_mode: false,
});
vi.clearAllMocks();
const mockInvokeImpl = vi.mocked(invoke);
const invokeCalls: Array<{ command: string; config: HikariConfig }> = [];
mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => {
if (command === "save_config" && args && typeof args === "object" && "config" in args) {
invokeCalls.push({ command, config: args.config as HikariConfig });
await new Promise((resolve) => setTimeout(resolve, 5));
}
return null;
});
// Update different fields concurrently
await Promise.all([
configStore.updateConfig({ font_size: 18 }),
configStore.updateConfig({ theme: "light" }),
configStore.updateConfig({ compact_mode: true }),
]);
// Check that each save included all previous config values
invokeCalls.forEach((call) => {
// Each save should have a complete config, not just the updated field
expect(call.config).toHaveProperty("font_size");
expect(call.config).toHaveProperty("theme");
expect(call.config).toHaveProperty("compact_mode");
expect(call.config).toHaveProperty("streamer_mode");
expect(call.config).toHaveProperty("model");
expect(call.config).toHaveProperty("api_key");
});
});
it("handles update during save operation", async () => {
const mockInvokeImpl = vi.mocked(invoke);
let firstSaveStarted = false;
let firstSaveCompleted = false;
mockInvokeImpl.mockImplementation(async (command: string) => {
if (command === "save_config") {
if (!firstSaveStarted) {
firstSaveStarted = true;
// Simulate slow save
await new Promise((resolve) => setTimeout(resolve, 50));
firstSaveCompleted = true;
} else {
// Second save starts while first is in progress
expect(firstSaveStarted).toBe(true);
// First save might not be complete yet (race condition scenario)
}
}
return null;
});
// Start first update
const firstUpdate = configStore.updateConfig({ font_size: 16 });
// Wait a bit then start second update whilst first is still saving
await new Promise((resolve) => setTimeout(resolve, 10));
const secondUpdate = configStore.updateConfig({ theme: "light" });
// Wait for both to complete
await Promise.all([firstUpdate, secondUpdate]);
// Both should complete successfully without errors
expect(firstSaveCompleted).toBe(true);
});
it("getConfig returns most recently set configuration", async () => {
await configStore.updateConfig({ font_size: 14 });
expect(configStore.getConfig().font_size).toBe(14);
await configStore.updateConfig({ font_size: 16 });
expect(configStore.getConfig().font_size).toBe(16);
await configStore.updateConfig({ font_size: 18 });
expect(configStore.getConfig().font_size).toBe(18);
});
it("updates do not lose data from previous operations", async () => {
// Set multiple fields
await configStore.updateConfig({
font_size: 16,
theme: "dark",
compact_mode: true,
streamer_mode: true,
model: "claude-sonnet-4",
});
// Update just one field
await configStore.updateConfig({ theme: "light" });
// Other fields should be preserved
const config = configStore.getConfig();
expect(config.theme).toBe("light");
expect(config.font_size).toBe(16);
expect(config.compact_mode).toBe(true);
expect(config.streamer_mode).toBe(true);
expect(config.model).toBe("claude-sonnet-4");
});
it("auto granted tools are not lost during other updates", async () => {
// Add some tools
await configStore.addAutoGrantedTool("Read");
await configStore.addAutoGrantedTool("Write");
expect(configStore.getConfig().auto_granted_tools).toContain("Read");
expect(configStore.getConfig().auto_granted_tools).toContain("Write");
// Update another field
await configStore.updateConfig({ theme: "light" });
// Tools should still be there
expect(configStore.getConfig().auto_granted_tools).toContain("Read");
expect(configStore.getConfig().auto_granted_tools).toContain("Write");
});
it("custom theme colors persist across other config updates", async () => {
const customColors: CustomThemeColors = {
bg_primary: "#1a1a2e",
bg_secondary: "#16213e",
bg_terminal: "#0f0f23",
accent_primary: "#e94560",
accent_secondary: "#533483",
text_primary: "#eaeaea",
text_secondary: "#a0a0a0",
border_color: "#333355",
};
await configStore.setCustomThemeColors(customColors);
// Update another field
await configStore.updateConfig({ font_size: 18 });
// Colors should still be there
const config = configStore.getConfig();
expect(config.custom_theme_colors.bg_primary).toBe("#1a1a2e");
expect(config.custom_theme_colors.accent_primary).toBe("#e94560");
});
it("handles save errors gracefully without losing data", async () => {
const mockInvokeImpl = vi.mocked(invoke);
// Set initial config
await configStore.updateConfig({ font_size: 14 });
// Make next save fail
mockInvokeImpl.mockRejectedValueOnce(new Error("Save failed"));
// Try to update - should throw
await expect(configStore.updateConfig({ theme: "light" })).rejects.toThrow();
// Original config should still be accessible
expect(configStore.getConfig().font_size).toBe(14);
});
});
describe("Config Persistence Tests", () => {
it("loadConfig retrieves saved configuration", async () => {
const mockConfig: HikariConfig = {
model: "claude-sonnet-4",
api_key: "test-key",
custom_instructions: "Be helpful",
mcp_servers_json: "{}",
auto_granted_tools: ["Read", "Write"],
theme: "light",
greeting_enabled: false,
greeting_custom_prompt: null,
notifications_enabled: false,
notification_volume: 0.5,
always_on_top: true,
update_checks_enabled: false,
character_panel_width: 400,
font_size: 18,
streamer_mode: true,
streamer_hide_paths: true,
compact_mode: true,
profile_name: "Test User",
profile_avatar_path: "/test/avatar.png",
profile_bio: "Test bio",
custom_theme_colors: {
bg_primary: null,
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
},
budget_enabled: true,
session_token_budget: 100000,
session_cost_budget: 1.5,
budget_action: "block",
budget_warning_threshold: 0.9,
discord_rpc_enabled: false,
};
const mockInvokeImpl = vi.mocked(invoke);
mockInvokeImpl.mockResolvedValueOnce(mockConfig);
await configStore.loadConfig();
const loadedConfig = configStore.getConfig();
expect(loadedConfig.model).toBe("claude-sonnet-4");
expect(loadedConfig.theme).toBe("light");
expect(loadedConfig.font_size).toBe(18);
expect(loadedConfig.auto_granted_tools).toEqual(["Read", "Write"]);
});
it("saveConfig persists configuration to backend", async () => {
const mockInvokeImpl = vi.mocked(invoke);
const savedConfigs: HikariConfig[] = [];
mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => {
if (command === "save_config" && args && typeof args === "object" && "config" in args) {
savedConfigs.push(args.config as HikariConfig);
}
return null;
});
const configToSave: Partial<HikariConfig> = {
model: "claude-sonnet-4",
theme: "dark",
font_size: 16,
};
await configStore.updateConfig(configToSave);
expect(savedConfigs.length).toBeGreaterThan(0);
const lastSaved = savedConfigs[savedConfigs.length - 1];
expect(lastSaved.model).toBe("claude-sonnet-4");
expect(lastSaved.theme).toBe("dark");
expect(lastSaved.font_size).toBe(16);
});
});
});
+18 -23
View File
@@ -27,7 +27,6 @@ export interface HikariConfig {
notifications_enabled: boolean;
notification_volume: number;
always_on_top: boolean;
minimize_to_tray: boolean;
update_checks_enabled: boolean;
character_panel_width: number | null;
font_size: number;
@@ -60,7 +59,6 @@ const defaultConfig: HikariConfig = {
notifications_enabled: true,
notification_volume: 0.7,
always_on_top: false,
minimize_to_tray: false,
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
@@ -94,6 +92,14 @@ function createConfigStore() {
const isSidebarOpen = writable<boolean>(false);
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() {
isLoading.set(true);
try {
@@ -121,8 +127,7 @@ function createConfigStore() {
}
async function updateConfig(updates: Partial<HikariConfig>) {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const currentConfig = getCurrentConfig();
const newConfig = { ...currentConfig, ...updates };
await saveConfig(newConfig);
}
@@ -147,15 +152,13 @@ function createConfigStore() {
updates.custom_theme_colors = customColors;
}
await updateConfig(updates);
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const currentConfig = getCurrentConfig();
applyTheme(theme, currentConfig.custom_theme_colors);
},
setCustomThemeColors: async (colors: CustomThemeColors) => {
await updateConfig({ custom_theme_colors: colors });
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const currentConfig = getCurrentConfig();
if (currentConfig.theme === "custom") {
applyCustomThemeColors(colors);
}
@@ -168,16 +171,14 @@ function createConfigStore() {
},
increaseFontSize: async () => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const currentConfig = getCurrentConfig();
const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2);
await updateConfig({ font_size: newSize });
applyFontSize(newSize);
},
decreaseFontSize: async () => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const currentConfig = getCurrentConfig();
const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2);
await updateConfig({ font_size: newSize });
applyFontSize(newSize);
@@ -189,8 +190,7 @@ function createConfigStore() {
},
addAutoGrantedTool: async (tool: string) => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const currentConfig = getCurrentConfig();
if (!currentConfig.auto_granted_tools.includes(tool)) {
const newTools = [...currentConfig.auto_granted_tools, tool];
await updateConfig({ auto_granted_tools: newTools });
@@ -198,27 +198,22 @@ function createConfigStore() {
},
removeAutoGrantedTool: async (tool: string) => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const currentConfig = getCurrentConfig();
const newTools = currentConfig.auto_granted_tools.filter((t) => t !== tool);
await updateConfig({ auto_granted_tools: newTools });
},
getConfig: (): HikariConfig => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
return currentConfig;
return getCurrentConfig();
},
toggleStreamerMode: async () => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const currentConfig = getCurrentConfig();
await updateConfig({ streamer_mode: !currentConfig.streamer_mode });
},
toggleCompactMode: async () => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const currentConfig = getCurrentConfig();
await updateConfig({ compact_mode: !currentConfig.compact_mode });
},
+35 -7
View File
@@ -28,7 +28,7 @@ export interface Conversation {
characterState: CharacterState;
isProcessing: boolean;
grantedTools: Set<string>;
pendingPermission: PermissionRequest | null;
pendingPermissions: PermissionRequest[];
pendingQuestion: UserQuestionEvent | null;
scrollPosition: number;
createdAt: Date;
@@ -66,7 +66,7 @@ function createConversationsStore() {
characterState: "idle",
isProcessing: false,
grantedTools: new Set(),
pendingPermission: null,
pendingPermissions: [],
pendingQuestion: null,
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
createdAt: new Date(),
@@ -120,7 +120,11 @@ function createConversationsStore() {
);
const pendingPermission = derived(
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 scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
@@ -133,6 +137,7 @@ function createConversationsStore() {
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
terminalLines: { subscribe: terminalLines.subscribe },
pendingPermission: { subscribe: pendingPermission.subscribe },
pendingPermissions: { subscribe: pendingPermissions.subscribe },
pendingQuestion: { subscribe: pendingQuestion.subscribe },
isProcessing: { subscribe: isProcessing.subscribe },
grantedTools: { subscribe: grantedTools.subscribe },
@@ -190,7 +195,7 @@ function createConversationsStore() {
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingPermission = request;
conv.pendingPermissions.push(request);
conv.lastActivityAt = new Date();
}
return convs;
@@ -203,7 +208,7 @@ function createConversationsStore() {
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingPermission = null;
conv.pendingPermissions = [];
conv.lastActivityAt = new Date();
}
return convs;
@@ -213,7 +218,7 @@ function createConversationsStore() {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.pendingPermission = request;
conv.pendingPermissions.push(request);
conv.lastActivityAt = new Date();
}
return convs;
@@ -223,7 +228,30 @@ function createConversationsStore() {
conversations.update((convs) => {
const conv = convs.get(conversationId);
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();
}
return convs;
+154
View File
@@ -0,0 +1,154 @@
import { writable, derived } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
export type LogLevel = "debug" | "info" | "warn" | "error";
export interface LogEntry {
id: string;
timestamp: Date;
level: LogLevel;
message: string;
source: "frontend" | "backend";
}
interface DebugConsoleState {
logs: LogEntry[];
isOpen: boolean;
maxLogs: number;
filterLevel: LogLevel | "all";
autoScroll: boolean;
}
const MAX_LOGS = 1000; // Circular buffer size
function createDebugConsoleStore() {
const { subscribe, update } = writable<DebugConsoleState>({
logs: [],
isOpen: false,
maxLogs: MAX_LOGS,
filterLevel: "all",
autoScroll: true,
});
let logCounter = 0;
function addLog(level: LogLevel, message: string, source: "frontend" | "backend") {
update((state) => {
const newLog: LogEntry = {
id: `log-${Date.now()}-${logCounter++}`,
timestamp: new Date(),
level,
message,
source,
};
const updatedLogs = [...state.logs, newLog];
// Implement circular buffer - remove oldest if exceeding max
if (updatedLogs.length > state.maxLogs) {
updatedLogs.shift();
}
return { ...state, logs: updatedLogs };
});
}
// Override console methods to capture frontend logs
const originalConsole = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
debug: console.debug,
};
function setupConsoleCapture() {
console.log = (...args: unknown[]) => {
originalConsole.log(...args);
addLog("info", args.map((arg) => String(arg)).join(" "), "frontend");
};
console.info = (...args: unknown[]) => {
originalConsole.info(...args);
addLog("info", args.map((arg) => String(arg)).join(" "), "frontend");
};
console.warn = (...args: unknown[]) => {
originalConsole.warn(...args);
addLog("warn", args.map((arg) => String(arg)).join(" "), "frontend");
};
console.error = (...args: unknown[]) => {
originalConsole.error(...args);
addLog("error", args.map((arg) => String(arg)).join(" "), "frontend");
};
console.debug = (...args: unknown[]) => {
originalConsole.debug(...args);
addLog("debug", args.map((arg) => String(arg)).join(" "), "frontend");
};
// Capture unhandled errors
window.addEventListener("error", (event) => {
addLog(
"error",
`[Unhandled Error] ${event.message} at ${event.filename}:${event.lineno}:${event.colno}`,
"frontend"
);
});
// Capture unhandled promise rejections
window.addEventListener("unhandledrejection", (event) => {
addLog("error", `[Unhandled Promise Rejection] ${event.reason}`, "frontend");
});
}
function restoreConsole() {
console.log = originalConsole.log;
console.info = originalConsole.info;
console.warn = originalConsole.warn;
console.error = originalConsole.error;
console.debug = originalConsole.debug;
}
// Listen for backend logs
async function setupBackendLogsListener() {
await listen<{ level: LogLevel; message: string }>("debug:log", (event) => {
addLog(event.payload.level, event.payload.message, "backend");
});
}
return {
subscribe,
toggle: () => update((state) => ({ ...state, isOpen: !state.isOpen })),
open: () => update((state) => ({ ...state, isOpen: true })),
close: () => update((state) => ({ ...state, isOpen: false })),
clear: () => update((state) => ({ ...state, logs: [] })),
setFilterLevel: (level: LogLevel | "all") =>
update((state) => ({ ...state, filterLevel: level })),
setAutoScroll: (enabled: boolean) => update((state) => ({ ...state, autoScroll: enabled })),
setupConsoleCapture,
restoreConsole,
setupBackendLogsListener,
};
}
export const debugConsoleStore = createDebugConsoleStore();
// Derived store for filtered logs
export const filteredLogs = derived(debugConsoleStore, ($debugConsole) => {
if ($debugConsole.filterLevel === "all") {
return $debugConsole.logs;
}
const levelPriority: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
const minPriority = levelPriority[$debugConsole.filterLevel];
return $debugConsole.logs.filter((log) => levelPriority[log.level] >= minPriority);
});
+67
View File
@@ -285,6 +285,73 @@ describe("snippetsStore", () => {
expect(get(snippetsStore.selectedCategory)).toBeNull();
});
});
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", () => {
+33 -23
View File
@@ -328,31 +328,41 @@ export async function initializeTauriListeners() {
});
unlisteners.push(cwdUnlisten);
console.log("[Tauri Listener] Setting up claude:permission listener");
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
if (conversation_id) {
claudeStore.requestPermissionForConversation(conversation_id, {
id,
tool: tool_name,
description,
input: tool_input,
});
claudeStore.addLineToConversation(
conversation_id,
"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}`);
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
for (const permission of permissions) {
const { id, tool_name, tool_input, description } = permission;
if (conversation_id) {
claudeStore.requestPermissionForConversation(conversation_id, {
id,
tool: tool_name,
description,
input: tool_input,
});
claudeStore.addLineToConversation(
conversation_id,
"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);
+5 -1
View File
@@ -126,11 +126,15 @@ export interface PermissionRequest {
input: Record<string, unknown>;
}
export interface PermissionPromptEvent {
export interface PermissionPromptEventItem {
id: string;
tool_name: string;
tool_input: Record<string, unknown>;
description: string;
}
export interface PermissionPromptEvent {
permissions: PermissionPromptEventItem[];
conversation_id?: string;
}
+175
View File
@@ -135,6 +135,151 @@ describe("stateMapper", () => {
};
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", () => {
@@ -192,6 +337,36 @@ describe("stateMapper", () => {
};
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", () => {
+2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import "../app.css";
import DebugConsole from "$lib/components/DebugConsole.svelte";
let { children } = $props();
@@ -14,4 +15,5 @@
<div id="app">
{@render children()}
<DebugConsole />
</div>
+73
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { get } from "svelte/store";
import {
initializeTauriListeners,
@@ -31,12 +32,16 @@
import AchievementNotification from "$lib/components/AchievementNotification.svelte";
import AchievementsPanel from "$lib/components/AchievementsPanel.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 updateNotification: UpdateNotification | undefined = $state(undefined);
let achievementPanelOpen = $state(false);
let currentCharacterState: CharacterState = $state("idle");
let compactModeActive = $state(false);
let closeConfirmModalOpen = $state(false);
let hasActiveConversation = $state(false);
// Editor state
const isEditorVisible = editorStore.isEditorVisible;
@@ -230,6 +235,13 @@
return;
}
// Ctrl+` - Toggle debug console
if (event.ctrlKey && event.key === "`") {
event.preventDefault();
debugConsoleStore.toggle();
return;
}
// Ctrl+E - Toggle editor panel (only when connected)
if (event.ctrlKey && event.key === "e") {
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 () => {
if (!initialized) {
initialized = true;
@@ -387,6 +443,16 @@
// Initialize Discord RPC
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)}
/>
<UpdateNotification bind:this={updateNotification} />
<CloseAppConfirmModal
isOpen={closeConfirmModalOpen}
{hasActiveConversation}
onClose={handleConfirmClose}
onMinimize={handleMinimizeToTray}
onCancel={handleCancelClose}
/>
</div>
{/if}
-1
View File
@@ -39,7 +39,6 @@ vi.mock("@tauri-apps/api/core", () => ({
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
minimize_to_tray: false,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,