Compare commits

..

16 Commits

Author SHA1 Message Date
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
40 changed files with 230 additions and 5472 deletions
-142
View File
@@ -30,148 +30,6 @@ Example commit command:
git commit --author="Hikari <hikari@nhcarrigan.com>" --no-gpg-sign -m "your commit message"
```
## Testing Requirements
All new features, fixes, and significant changes should include tests whenever possible:
- **Frontend tests**: Use Vitest with `@testing-library/svelte` for component tests
- **Test files**: Place test files next to the code they test with `.test.ts` or `.spec.ts` extension
- **Run tests**: Use `pnpm test` to run all tests, or `pnpm test:watch` for watch mode
- **Coverage**: Run `pnpm test:coverage` to generate coverage reports
- **Rust tests**: Use `pnpm test:backend` for Rust/Tauri backend tests
### Testing Guidelines
- Write tests for utility functions, stores, and business logic
- For Svelte 5 components, focus on testing the underlying logic functions
- Use descriptive test names that explain what behaviour is being tested
- Include edge cases and error conditions in test coverage
- Mock Tauri APIs using the patterns in `vitest.setup.ts`
- **Coverage Goal**: Maintain as close to 100% test coverage as possible across the entire codebase
### Mocking Strategies
#### Console Mocking
When testing code that intentionally logs errors (like error handling paths), mock console methods to prevent stderr output that makes tests appear flaky:
```typescript
it("handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Test error handling code
await expect(functionThatLogs()).rejects.toThrow();
// Verify error was logged
expect(consoleErrorSpy).toHaveBeenCalledWith("Expected error:", expect.any(Error));
// Restore console.error
consoleErrorSpy.mockRestore();
});
```
#### E2E Integration Testing for Cross-Platform Code
For code that calls platform-specific system APIs (like Windows PowerShell or Linux notify-send), use helper functions that build the command structure without execution. This allows CI to verify cross-platform compatibility on Linux-only containers:
```rust
/// Build notify-send command for testing (doesn't execute)
#[cfg(test)]
fn build_notify_send_command(title: &str, body: &str) -> (String, Vec<String>) {
(
"notify-send".to_string(),
vec![
title.to_string(),
body.to_string(),
"--urgency=normal".to_string(),
"--app-name=Hikari Desktop".to_string(),
],
)
}
#[test]
fn test_e2e_notify_send_command_structure() {
let (command, args) = build_notify_send_command("Test Title", "Test Body");
assert_eq!(command, "notify-send");
assert_eq!(args.len(), 4);
assert_eq!(args[0], "Test Title");
assert_eq!(args[1], "Test Body");
}
```
This approach:
- Verifies command structure, argument order, and escaping logic
- Tests cross-platform code paths without requiring the target platform
- Allows CI to catch regressions in Windows-specific code whilst running on Linux
- Keeps tests fast and deterministic (no actual system calls)
### Example Test Structure
```typescript
import { describe, it, expect } from "vitest";
describe("FeatureName", () => {
it("handles the normal case correctly", () => {
// Arrange
const input = "test data";
// Act
const result = functionUnderTest(input);
// Assert
expect(result).toBe("expected output");
});
it("handles edge cases gracefully", () => {
// Test edge cases...
});
});
```
### Adding Tests for New Features
When developing new features, always add corresponding tests:
1. **Before implementing**: Consider what needs testing (happy path, edge cases, errors)
2. **During implementation**: Write tests alongside the code
3. **After implementation**: Run `pnpm test:coverage` to verify coverage remains high
4. **Before committing**: Ensure `check-all.sh` passes (includes all tests)
The goal is to maintain our near-100% coverage as the codebase grows, so future refactoring and changes can be made with confidence!
## Quality Assurance
Before committing any changes, **always run the full test suite**:
```bash
./check-all.sh
```
This script runs all checks in the correct order:
1. Frontend linting (ESLint)
2. Frontend formatting (Prettier)
3. Frontend type checking (svelte-check)
4. Frontend tests with coverage (Vitest)
5. Backend linting (Clippy with strict rules)
6. Backend tests with coverage (cargo test + llvm-cov)
**Important**: The script requires Node.js and Rust toolchains to be available:
- **Node.js tools** (pnpm, npm): Source nvm first if needed: `source ~/.nvm/nvm.sh`
- **Rust tools** (cargo, clippy): Should be in PATH via `~/.cargo/bin/`
If `check-all.sh` reports any failures:
1. Read the error messages carefully - they usually explain what needs fixing
2. Fix the issues (linting errors, test failures, etc.)
3. Run `check-all.sh` again to verify the fixes
4. Only commit once all checks pass ✨
**Never commit code that doesn't pass `check-all.sh`** - this ensures code quality and prevents broken builds!
## 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!
+3 -3
View File
@@ -36,11 +36,11 @@ echo -e "${YELLOW}🔍 Running all checks for Hikari Desktop...${NC}"
run_check "Frontend lint" "pnpm lint" || failed=1
run_check "Frontend format check" "pnpm format:check" || failed=1
run_check "Frontend type check" "pnpm check" || failed=1
run_check "Frontend tests with coverage" "pnpm test:coverage" || failed=1
run_check "Frontend tests" "pnpm test" || failed=1
# Backend checks
run_check "Backend clippy (strict)" "(cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings)" || failed=1
run_check "Backend tests with coverage" "(cd src-tauri && cargo llvm-cov --fail-under-lines 50)" || failed=1
run_check "Backend clippy (strict)" "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings" || failed=1
run_check "Backend tests" "cargo test" || failed=1
# Summary
echo -e "\n${YELLOW}========================================${NC}"
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "hikari-desktop",
"version": "1.5.1",
"version": "1.3.0",
"description": "",
"type": "module",
"scripts": {
@@ -64,7 +64,6 @@
"@tauri-apps/plugin-store": "^2",
"codemirror": "^6.0.2",
"highlight.js": "^11.11.1",
"lucide-svelte": "0.564.0",
"marked": "^17.0.1"
},
"devDependencies": {
-12
View File
@@ -119,9 +119,6 @@ importers:
highlight.js:
specifier: ^11.11.1
version: 11.11.1
lucide-svelte:
specifier: 0.564.0
version: 0.564.0(svelte@5.46.3)
marked:
specifier: ^17.0.1
version: 17.0.1
@@ -1671,11 +1668,6 @@ packages:
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
engines: {node: 20 || >=22}
lucide-svelte@0.564.0:
resolution: {integrity: sha512-jeubFecyzbeze/Zwu5pFHHrv/u8OtiZ7VesXXus4cAnfRQlmb8TDtiC5gb485z8e4aAqe8FF7I0OVB/TDFAggg==}
peerDependencies:
svelte: ^3 || ^4 || ^5.0.0-next.42
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -3658,10 +3650,6 @@ snapshots:
lru-cache@11.2.4: {}
lucide-svelte@0.564.0(svelte@5.46.3):
dependencies:
svelte: 5.46.3
lz-string@1.5.0: {}
magic-string@0.30.21:
+1 -1
View File
@@ -1636,7 +1636,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hikari-desktop"
version = "1.5.1"
version = "1.3.0"
dependencies = [
"chrono",
"dirs 5.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hikari-desktop"
version = "1.5.1"
version = "1.3.0"
description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"]
edition = "2021"
-8
View File
@@ -28,14 +28,6 @@
"identifier": "fs:allow-write-file",
"allow": [{ "path": "**" }]
},
{
"identifier": "fs:scope",
"allow": [{ "path": "$HOME/.claude/**" }]
},
{
"identifier": "fs:allow-read-text-file",
"allow": [{ "path": "$HOME/.claude/**" }]
},
"core:window:allow-set-size",
"core:window:allow-set-always-on-top",
"core:window:allow-inner-size",
-124
View File
@@ -173,127 +173,3 @@ pub type SharedBridgeManager = Arc<Mutex<BridgeManager>>;
pub fn create_shared_bridge_manager() -> SharedBridgeManager {
Arc::new(Mutex::new(BridgeManager::new()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bridge_manager_new() {
let manager = BridgeManager::new();
assert!(manager.app_handle.is_none());
assert!(manager.bridges.is_empty());
}
#[test]
fn test_bridge_manager_default() {
let manager = BridgeManager::default();
assert!(manager.app_handle.is_none());
assert!(manager.bridges.is_empty());
}
#[test]
fn test_is_claude_running_no_bridge() {
let manager = BridgeManager::new();
assert!(!manager.is_claude_running("nonexistent"));
}
#[test]
fn test_get_working_directory_no_bridge() {
let manager = BridgeManager::new();
let result = manager.get_working_directory("nonexistent");
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"No Claude instance found for this conversation"
);
}
#[test]
fn test_get_usage_stats_no_bridge() {
let manager = BridgeManager::new();
let result = manager.get_usage_stats("nonexistent");
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"No Claude instance found for this conversation"
);
}
#[test]
fn test_stop_claude_no_bridge() {
let mut manager = BridgeManager::new();
let result = manager.stop_claude("nonexistent");
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"No Claude instance found for this conversation"
);
}
#[test]
fn test_interrupt_claude_no_bridge() {
let mut manager = BridgeManager::new();
let result = manager.interrupt_claude("nonexistent");
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"No Claude instance found for this conversation"
);
}
#[test]
fn test_send_prompt_no_bridge() {
let mut manager = BridgeManager::new();
let result = manager.send_prompt("nonexistent", "Hello".to_string());
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"No Claude instance found for this conversation"
);
}
#[test]
fn test_send_tool_result_no_bridge() {
let mut manager = BridgeManager::new();
let result = manager.send_tool_result(
"nonexistent",
"tool_id",
serde_json::json!({"result": "success"}),
);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"No Claude instance found for this conversation"
);
}
#[test]
fn test_create_shared_bridge_manager() {
let shared = create_shared_bridge_manager();
let manager = shared.lock();
assert!(manager.bridges.is_empty());
assert!(manager.app_handle.is_none());
}
#[test]
fn test_cleanup_stopped_bridges_empty() {
let mut manager = BridgeManager::new();
manager.cleanup_stopped_bridges();
assert!(manager.bridges.is_empty());
}
#[test]
fn test_get_active_conversations_empty() {
let manager = BridgeManager::new();
let active = manager.get_active_conversations();
assert!(active.is_empty());
}
#[test]
fn test_stop_all_without_app_handle() {
let mut manager = BridgeManager::new();
manager.stop_all(); // Should not panic
assert!(manager.bridges.is_empty());
}
}
+9 -1203
View File
File diff suppressed because it is too large Load Diff
-79
View File
@@ -76,82 +76,3 @@ where
let _ = self.app.emit("debug:log", log_event);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_debug_log_event_creation() {
let event = DebugLogEvent {
level: "info".to_string(),
message: "Test message".to_string(),
};
assert_eq!(event.level, "info");
assert_eq!(event.message, "Test message");
}
#[test]
fn test_debug_log_event_serialization() {
let event = DebugLogEvent {
level: "error".to_string(),
message: "Error occurred".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"level\":\"error\""));
assert!(json.contains("\"message\":\"Error occurred\""));
}
#[test]
fn test_debug_log_event_deserialization() {
let json = r#"{"level":"warn","message":"Warning message"}"#;
let event: DebugLogEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.level, "warn");
assert_eq!(event.message, "Warning message");
}
#[test]
fn test_debug_log_event_with_special_characters() {
let event = DebugLogEvent {
level: "info".to_string(),
message: "Message with \"quotes\" and \n newlines".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
let decoded: DebugLogEvent = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.level, event.level);
assert_eq!(decoded.message, event.message);
}
#[test]
fn test_debug_log_event_with_unicode() {
let event = DebugLogEvent {
level: "debug".to_string(),
message: "Unicode: 日本語 🎉".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
let decoded: DebugLogEvent = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.message, "Unicode: 日本語 🎉");
}
#[test]
fn test_debug_log_event_all_levels() {
let levels = vec!["error", "warn", "info", "debug", "trace"];
for level in levels {
let event = DebugLogEvent {
level: level.to_string(),
message: format!("{} level message", level),
};
assert_eq!(event.level, level);
assert!(event.message.contains(level));
}
}
}
+75 -35
View File
@@ -1,13 +1,18 @@
use discord_rich_presence::activity::{Activity, Assets, Timestamps};
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
use parking_lot::RwLock;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;
use tauri::{AppHandle, Manager};
pub struct DiscordRpcManager {
client: Arc<RwLock<Option<DiscordIpcClient>>>,
session_name: Arc<RwLock<String>>,
model: Arc<RwLock<String>>,
started_at: Arc<RwLock<i64>>,
log_path: Arc<RwLock<Option<PathBuf>>>,
}
impl DiscordRpcManager {
@@ -17,47 +22,82 @@ impl DiscordRpcManager {
session_name: Arc::new(RwLock::new(String::new())),
model: Arc::new(RwLock::new(String::new())),
started_at: Arc::new(RwLock::new(0)),
log_path: Arc::new(RwLock::new(None)),
}
}
pub fn set_app_handle(&self, app_handle: &AppHandle) {
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) {
tracing::error!("Failed to create app data directory: {}", e);
return;
}
let log_path = app_data_dir.join("hikari_discord_rpc.log");
*self.log_path.write() = Some(log_path.clone());
self.log(&format!(
"Log file initialised at: {}",
log_path.display()
));
}
}
pub fn log(&self, message: &str) {
let log_path_guard = self.log_path.read();
let path = match log_path_guard.as_ref() {
Some(p) => p.clone(),
None => PathBuf::from("hikari_discord_rpc.log"),
};
drop(log_path_guard);
if let Ok(mut file) = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let _ = writeln!(file, "[{}] {}", timestamp, message);
}
}
pub fn init(&self, initial_session_name: String, initial_model: String, started_at: i64) -> Result<(), String> {
tracing::debug!("Attempting to initialize Discord RPC...");
tracing::debug!("Application ID: 1391117878182281316");
tracing::debug!("Initial session: '{}', model: '{}', timestamp: {}",
initial_session_name, initial_model, started_at);
self.log("Attempting to initialize Discord RPC...");
self.log("DEBUG: Application ID: 1391117878182281316");
self.log(&format!("DEBUG: Initial session: '{}', model: '{}', timestamp: {}",
initial_session_name, initial_model, started_at));
let mut client = DiscordIpcClient::new("1391117878182281316")
.map_err(|e| {
let error_msg = format!("Failed to create Discord RPC client: {} (is Discord running?)", e);
tracing::error!("{}", error_msg);
self.log(&format!("ERROR: {}", error_msg));
error_msg
})?;
tracing::debug!("DiscordIpcClient created successfully");
self.log("DEBUG: DiscordIpcClient created successfully");
client
.connect()
.map_err(|e| {
let error_msg = format!("Failed to connect to Discord RPC: {} (ensure Discord is running)", e);
tracing::error!("{}", error_msg);
self.log(&format!("ERROR: {}", error_msg));
error_msg
})?;
tracing::debug!("Connected to Discord IPC socket");
self.log("DEBUG: Connected to Discord IPC socket");
// Set initial activity immediately after connecting
tracing::debug!("Building initial activity...");
self.log("DEBUG: Building initial activity...");
let state_text = format!("Model: {}", initial_model);
let assets = Assets::new()
.large_image("hikari")
.large_text("Hikari - Claude Code Assistant");
tracing::debug!("Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
self.log("DEBUG: Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
let timestamps = Timestamps::new()
.start(started_at);
tracing::debug!("Timestamps created - start: {}", started_at);
self.log(&format!("DEBUG: Timestamps created - start: {}", started_at));
let activity = Activity::new()
.details(initial_session_name.as_str())
@@ -65,19 +105,19 @@ impl DiscordRpcManager {
.assets(assets)
.timestamps(timestamps);
tracing::debug!("Activity created - details: '{}', state: '{}'",
initial_session_name, state_text);
self.log(&format!("DEBUG: Activity created - details: '{}', state: '{}'",
initial_session_name, state_text));
tracing::debug!("Attempting to set initial activity...");
self.log("DEBUG: Attempting to set initial activity...");
client
.set_activity(activity)
.map_err(|e| {
let error_msg = format!("Failed to set initial Discord RPC activity: {}", e);
tracing::error!("{}", error_msg);
self.log(&format!("ERROR: {}", error_msg));
error_msg
})?;
tracing::debug!("Initial activity set successfully!");
self.log("DEBUG: Initial activity set successfully!");
// Store the client and initial state
*self.client.write() = Some(client);
@@ -85,8 +125,8 @@ impl DiscordRpcManager {
*self.model.write() = initial_model.clone();
*self.started_at.write() = started_at;
tracing::info!("Discord RPC connected successfully with initial activity: session='{}', model='{}'",
initial_session_name, initial_model);
self.log(&format!("Discord RPC connected successfully with initial activity: session='{}', model='{}'",
initial_session_name, initial_model));
Ok(())
}
@@ -96,37 +136,37 @@ impl DiscordRpcManager {
model: String,
started_at: i64,
) -> Result<(), String> {
tracing::debug!("update() called with session='{}', model='{}', timestamp={}",
session_name, model, started_at);
self.log(&format!("DEBUG: update() called with session='{}', model='{}', timestamp={}",
session_name, model, started_at));
*self.session_name.write() = session_name.clone();
*self.model.write() = model.clone();
*self.started_at.write() = started_at;
tracing::debug!("State variables updated");
self.log("DEBUG: State variables updated");
let mut client_guard = self.client.write();
let client = client_guard
.as_mut()
.ok_or_else(|| {
let error_msg = "Discord RPC client not initialized".to_string();
tracing::error!("{}", error_msg);
self.log(&format!("ERROR: {}", error_msg));
error_msg
})?;
tracing::debug!("Client lock acquired");
self.log("DEBUG: Client lock acquired");
let state_text = format!("Model: {}", model);
let assets = Assets::new()
.large_image("hikari")
.large_text("Hikari - Claude Code Assistant");
tracing::debug!("Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
self.log("DEBUG: Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
let timestamps = Timestamps::new()
.start(started_at);
tracing::debug!("Timestamps created - start: {}", started_at);
self.log(&format!("DEBUG: Timestamps created - start: {}", started_at));
let activity = Activity::new()
.details(session_name.as_str())
@@ -134,38 +174,38 @@ impl DiscordRpcManager {
.assets(assets)
.timestamps(timestamps);
tracing::debug!("Activity created - details: '{}', state: '{}'",
session_name, state_text);
self.log(&format!("DEBUG: Activity created - details: '{}', state: '{}'",
session_name, state_text));
tracing::debug!("Attempting to set activity...");
self.log("DEBUG: Attempting to set activity...");
client
.set_activity(activity)
.map_err(|e| {
let error_msg = format!("Failed to update Discord RPC: {}", e);
tracing::error!("{}", error_msg);
self.log(&format!("ERROR: {}", error_msg));
error_msg
})?;
tracing::info!("Updated Discord RPC: session='{}', model='{}'", session_name, model);
self.log(&format!("Updated Discord RPC: session='{}', model='{}'", session_name, model));
Ok(())
}
pub fn stop(&self) -> Result<(), String> {
tracing::debug!("stop() called");
self.log("DEBUG: stop() called");
let mut client_guard = self.client.write();
if let Some(mut client) = client_guard.take() {
tracing::debug!("Client found, attempting to close...");
self.log("DEBUG: Client found, attempting to close...");
client
.close()
.map_err(|e| {
let error_msg = format!("Failed to close Discord RPC: {}", e);
tracing::error!("{}", error_msg);
self.log(&format!("ERROR: {}", error_msg));
error_msg
})?;
tracing::info!("Discord RPC stopped successfully");
self.log("Discord RPC stopped successfully");
} else {
tracing::debug!("No client to stop (already stopped or never initialized)");
self.log("DEBUG: No client to stop (already stopped or never initialized)");
}
Ok(())
}
+4 -16
View File
@@ -73,6 +73,9 @@ pub fn run() {
// Initialize the app handle in the bridge manager
bridge_manager.lock().set_app_handle(app.handle().clone());
// Initialize the app handle in the Discord RPC manager for logging
discord_rpc.set_app_handle(app.handle());
// Clean up any orphaned temp files from previous sessions
if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() {
if count > 0 {
@@ -192,23 +195,8 @@ pub fn run() {
init_discord_rpc,
update_discord_rpc,
stop_discord_rpc,
log_discord_rpc,
close_application,
list_memory_files,
get_claude_version,
list_plugins,
install_plugin,
uninstall_plugin,
enable_plugin,
disable_plugin,
update_plugin,
list_marketplaces,
add_marketplace,
remove_marketplace,
list_mcp_servers,
get_mcp_server,
remove_mcp_server,
add_mcp_server,
get_mcp_server_details,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+29 -318
View File
@@ -1,83 +1,6 @@
use std::process::Command;
use tauri::command;
/// Generate PowerShell script for Windows Toast Notification
fn generate_powershell_toast_script(title: &str, body: &str) -> String {
format!(
r#"
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
$APP_ID = 'Hikari Desktop'
$template = @"
<toast>
<visual>
<binding template="ToastText02">
<text id="1">{}</text>
<text id="2">{}</text>
</binding>
</visual>
<audio src="ms-winsoundevent:Notification.Default" />
</toast>
"@
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
$xml.LoadXml($template)
$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($APP_ID).Show($toast)
"#,
title.replace("\"", "`\""),
body.replace("\"", "`\"")
)
}
/// Format simple notification message
fn format_simple_notification(title: &str, body: &str) -> String {
format!("{}\n\n{}", title, body)
}
/// Build notify-send command for testing (doesn't execute)
#[cfg(test)]
fn build_notify_send_command(title: &str, body: &str) -> (String, Vec<String>) {
(
"notify-send".to_string(),
vec![
title.to_string(),
body.to_string(),
"--urgency=normal".to_string(),
"--app-name=Hikari Desktop".to_string(),
],
)
}
/// Build Windows PowerShell command for testing (doesn't execute)
#[cfg(test)]
fn build_windows_powershell_command(title: &str, body: &str) -> (String, Vec<String>) {
let script = generate_powershell_toast_script(title, body);
(
"pwsh.exe".to_string(),
vec![
"-NoProfile".to_string(),
"-WindowStyle".to_string(),
"Hidden".to_string(),
"-Command".to_string(),
script,
],
)
}
/// Build simple notification command for testing (doesn't execute)
#[cfg(test)]
fn build_simple_notification_command(title: &str, body: &str) -> (String, Vec<String>) {
let message = format_simple_notification(title, body);
(
"cmd.exe".to_string(),
vec!["/c".to_string(), "msg".to_string(), "*".to_string(), message],
)
}
#[command]
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
// Use notify-send for Linux/WSL
@@ -105,7 +28,34 @@ pub async fn send_notify_send(title: String, body: String) -> Result<(), String>
#[command]
pub async fn send_windows_notification(title: String, body: String) -> Result<(), String> {
// Create PowerShell script for Windows Toast Notification
let ps_script = generate_powershell_toast_script(&title, &body);
let ps_script = format!(
r#"
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
$APP_ID = 'Hikari Desktop'
$template = @"
<toast>
<visual>
<binding template="ToastText02">
<text id="1">{}</text>
<text id="2">{}</text>
</binding>
</visual>
<audio src="ms-winsoundevent:Notification.Default" />
</toast>
"@
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
$xml.LoadXml($template)
$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($APP_ID).Show($toast)
"#,
title.replace("\"", "`\""),
body.replace("\"", "`\"")
);
// Try PowerShell Core first (pwsh), then fall back to Windows PowerShell
let output = Command::new("pwsh.exe")
@@ -137,7 +87,7 @@ pub async fn send_windows_notification(title: String, body: String) -> Result<()
// Alternative: Use Windows built-in MSG command for simple notifications
#[command]
pub async fn send_simple_notification(title: String, body: String) -> Result<(), String> {
let message = format_simple_notification(&title, &body);
let message = format!("{}\n\n{}", title, body);
Command::new("cmd.exe")
.arg("/c")
@@ -149,242 +99,3 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(),
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_powershell_toast_script_basic() {
let script = generate_powershell_toast_script("Title", "Body");
assert!(script.contains("Hikari Desktop"));
assert!(script.contains("Title"));
assert!(script.contains("Body"));
assert!(script.contains("ToastNotification"));
}
#[test]
fn test_generate_powershell_toast_script_escapes_quotes() {
let script = generate_powershell_toast_script("Title with \"quotes\"", "Body with \"quotes\"");
// Quotes should be escaped as `" in PowerShell
assert!(script.contains("Title with `\"quotes`\""));
assert!(script.contains("Body with `\"quotes`\""));
}
#[test]
fn test_generate_powershell_toast_script_with_special_chars() {
let script = generate_powershell_toast_script("Title: Test", "Body\nwith\nnewlines");
assert!(script.contains("Title: Test"));
assert!(script.contains("Body\nwith\nnewlines"));
}
#[test]
fn test_generate_powershell_toast_script_unicode() {
let script = generate_powershell_toast_script("日本語 Title", "Unicode: 🎉");
assert!(script.contains("日本語 Title"));
assert!(script.contains("Unicode: 🎉"));
}
#[test]
fn test_generate_powershell_toast_script_empty() {
let script = generate_powershell_toast_script("", "");
// Should still contain the structure
assert!(script.contains("Hikari Desktop"));
assert!(script.contains("ToastNotification"));
}
#[test]
fn test_format_simple_notification_basic() {
let message = format_simple_notification("Title", "Body");
assert_eq!(message, "Title\n\nBody");
}
#[test]
fn test_format_simple_notification_with_newlines() {
let message = format_simple_notification("Multi\nLine\nTitle", "Multi\nLine\nBody");
assert!(message.contains("Multi\nLine\nTitle"));
assert!(message.contains("\n\n"));
assert!(message.contains("Multi\nLine\nBody"));
}
#[test]
fn test_format_simple_notification_unicode() {
let message = format_simple_notification("日本語", "🎉 Unicode");
assert_eq!(message, "日本語\n\n🎉 Unicode");
}
#[test]
fn test_format_simple_notification_empty() {
let message = format_simple_notification("", "");
assert_eq!(message, "\n\n");
}
#[test]
fn test_format_simple_notification_long_text() {
let long_title = "A".repeat(1000);
let long_body = "B".repeat(1000);
let message = format_simple_notification(&long_title, &long_body);
assert!(message.starts_with(&long_title));
assert!(message.ends_with(&long_body));
assert!(message.contains("\n\n"));
}
#[test]
fn test_generate_powershell_toast_script_multiple_quotes() {
let script = generate_powershell_toast_script(
"\"Quoted\" \"Multiple\" \"Times\"",
"\"More\" \"Quotes\" \"Here\""
);
// Each quote should be escaped
assert!(script.contains("`\"Quoted`\" `\"Multiple`\" `\"Times`\""));
assert!(script.contains("`\"More`\" `\"Quotes`\" `\"Here`\""));
}
// E2E Integration Tests - Command Structure Verification
#[test]
fn test_e2e_notify_send_command_structure() {
let (command, args) = build_notify_send_command("Test Title", "Test Body");
assert_eq!(command, "notify-send");
assert_eq!(args.len(), 4);
assert_eq!(args[0], "Test Title");
assert_eq!(args[1], "Test Body");
assert_eq!(args[2], "--urgency=normal");
assert_eq!(args[3], "--app-name=Hikari Desktop");
}
#[test]
fn test_e2e_notify_send_with_special_chars() {
let (command, args) =
build_notify_send_command("Title with \"quotes\"", "Body\nwith\nnewlines");
assert_eq!(command, "notify-send");
assert_eq!(args[0], "Title with \"quotes\"");
assert_eq!(args[1], "Body\nwith\nnewlines");
// notify-send handles these directly
}
#[test]
fn test_e2e_windows_powershell_command_structure() {
let (command, args) = build_windows_powershell_command("Test Title", "Test Body");
assert_eq!(command, "pwsh.exe");
assert_eq!(args.len(), 5);
assert_eq!(args[0], "-NoProfile");
assert_eq!(args[1], "-WindowStyle");
assert_eq!(args[2], "Hidden");
assert_eq!(args[3], "-Command");
// Verify the script in args[4] contains expected elements
let script = &args[4];
assert!(script.contains("Test Title"));
assert!(script.contains("Test Body"));
assert!(script.contains("Hikari Desktop"));
assert!(script.contains("ToastNotification"));
}
#[test]
fn test_e2e_windows_powershell_quote_escaping() {
let (_, args) =
build_windows_powershell_command("Title with \"quotes\"", "Body with \"quotes\"");
let script = &args[4];
// Verify quotes are properly escaped in the PowerShell script
assert!(script.contains("Title with `\"quotes`\""));
assert!(script.contains("Body with `\"quotes`\""));
}
#[test]
fn test_e2e_simple_notification_command_structure() {
let (command, args) = build_simple_notification_command("Test Title", "Test Body");
assert_eq!(command, "cmd.exe");
assert_eq!(args.len(), 4);
assert_eq!(args[0], "/c");
assert_eq!(args[1], "msg");
assert_eq!(args[2], "*");
assert_eq!(args[3], "Test Title\n\nTest Body");
}
#[test]
fn test_e2e_simple_notification_multiline() {
let (_, args) =
build_simple_notification_command("Multi\nLine\nTitle", "Multi\nLine\nBody");
let message = &args[3];
assert!(message.contains("Multi\nLine\nTitle"));
assert!(message.contains("\n\n"));
assert!(message.contains("Multi\nLine\nBody"));
}
#[test]
fn test_e2e_command_consistency_across_platforms() {
// Test that different platforms use consistent parameters
let title = "Consistency Test";
let body = "Testing cross-platform consistency";
// Linux command
let (notify_cmd, notify_args) = build_notify_send_command(title, body);
assert!(notify_cmd.contains("notify"));
assert!(notify_args.iter().any(|arg| arg.contains("Hikari Desktop")));
// Windows PowerShell command
let (ps_cmd, ps_args) = build_windows_powershell_command(title, body);
assert!(ps_cmd.contains("pwsh") || ps_cmd.contains("powershell"));
let ps_script = &ps_args[4];
assert!(ps_script.contains("Hikari Desktop"));
// Windows simple command
let (msg_cmd, msg_args) = build_simple_notification_command(title, body);
assert!(msg_cmd.contains("cmd"));
assert!(msg_args[3].contains(title));
assert!(msg_args[3].contains(body));
}
#[test]
fn test_e2e_unicode_support_across_platforms() {
let title = "日本語 Title";
let body = "Unicode: 🎉";
// Verify all platforms preserve unicode
let (_, notify_args) = build_notify_send_command(title, body);
assert_eq!(notify_args[0], title);
assert_eq!(notify_args[1], body);
let (_, ps_args) = build_windows_powershell_command(title, body);
let ps_script = &ps_args[4];
assert!(ps_script.contains(title));
assert!(ps_script.contains(body));
let (_, msg_args) = build_simple_notification_command(title, body);
assert!(msg_args[3].contains(title));
assert!(msg_args[3].contains(body));
}
#[test]
fn test_e2e_empty_input_handling() {
// Test that empty inputs are handled gracefully
let (_, notify_args) = build_notify_send_command("", "");
assert_eq!(notify_args[0], "");
assert_eq!(notify_args[1], "");
let (_, ps_args) = build_windows_powershell_command("", "");
let ps_script = &ps_args[4];
assert!(ps_script.contains("Hikari Desktop")); // Still has app name
let (_, msg_args) = build_simple_notification_command("", "");
assert_eq!(msg_args[3], "\n\n");
}
}
-15
View File
@@ -282,21 +282,6 @@ pub struct AgentEndEvent {
pub num_turns: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TodoItem {
pub content: String,
pub status: String, // "pending", "in_progress", or "completed"
#[serde(rename = "activeForm")]
pub active_form: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TodoUpdateEvent {
pub todos: Vec<TodoItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
+7 -277
View File
@@ -16,8 +16,8 @@ use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
use crate::types::{
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent,
PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent, TodoItem,
TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent,
PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent,
UserQuestionEvent, WorkingDirectoryEvent,
};
use parking_lot::RwLock;
use std::cell::RefCell;
@@ -129,11 +129,6 @@ impl WslBridge {
return Err("Process already running".to_string());
}
// Check if Claude binary is installed before attempting to start
if Command::new("which").arg("claude").output().ok().is_none_or(|output| !output.status.success()) {
return Err("Claude Code is not installed. Please install it using:\n\ncurl -fsSL https://claude.ai/install.sh | bash".to_string());
}
// Load saved achievements and stats when starting a new session
let app_clone = app.clone();
let stats = self.stats.clone();
@@ -683,34 +678,6 @@ fn handle_stderr(
}
}
// Check if this is a SubagentStop hook message
if line.contains("[SubagentStop Hook]") {
if let Some(stop_data) = parse_subagent_stop_hook(&line) {
tracing::debug!("Parsed SubagentStop hook: tool_use_id={:?}",
stop_data.parent_tool_use_id);
// Emit agent-end event if we have a tool_use_id
if let Some(tool_use_id) = stop_data.parent_tool_use_id {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let _ = app.emit(
"claude:agent-end",
AgentEndEvent {
tool_use_id,
ended_at: now,
is_error: false,
conversation_id: conversation_id.clone(),
duration_ms: None,
num_turns: None,
},
);
}
}
}
// Still emit the stderr line as output
let _ = app.emit(
"claude:output",
@@ -765,30 +732,6 @@ fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
})
}
#[derive(Debug)]
struct SubagentStopData {
parent_tool_use_id: Option<String>,
}
fn parse_subagent_stop_hook(line: &str) -> Option<SubagentStopData> {
// Parse: [SubagentStop Hook] ... parent_tool_use_id=Some("toolu_xxx"), ...
// Extract parent_tool_use_id if present
let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") {
line.split("parent_tool_use_id=Some(\"")
.nth(1)?
.split('"')
.next()
.map(|s| s.to_string())
} else {
None
};
Some(SubagentStopData {
parent_tool_use_id,
})
}
fn process_json_line(
line: &str,
app: &AppHandle,
@@ -958,8 +901,7 @@ fn process_json_line(
}
// Emit agent-start event for Task tool invocations
// Support both "Task" and "Task(agent_type)" syntax (CLI v2.1.33+)
if name == "Task" || name.starts_with("Task(") {
if name == "Task" {
let description = input
.get("description")
.and_then(|v| v.as_str())
@@ -994,34 +936,6 @@ fn process_json_line(
);
}
// Emit todo-update event for TodoWrite tool invocations
if name == "TodoWrite" {
if let Some(todos_value) = input.get("todos") {
if let Some(todos_array) = todos_value.as_array() {
let todos: Vec<TodoItem> = todos_array
.iter()
.filter_map(|todo| {
serde_json::from_value(todo.clone()).ok()
})
.collect();
tracing::debug!(
"Emitting todo-update: {} todos, parent={:?}",
todos.len(),
parent_tool_use_id
);
let _ = app.emit(
"claude:todo-update",
TodoUpdateEvent {
todos,
conversation_id: conversation_id.clone(),
},
);
}
}
}
let desc = format_tool_description(name, input);
let _ = app.emit(
"claude:output",
@@ -1059,8 +973,8 @@ fn process_json_line(
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "thinking".to_string(),
content: thinking.clone(),
line_type: "system".to_string(),
content: format!("[Thinking] {}", thinking),
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
@@ -1582,7 +1496,7 @@ fn get_tool_state(tool_name: &str) -> CharacterState {
CharacterState::Coding
} else if tool_name.starts_with("mcp__") {
CharacterState::Mcp
} else if tool_name == "Task" || tool_name.starts_with("Task(") {
} else if tool_name == "Task" {
CharacterState::Thinking
} else {
CharacterState::Typing
@@ -1590,21 +1504,10 @@ fn get_tool_state(tool_name: &str) -> CharacterState {
}
fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
// Helper function to check if a path is a memory file
fn is_memory_path(path: &str) -> bool {
path.contains("/.claude/") && (path.contains("/memory/") || path.ends_with("/MEMORY.md"))
}
match name {
"Read" => {
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
if is_memory_path(path) {
// Extract just the filename for cleaner display
let filename = path.split('/').next_back().unwrap_or(path);
format!("📝 Reading memory: {}", filename)
} else {
format!("Reading file: {}", path)
}
} else {
"Reading file...".to_string()
}
@@ -1623,26 +1526,9 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
"Searching in files...".to_string()
}
}
"Edit" => {
"Edit" | "Write" => {
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
if is_memory_path(path) {
let filename = path.split('/').next_back().unwrap_or(path);
format!("đź’ľ Updating memory: {}", filename)
} else {
format!("Editing: {}", path)
}
} else {
"Editing file...".to_string()
}
}
"Write" => {
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
if is_memory_path(path) {
let filename = path.split('/').next_back().unwrap_or(path);
format!("đź’ľ Writing memory: {}", filename)
} else {
format!("Editing: {}", path)
}
} else {
"Editing file...".to_string()
}
@@ -1737,19 +1623,6 @@ mod tests {
#[test]
fn test_get_tool_state_task() {
assert!(matches!(get_tool_state("Task"), CharacterState::Thinking));
// Test CLI v2.1.33+ Task(agent_type) syntax
assert!(matches!(
get_tool_state("Task(Explore)"),
CharacterState::Thinking
));
assert!(matches!(
get_tool_state("Task(Plan)"),
CharacterState::Thinking
));
assert!(matches!(
get_tool_state("Task(general-purpose)"),
CharacterState::Thinking
));
}
#[test]
@@ -1827,39 +1700,6 @@ mod tests {
assert_eq!(desc, "Using tool: CustomTool");
}
#[test]
fn test_format_tool_description_memory_read() {
let input =
serde_json::json!({"file_path": "/home/user/.claude/projects/test/memory/MEMORY.md"});
let desc = format_tool_description("Read", &input);
assert_eq!(desc, "📝 Reading memory: MEMORY.md");
}
#[test]
fn test_format_tool_description_memory_write() {
let input = serde_json::json!(
{"file_path": "/home/user/.claude/projects/test/memory/notes.md"}
);
let desc = format_tool_description("Write", &input);
assert_eq!(desc, "đź’ľ Writing memory: notes.md");
}
#[test]
fn test_format_tool_description_memory_edit() {
let input = serde_json::json!(
{"file_path": "/home/user/.claude/projects/test/memory/patterns.md"}
);
let desc = format_tool_description("Edit", &input);
assert_eq!(desc, "đź’ľ Updating memory: patterns.md");
}
#[test]
fn test_format_tool_description_non_memory_read() {
let input = serde_json::json!({"file_path": "/home/user/code/test.txt"});
let desc = format_tool_description("Read", &input);
assert_eq!(desc, "Reading file: /home/user/code/test.txt");
}
#[test]
fn test_wsl_bridge_new() {
let bridge = WslBridge::new();
@@ -1873,22 +1713,6 @@ mod tests {
assert!(!bridge.is_running());
}
#[test]
fn test_claude_binary_check_command_structure() {
// Test that we're using the correct command to check for Claude binary
let output = Command::new("which").arg("claude").output();
// The command should execute successfully (even if claude is not found)
// We're just verifying the command structure is valid
assert!(output.is_ok(), "which command should execute without error");
// Verify the check logic returns a boolean
// This is the same logic used in start() to check if claude is installed
let _result = output.ok().is_none_or(|o| !o.status.success());
// If claude is not installed, _result will be true (show error)
// If claude is installed, _result will be false (proceed with connection)
}
#[test]
fn test_create_shared_bridge_manager() {
use crate::bridge_manager::create_shared_bridge_manager;
@@ -1896,98 +1720,4 @@ mod tests {
let manager = shared.lock();
assert!(manager.get_active_conversations().is_empty());
}
// SubagentStart hook parsing tests
#[test]
fn test_parse_subagent_start_hook_with_parent() {
let line = r#"[SubagentStart Hook] agent_id=agent-abc123, parent_tool_use_id=Some("toolu_01XYZ789"), session_id=123"#;
let result = parse_subagent_start_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.agent_id, "agent-abc123");
assert_eq!(data.parent_tool_use_id, Some("toolu_01XYZ789".to_string()));
}
#[test]
fn test_parse_subagent_start_hook_without_parent() {
let line = r#"[SubagentStart Hook] agent_id=agent-xyz789, parent_tool_use_id=None, session_id=456"#;
let result = parse_subagent_start_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.agent_id, "agent-xyz789");
assert_eq!(data.parent_tool_use_id, None);
}
#[test]
fn test_parse_subagent_start_hook_invalid() {
let line = "[SubagentStart Hook] invalid data";
let result = parse_subagent_start_hook(line);
assert!(result.is_none());
}
#[test]
fn test_parse_subagent_start_hook_with_extra_fields() {
let line = r#"[SubagentStart Hook] agent_id=agent-test, parent_tool_use_id=Some("toolu_test"), session_id=789, cwd=/home/user"#;
let result = parse_subagent_start_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.agent_id, "agent-test");
assert_eq!(data.parent_tool_use_id, Some("toolu_test".to_string()));
}
// SubagentStop hook parsing tests
#[test]
fn test_parse_subagent_stop_hook_with_parent() {
let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), session_id=123"#;
let result = parse_subagent_stop_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.parent_tool_use_id, Some("toolu_01ABC123".to_string()));
}
#[test]
fn test_parse_subagent_stop_hook_without_parent() {
let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=None, session_id=456"#;
let result = parse_subagent_stop_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.parent_tool_use_id, None);
}
#[test]
fn test_parse_subagent_stop_hook_minimal() {
let line = r#"[SubagentStop Hook] parent_tool_use_id=Some("toolu_minimal")"#;
let result = parse_subagent_stop_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.parent_tool_use_id, Some("toolu_minimal".to_string()));
}
#[test]
fn test_parse_subagent_stop_hook_with_extra_fields() {
let line = r#"[SubagentStop Hook] stop_hook_active=false, parent_tool_use_id=Some("toolu_extra"), session_id=789, transcript_path=/path/to/transcript"#;
let result = parse_subagent_stop_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.parent_tool_use_id, Some("toolu_extra".to_string()));
}
#[test]
fn test_parse_subagent_stop_hook_empty() {
let line = "[SubagentStop Hook]";
let result = parse_subagent_stop_hook(line);
// Should still return Some with None parent_tool_use_id
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.parent_tool_use_id, None);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop",
"version": "1.5.1",
"version": "1.3.0",
"identifier": "com.naomi.hikari-desktop",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -118,8 +118,6 @@
try {
await invoke("interrupt_claude", { conversationId: currentConversationId });
// Mark all running agents as errored after killing the process
agentStore.markAllErrored(currentConversationId);
} catch (error) {
console.error("Failed to kill Claude process:", error);
}
-68
View File
@@ -1,68 +0,0 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { onMount } from "svelte";
let version = $state("Loading...");
async function fetchVersion() {
try {
const result = await invoke<string>("get_claude_version");
version = result;
} catch (error) {
console.error("Failed to get Claude CLI version:", error);
version = "Unknown";
}
}
onMount(() => {
fetchVersion();
});
</script>
<div class="cli-version">
<svg
class="terminal-icon"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
<span class="version-text">CLI {version}</span>
</div>
<style>
.cli-version {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
font-size: 0.85rem;
font-family: var(--font-mono, monospace);
transition: all 0.2s;
}
.cli-version:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.terminal-icon {
flex-shrink: 0;
opacity: 0.7;
}
.version-text {
white-space: nowrap;
}
</style>
-17
View File
@@ -51,7 +51,6 @@
budget_action: "warn",
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
show_thinking_blocks: true,
});
let showCustomThemeEditor = $state(false);
@@ -704,22 +703,6 @@
Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset
</p>
</div>
<!-- Show Thinking Blocks Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.show_thinking_blocks}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
/>
<span class="text-sm text-[var(--text-primary)]">Show Extended Thinking Blocks</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Display Claude's extended thinking process in the conversation. Thinking blocks can be
expanded/collapsed to see reasoning details.
</p>
</div>
</section>
<!-- Window Section -->
-18
View File
@@ -17,8 +17,6 @@
} from "$lib/stores/historyRestore";
import MessageModeSelector from "$lib/components/MessageModeSelector.svelte";
import SlashCommandMenu from "$lib/components/SlashCommandMenu.svelte";
import SystemClock from "$lib/components/SystemClock.svelte";
import CliVersion from "$lib/components/CliVersion.svelte";
import { getCurrentMode } from "$lib/stores/messageMode";
import { formatMessageWithMode } from "$lib/types/messageMode";
import {
@@ -916,9 +914,6 @@ User: ${formattedMessage}`;
</svg>
<span>Clipboard</span>
</button>
<CliVersion />
<SystemClock />
</div>
<div class="input-row">
@@ -1071,7 +1066,6 @@ User: ${formattedMessage}`;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.control-button {
@@ -1088,18 +1082,6 @@ User: ${formattedMessage}`;
transition: all 0.2s;
font-size: 14px;
white-space: nowrap;
flex-shrink: 0;
}
/* Hide button text on smaller screens, show icons only */
@media (max-width: 640px) {
.control-button span {
display: none;
}
.control-button {
padding: 10px;
min-width: 40px;
}
}
.control-button:hover {
@@ -1,433 +0,0 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { onMount } from "svelte";
import { Trash2, RefreshCw, Server, Globe, Terminal } from "lucide-svelte";
interface Props {
onClose: () => void;
}
interface McpServerInfo {
name: string;
command: string | null;
url: string | null;
transport: string; // "stdio", "http", or "sse"
env: Record<string, string> | null;
status: string | null; // "Connected" or "Failed to connect"
}
const { onClose }: Props = $props();
let servers = $state<McpServerInfo[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
let selectedServer = $state<McpServerInfo | null>(null);
let isLoadingDetails = $state(false);
let actionInProgress = $state<string | null>(null);
let showAddForm = $state(false);
let serverDetails = $state<string>("");
// Add server form fields
let newServerName = $state("");
let newServerUrl = $state("");
let newServerTransport = $state("stdio");
let isAdding = $state(false);
async function loadServers(): Promise<void> {
try {
isLoading = true;
error = null;
servers = await invoke<McpServerInfo[]>("list_mcp_servers");
} catch (e) {
error = `Failed to load MCP servers: ${e}`;
console.error(error);
} finally {
isLoading = false;
}
}
async function loadServerDetails(name: string): Promise<void> {
try {
isLoadingDetails = true;
error = null;
selectedServer = await invoke<McpServerInfo>("get_mcp_server", { name });
serverDetails = await invoke<string>("get_mcp_server_details", { name });
} catch (e) {
error = `Failed to load server details: ${e}`;
console.error(error);
} finally {
isLoadingDetails = false;
}
}
async function removeServer(name: string): Promise<void> {
try {
actionInProgress = name;
error = null;
await invoke("remove_mcp_server", { name });
if (selectedServer?.name === name) {
selectedServer = null;
serverDetails = "";
}
await loadServers();
} catch (e) {
error = `Failed to remove server: ${e}`;
console.error(error);
} finally {
actionInProgress = null;
}
}
async function addServer(): Promise<void> {
if (!newServerName.trim() || !newServerUrl.trim()) return;
try {
isAdding = true;
error = null;
await invoke("add_mcp_server", {
name: newServerName.trim(),
commandOrUrl: newServerUrl.trim(),
transport: newServerTransport,
envVars: null,
headers: null,
});
newServerName = "";
newServerUrl = "";
newServerTransport = "stdio";
showAddForm = false;
await loadServers();
} catch (e) {
error = `Failed to add server: ${e}`;
console.error(error);
} finally {
isAdding = false;
}
}
function getTransportIcon(transport: string) {
switch (transport) {
case "http":
return Globe;
case "stdio":
return Terminal;
case "sse":
return Server;
default:
return Server;
}
}
function getTransportColor(transport: string) {
switch (transport) {
case "http":
return "text-blue-400";
case "stdio":
return "text-green-400";
case "sse":
return "text-purple-400";
default:
return "text-[var(--text-secondary)]";
}
}
onMount(() => {
loadServers();
});
</script>
<div
class="fixed top-0 right-0 h-full w-[700px] bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
>
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
<div class="flex items-center gap-3">
<div class="text-[var(--accent-primary)]">
<Server class="w-6 h-6" />
</div>
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">MCP Server Management</h2>
<p class="text-xs text-[var(--text-secondary)]">
{servers.length} server{servers.length !== 1 ? "s" : ""} configured
</p>
</div>
</div>
<button
onclick={onClose}
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
aria-label="Close MCP panel"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<!-- Add Server Button -->
<div class="p-4 border-b border-[var(--border-color)]">
<button
onclick={() => (showAddForm = !showAddForm)}
class="w-full px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 transition-opacity flex items-center justify-center gap-2"
>
<Server class="w-4 h-4" />
{showAddForm ? "Cancel" : "Add New Server"}
</button>
</div>
<!-- Add Server Form -->
{#if showAddForm}
<div
class="mx-4 mt-4 p-4 bg-[var(--bg-secondary)]/50 border border-[var(--border-color)] rounded-lg"
>
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-3">Add MCP Server</h3>
<div class="space-y-3">
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
>Server Name</label
>
<input
type="text"
bind:value={newServerName}
placeholder="my-server"
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
/>
</div>
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
>Transport</label
>
<select
bind:value={newServerTransport}
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
>
<option value="stdio">STDIO</option>
<option value="http">HTTP</option>
<option value="sse">SSE</option>
</select>
</div>
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1">
{newServerTransport === "stdio" ? "Command" : "URL"}
</label>
<input
type="text"
bind:value={newServerUrl}
placeholder={newServerTransport === "stdio"
? "npx my-mcp-server"
: "https://mcp.example.com"}
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
/>
</div>
<button
onclick={addServer}
disabled={isAdding || !newServerName.trim() || !newServerUrl.trim()}
class="w-full px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{#if isAdding}
<RefreshCw class="w-4 h-4 animate-spin" />
{:else}
<Server class="w-4 h-4" />
{/if}
Add Server
</button>
</div>
</div>
{/if}
<!-- Error Display -->
{#if error}
<div class="mx-4 mt-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
<p class="text-sm text-red-400">{error}</p>
</div>
{/if}
<!-- Content -->
<div class="flex-1 overflow-y-auto p-4 flex gap-4">
<!-- Server List -->
<div class="flex-1">
{#if isLoading}
<div class="flex items-center justify-center h-full text-[var(--text-secondary)]">
<RefreshCw class="w-8 h-8 animate-spin" />
</div>
{:else if servers.length === 0}
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
<Server class="w-16 h-16 mb-4 opacity-50" />
<p class="text-center">No MCP servers configured</p>
<p class="text-sm text-center mt-2">Add servers via Settings</p>
</div>
{:else}
<div class="space-y-2">
{#each servers as server (server.name)}
<button
onclick={() => loadServerDetails(server.name)}
class="w-full bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all text-left"
class:border-[var(--accent-primary)]={selectedServer?.name === server.name}
>
<div class="flex items-start justify-between">
<div class="flex-1">
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
<svelte:component
this={getTransportIcon(server.transport)}
class="w-4 h-4 {getTransportColor(server.transport)}"
/>
{server.name}
{#if server.status}
{#if server.status.includes("Connected")}
<span
class="px-2 py-0.5 bg-[var(--success-color)]/20 text-[var(--success-color)] text-xs rounded border border-[var(--success-color)]/30"
>
âś“
</span>
{:else}
<span
class="px-2 py-0.5 bg-red-500/20 text-red-400 text-xs rounded border border-red-500/30"
>
âś—
</span>
{/if}
{/if}
</h4>
<p class="text-xs text-[var(--text-secondary)] mt-1">
{server.transport.toUpperCase()}
{#if server.url}
• {server.url}
{:else if server.command}
• {server.command}
{/if}
</p>
</div>
</div>
</button>
{/each}
</div>
{/if}
</div>
<!-- Server Details Panel -->
{#if selectedServer}
<div
class="w-80 bg-[var(--bg-secondary)]/50 rounded-lg p-4 border border-[var(--border-color)]"
>
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Server Details</h3>
{#if isLoadingDetails}
<div class="flex items-center justify-center h-32">
<RefreshCw class="w-6 h-6 animate-spin text-[var(--text-secondary)]" />
</div>
{:else}
<div class="space-y-4">
<!-- Name -->
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Name</label
>
<p class="text-sm text-[var(--text-primary)] mt-1">{selectedServer.name}</p>
</div>
<!-- Transport -->
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Transport</label
>
<p class="text-sm text-[var(--text-primary)] mt-1 flex items-center gap-2">
<svelte:component
this={getTransportIcon(selectedServer.transport)}
class="w-4 h-4 {getTransportColor(selectedServer.transport)}"
/>
{selectedServer.transport.toUpperCase()}
</p>
</div>
<!-- URL or Command -->
{#if selectedServer.url}
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>URL</label
>
<p
class="text-sm text-[var(--text-primary)] mt-1 break-all font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
>
{selectedServer.url}
</p>
</div>
{/if}
{#if selectedServer.command}
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Command</label
>
<p
class="text-sm text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
>
{selectedServer.command}
</p>
</div>
{/if}
<!-- Environment Variables -->
{#if selectedServer.env}
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Environment</label
>
<pre
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto">{JSON.stringify(
selectedServer.env,
null,
2
)}</pre>
</div>
{/if}
<!-- Full Server Details -->
{#if serverDetails}
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Full Details</label
>
<pre
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto whitespace-pre-wrap">{serverDetails}</pre>
</div>
{/if}
<!-- Actions -->
<div class="pt-4 border-t border-[var(--border-color)]">
<button
onclick={() => selectedServer && removeServer(selectedServer.name)}
disabled={actionInProgress === selectedServer?.name}
class="w-full px-4 py-2 bg-red-500/20 border border-red-500/30 rounded-lg text-sm text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<Trash2 class="w-4 h-4" />
Remove Server
</button>
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>
<style>
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>
@@ -1,458 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import Markdown from "./Markdown.svelte";
let memoryFiles: string[] = $state([]);
let selectedFile: string | null = $state(null);
let fileContent: string = $state("");
let isLoading = $state(false);
let error: string | null = $state(null);
let isPanelOpen = $state(false);
interface MemoryFilesResponse {
files: string[];
}
async function loadMemoryFiles() {
isLoading = true;
error = null;
try {
const response = await invoke<MemoryFilesResponse>("list_memory_files");
memoryFiles = response.files;
} catch (e) {
error = `Failed to load memory files: ${e}`;
console.error(error);
} finally {
isLoading = false;
}
}
async function loadFileContent(filePath: string) {
isLoading = true;
error = null;
try {
// Use our backend command instead of Tauri plugin to handle WSL paths
const content = await invoke<string>("read_file_content", { path: filePath });
fileContent = content;
selectedFile = filePath;
} catch (e) {
error = `Failed to read file: ${e}`;
console.error(error);
fileContent = "";
} finally {
isLoading = false;
}
}
function getFileName(path: string): string {
return path.split("/").pop() || path;
}
function togglePanel() {
isPanelOpen = !isPanelOpen;
if (isPanelOpen && memoryFiles.length === 0) {
loadMemoryFiles();
}
}
onMount(() => {
// Don't load on mount - only when panel is opened
});
</script>
<button class="memory-toggle" onclick={togglePanel} title="Memory Browser">
<svg
class="icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
<span class="label">Memory</span>
</button>
{#if isPanelOpen}
<div class="memory-panel">
<div class="panel-header">
<div class="header-title">
<svg
class="header-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
<h3>Memory Files</h3>
</div>
<button class="close-btn" onclick={togglePanel} title="Close">
<svg
class="close-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="panel-content">
{#if isLoading && memoryFiles.length === 0}
<div class="loading">
<svg
class="spinner"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Loading memory files...
</div>
{:else if error}
<div class="error">
<p>{error}</p>
<button class="retry-btn" onclick={loadMemoryFiles}>Retry</button>
</div>
{:else if memoryFiles.length === 0}
<div class="empty">
<p>No memory files found.</p>
<p class="hint">
Memory files are created automatically as I learn from our conversations!
</p>
</div>
{:else}
<div class="panel-layout">
<div class="file-list">
{#each memoryFiles as file (file)}
<button
class="file-item"
class:active={selectedFile === file}
onclick={() => loadFileContent(file)}
>
<svg
class="file-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span class="file-name">{getFileName(file)}</span>
</button>
{/each}
</div>
<div class="file-viewer">
{#if selectedFile && fileContent}
<div class="viewer-header">
<h4>{getFileName(selectedFile)}</h4>
</div>
<div class="viewer-content">
<Markdown content={fileContent} />
</div>
{:else if selectedFile && isLoading}
<div class="loading-file">
<svg
class="spinner"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Loading file...
</div>
{:else}
<div class="no-selection">
<p>Select a memory file to view its contents</p>
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}
<style>
.memory-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
}
.memory-toggle:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.icon {
width: 1.25rem;
height: 1.25rem;
}
.label {
font-size: 0.875rem;
font-weight: 500;
}
.memory-panel {
position: fixed;
top: 0;
right: 0;
width: 600px;
height: 100vh;
background: var(--bg-primary);
border-left: 1px solid var(--border-color);
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.header-title {
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-icon {
width: 1.5rem;
height: 1.5rem;
color: var(--accent-primary);
}
.panel-header h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.close-btn {
padding: 0.5rem;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: 4px;
transition: all 0.2s ease;
}
.close-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.close-icon {
width: 1.25rem;
height: 1.25rem;
}
.panel-content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.loading,
.error,
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 3rem 1.5rem;
text-align: center;
color: var(--text-secondary);
}
.spinner {
width: 2.5rem;
height: 2.5rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.error p {
color: var(--terminal-error, #f87171);
}
.retry-btn {
padding: 0.5rem 1rem;
background: var(--accent-primary);
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
}
.retry-btn:hover {
opacity: 0.9;
}
.hint {
font-size: 0.875rem;
font-style: italic;
max-width: 400px;
}
.panel-layout {
display: grid;
grid-template-columns: 200px 1fr;
gap: 1.5rem;
height: 100%;
}
.file-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.file-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
cursor: pointer;
text-align: left;
transition: all 0.2s ease;
}
.file-item:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.file-item.active {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: white;
}
.file-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
.file-name {
font-size: 0.875rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-viewer {
display: flex;
flex-direction: column;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.viewer-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-primary);
}
.viewer-header h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.viewer-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
color: var(--text-primary);
}
.loading-file,
.no-selection {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 3rem 1.5rem;
color: var(--text-secondary);
}
</style>
@@ -1,447 +0,0 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { onMount } from "svelte";
import { Download, Trash2, Power, PowerOff, RefreshCw } from "lucide-svelte";
interface Props {
onClose: () => void;
}
interface PluginInfo {
name: string;
version: string;
description: string | null;
enabled: boolean;
}
interface MarketplaceInfo {
name: string;
source: string;
}
const { onClose }: Props = $props();
let plugins = $state<PluginInfo[]>([]);
let marketplaces = $state<MarketplaceInfo[]>([]);
let isLoading = $state(true);
let isLoadingMarketplaces = $state(false);
let error = $state<string | null>(null);
let newPluginName = $state("");
let isInstalling = $state(false);
let actionInProgress = $state<string | null>(null);
let showMarketplaces = $state(false);
let newMarketplaceSource = $state("");
let isAddingMarketplace = $state(false);
async function loadPlugins(): Promise<void> {
try {
isLoading = true;
error = null;
plugins = await invoke<PluginInfo[]>("list_plugins");
} catch (e) {
error = `Failed to load plugins: ${e}`;
console.error(error);
} finally {
isLoading = false;
}
}
async function loadMarketplaces(): Promise<void> {
try {
isLoadingMarketplaces = true;
error = null;
marketplaces = await invoke<MarketplaceInfo[]>("list_marketplaces");
} catch (e) {
error = `Failed to load marketplaces: ${e}`;
console.error(error);
} finally {
isLoadingMarketplaces = false;
}
}
async function installPlugin(): Promise<void> {
if (!newPluginName.trim()) return;
try {
isInstalling = true;
error = null;
await invoke("install_plugin", { pluginName: newPluginName.trim() });
newPluginName = "";
await loadPlugins();
} catch (e) {
error = `Failed to install plugin: ${e}`;
console.error(error);
} finally {
isInstalling = false;
}
}
async function uninstallPlugin(pluginName: string): Promise<void> {
try {
actionInProgress = pluginName;
error = null;
await invoke("uninstall_plugin", { pluginName });
await loadPlugins();
} catch (e) {
error = `Failed to uninstall plugin: ${e}`;
console.error(error);
} finally {
actionInProgress = null;
}
}
async function togglePlugin(plugin: PluginInfo): Promise<void> {
try {
actionInProgress = plugin.name;
error = null;
if (plugin.enabled) {
await invoke("disable_plugin", { pluginName: plugin.name });
} else {
await invoke("enable_plugin", { pluginName: plugin.name });
}
await loadPlugins();
} catch (e) {
error = `Failed to ${plugin.enabled ? "disable" : "enable"} plugin: ${e}`;
console.error(error);
} finally {
actionInProgress = null;
}
}
async function updatePlugin(pluginName: string): Promise<void> {
try {
actionInProgress = pluginName;
error = null;
await invoke("update_plugin", { pluginName });
await loadPlugins();
} catch (e) {
error = `Failed to update plugin: ${e}`;
console.error(error);
} finally {
actionInProgress = null;
}
}
async function addMarketplace(): Promise<void> {
if (!newMarketplaceSource.trim()) return;
try {
isAddingMarketplace = true;
error = null;
await invoke("add_marketplace", { source: newMarketplaceSource.trim() });
newMarketplaceSource = "";
await loadMarketplaces();
} catch (e) {
error = `Failed to add marketplace: ${e}`;
console.error(error);
} finally {
isAddingMarketplace = false;
}
}
async function removeMarketplace(name: string): Promise<void> {
try {
actionInProgress = name;
error = null;
await invoke("remove_marketplace", { name });
await loadMarketplaces();
} catch (e) {
error = `Failed to remove marketplace: ${e}`;
console.error(error);
} finally {
actionInProgress = null;
}
}
onMount(() => {
loadPlugins();
});
</script>
<div
class="fixed top-0 right-0 h-full w-[600px] bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
>
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
<div class="flex items-center gap-3">
<div class="text-[var(--accent-primary)]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Plugin Management</h2>
<p class="text-xs text-[var(--text-secondary)]">
{plugins.length} plugin{plugins.length !== 1 ? "s" : ""} installed
</p>
</div>
</div>
<button
onclick={onClose}
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
aria-label="Close plugin panel"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<!-- Install Plugin Section -->
<div class="p-4 border-b border-[var(--border-color)]">
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-2">Install New Plugin</h3>
<p class="text-xs text-[var(--text-secondary)] mb-3">
Enter plugin name (e.g., "macrodata" or "macrodata@macrodata" for specific marketplace)
</p>
<div class="flex gap-2">
<input
type="text"
bind:value={newPluginName}
placeholder="plugin-name or plugin@marketplace"
class="flex-1 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
onkeydown={(e) => e.key === "Enter" && installPlugin()}
disabled={isInstalling}
/>
<button
onclick={installPlugin}
disabled={isInstalling || !newPluginName.trim()}
class="px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
>
{#if isInstalling}
<RefreshCw class="w-4 h-4 animate-spin" />
{:else}
<Download class="w-4 h-4" />
{/if}
Install
</button>
</div>
</div>
<!-- Marketplace Management Section -->
<div class="p-4 border-b border-[var(--border-color)]">
<button
onclick={() => {
showMarketplaces = !showMarketplaces;
if (showMarketplaces && marketplaces.length === 0) {
loadMarketplaces();
}
}}
class="w-full text-left flex items-center justify-between text-sm font-medium text-[var(--text-primary)] hover:text-[var(--accent-primary)] transition-colors"
>
<span>Manage Marketplaces</span>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 transition-transform"
class:rotate-180={showMarketplaces}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if showMarketplaces}
<div class="mt-3 space-y-3">
<!-- Add Marketplace Form -->
<div>
<p class="text-xs text-[var(--text-secondary)] mb-2">
Add a marketplace from GitHub (e.g., "ascorbic/macrodata")
</p>
<div class="flex gap-2">
<input
type="text"
bind:value={newMarketplaceSource}
placeholder="owner/repo"
class="flex-1 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
onkeydown={(e) => e.key === "Enter" && addMarketplace()}
disabled={isAddingMarketplace}
/>
<button
onclick={addMarketplace}
disabled={isAddingMarketplace || !newMarketplaceSource.trim()}
class="px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
>
{#if isAddingMarketplace}
<RefreshCw class="w-4 h-4 animate-spin" />
{:else}
<Download class="w-4 h-4" />
{/if}
Add
</button>
</div>
</div>
<!-- Marketplaces List -->
{#if isLoadingMarketplaces}
<div class="flex items-center justify-center py-4">
<RefreshCw class="w-5 h-5 animate-spin text-[var(--text-secondary)]" />
</div>
{:else if marketplaces.length > 0}
<div class="space-y-2">
{#each marketplaces as marketplace (marketplace.name)}
<div
class="bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)]"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<h4 class="font-medium text-[var(--text-primary)]">{marketplace.name}</h4>
<p class="text-xs text-[var(--text-secondary)] mt-1">{marketplace.source}</p>
</div>
<button
onclick={() => removeMarketplace(marketplace.name)}
disabled={actionInProgress === marketplace.name}
class="px-2 py-1 text-red-400 hover:bg-red-500/20 rounded transition-colors disabled:opacity-40"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-[var(--text-secondary)] text-center py-4">
No marketplaces configured
</p>
{/if}
</div>
{/if}
</div>
<!-- Error Display -->
{#if error}
<div class="mx-4 mt-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
<p class="text-sm text-red-400">{error}</p>
</div>
{/if}
<!-- Plugins List -->
<div class="flex-1 overflow-y-auto p-4">
{#if isLoading}
<div class="flex items-center justify-center h-full text-[var(--text-secondary)]">
<RefreshCw class="w-8 h-8 animate-spin" />
</div>
{:else if plugins.length === 0}
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 mb-4 opacity-50"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
<p class="text-center">No plugins installed</p>
<p class="text-sm text-center mt-2">Install a plugin using the form above</p>
</div>
{:else}
<div class="space-y-3">
{#each plugins as plugin (plugin.name)}
<div
class="bg-[var(--bg-secondary)]/50 rounded-lg p-4 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all"
>
<div class="flex items-start justify-between mb-2">
<div class="flex-1">
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
{plugin.name}
{#if plugin.enabled}
<span
class="px-2 py-0.5 bg-[var(--success-color)]/20 text-[var(--success-color)] text-xs rounded border border-[var(--success-color)]/30"
>
Enabled
</span>
{:else}
<span
class="px-2 py-0.5 bg-[var(--text-secondary)]/20 text-[var(--text-secondary)] text-xs rounded border border-[var(--border-color)]"
>
Disabled
</span>
{/if}
</h4>
<p class="text-xs text-[var(--text-secondary)] mt-1">v{plugin.version}</p>
{#if plugin.description}
<p class="text-sm text-[var(--text-secondary)] mt-2">{plugin.description}</p>
{/if}
</div>
</div>
<div class="flex gap-2 mt-3">
<button
onclick={() => togglePlugin(plugin)}
disabled={actionInProgress === plugin.name}
class="flex-1 px-3 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{#if plugin.enabled}
<PowerOff class="w-4 h-4" />
Disable
{:else}
<Power class="w-4 h-4" />
Enable
{/if}
</button>
<button
onclick={() => updatePlugin(plugin.name)}
disabled={actionInProgress === plugin.name}
class="px-3 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
>
<RefreshCw class="w-4 h-4" />
Update
</button>
<button
onclick={() => uninstallPlugin(plugin.name)}
disabled={actionInProgress === plugin.name}
class="px-3 py-1.5 bg-red-500/20 border border-red-500/30 rounded text-sm text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
>
<Trash2 class="w-4 h-4" />
Uninstall
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<style>
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>
+5 -96
View File
@@ -15,23 +15,9 @@
let showDeleteConfirm = $state<string | null>(null);
let showExportMenu = $state<string | null>(null);
let isImporting = $state(false);
let showClearAllConfirm = $state(false);
let sessions = $state<SessionListItem[]>([]);
let isLoading = $state(false);
$effect(() => {
const unsubSessions = sessionsStore.sessions.subscribe((value) => {
sessions = value;
});
const unsubLoading = sessionsStore.isLoading.subscribe((value) => {
isLoading = value;
});
return () => {
unsubSessions();
unsubLoading();
};
});
const sessions = $derived(sessionsStore.sessions);
const isLoading = $derived(sessionsStore.isLoading);
onMount(() => {
sessionsStore.loadSessions();
@@ -135,11 +121,6 @@
}
}
async function handleClearAll(): Promise<void> {
await sessionsStore.clearAllSessions();
showClearAllConfirm = false;
}
function toggleExportMenu(sessionId: string): void {
if (showExportMenu === sessionId) {
showExportMenu = null;
@@ -205,22 +186,6 @@
</svg>
{isImporting ? "Importing..." : "Import"}
</button>
<button
onclick={() => (showClearAllConfirm = true)}
disabled={sessions.length === 0}
class="px-3 py-1.5 text-sm font-medium bg-red-500/10 text-red-500 border border-red-500/30 rounded hover:bg-red-500/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
title="Clear all sessions"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Clear All
</button>
{/if}
<button
onclick={onClose}
@@ -316,11 +281,11 @@
</div>
<div class="overflow-y-auto flex-1">
{#if isLoading}
{#if $isLoading}
<div class="flex items-center justify-center p-8">
<div class="text-[var(--text-tertiary)]">Loading sessions...</div>
</div>
{:else if sessions.length === 0}
{:else if $sessions.length === 0}
<div class="flex flex-col items-center justify-center p-8 text-center">
<svg
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
@@ -342,7 +307,7 @@
</div>
{:else}
<div class="divide-y divide-[var(--border-color)]">
{#each sessions as session (session.id)}
{#each $sessions as session (session.id)}
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
<div class="flex items-start justify-between gap-4">
<button class="flex-1 text-left" onclick={() => handleViewSession(session)}>
@@ -463,62 +428,6 @@
</div>
</div>
{#if showClearAllConfirm}
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
onclick={() => (showClearAllConfirm = false)}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && (showClearAllConfirm = false)}
>
<div
class="bg-[var(--bg-primary)] border border-red-500/30 rounded-lg shadow-xl max-w-md w-full p-6"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="clear-all-title"
aria-describedby="clear-all-description"
tabindex="-1"
>
<div class="flex items-start gap-4">
<div class="flex-shrink-0 text-red-500">
<svg class="w-6 h-6" 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="clear-all-title" class="text-lg font-semibold text-[var(--text-primary)] mb-2">
Clear All Sessions?
</h3>
<p id="clear-all-description" class="text-[var(--text-secondary)] mb-4">
This will permanently delete all {sessions.length} session{sessions.length === 1
? ""
: "s"}. This action cannot be undone.
</p>
<div class="flex justify-end gap-3">
<button
onclick={() => (showClearAllConfirm = false)}
class="px-4 py-2 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--bg-tertiary)] transition-colors"
>
Cancel
</button>
<button
onclick={handleClearAll}
class="px-4 py-2 text-sm font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
>
Clear All Sessions
</button>
</div>
</div>
</div>
</div>
</div>
{/if}
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
+4 -94
View File
@@ -23,12 +23,9 @@
import { achievementProgress } from "$lib/stores/achievements";
import { runningAgentCount } from "$lib/stores/agents";
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
import TodoPanel from "./TodoPanel.svelte";
import GitPanel from "./GitPanel.svelte";
import ProfilePanel from "./ProfilePanel.svelte";
import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
import PluginManagementPanel from "./PluginManagementPanel.svelte";
import McpManagementPanel from "./McpManagementPanel.svelte";
import { conversationsStore } from "$lib/stores/conversations";
import {
generateContextInjection,
@@ -52,12 +49,9 @@
let showHelp = $state(false);
let showKeyboardShortcuts = $state(false);
let showSessionHistory = $state(false);
let showTodoPanel = $state(false);
let showGitPanel = $state(false);
let showProfile = $state(false);
let showAgentMonitor = $state(false);
let showPluginPanel = $state(false);
let showMcpPanel = $state(false);
let isSummarising = $state(false);
const progress = $derived($achievementProgress);
const activeAgentCount = $derived($runningAgentCount);
@@ -98,7 +92,6 @@
budget_action: "warn",
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
show_thinking_blocks: true,
});
let streamerModeActive = $state(false);
@@ -381,16 +374,16 @@
{/if}
</div>
<div class="flex items-center gap-2 flex-wrap min-w-0">
<div class="flex items-center gap-3">
{#if streamerModeActive}
<div
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse shrink-0"
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse"
title="Streamer mode active (Ctrl+Shift+S to toggle)"
></div>
{/if}
<button
onclick={() => (showProfile = true)}
class="p-1 text-gray-500 icon-trans-hover shrink-0"
class="p-1 text-gray-500 icon-trans-hover"
title="Profile"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -444,20 +437,6 @@
/>
</svg>
</button>
<button
onclick={() => (showTodoPanel = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Todo List"
>
<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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
</button>
<button
onclick={() => (showGitPanel = true)}
class="p-1 text-gray-500 icon-trans-hover"
@@ -472,34 +451,6 @@
/>
</svg>
</button>
<button
onclick={() => (showPluginPanel = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Plugin Management"
>
<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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
</button>
<button
onclick={() => (showMcpPanel = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="MCP Server Management"
>
<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="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
/>
</svg>
</button>
<button
onclick={toggleEditor}
disabled={connectionStatus !== "connected"}
@@ -696,7 +647,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div>
<div class="fixed top-14 right-4 z-50 max-h-[calc(100vh-4rem)] overflow-y-auto">
<div class="fixed top-14 right-4 z-50">
<StatsDisplay
onRequestSummary={handleCompactConversation}
onStartFreshWithContext={handleStartFreshWithContext}
@@ -721,10 +672,6 @@
<SessionHistoryPanel onClose={() => (showSessionHistory = false)} />
{/if}
{#if showTodoPanel}
<TodoPanel onClose={() => (showTodoPanel = false)} />
{/if}
{#if showGitPanel}
<GitPanel isOpen={showGitPanel} onClose={() => (showGitPanel = false)} />
{/if}
@@ -736,40 +683,3 @@
{#if showAgentMonitor}
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
{/if}
{#if showPluginPanel}
<PluginManagementPanel onClose={() => (showPluginPanel = false)} />
{/if}
{#if showMcpPanel}
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
{/if}
<style>
/* Responsive status bar styling */
.status-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
/* Make all icon buttons shrink but not grow */
.status-bar button {
flex-shrink: 0;
}
/* Hide version text on very small screens */
@media (max-width: 640px) {
.status-bar button span:last-of-type {
display: none;
}
}
/* Stack left and right sections on very small screens */
@media (max-width: 768px) {
.status-bar {
flex-direction: column;
gap: 0.75rem;
}
}
</style>
-81
View File
@@ -1,81 +0,0 @@
<script lang="ts">
let currentTime = $state("");
function updateTime() {
const now = new Date();
// Format date as "1 January 2026"
const day = now.getDate();
const month = now.toLocaleString("en-GB", { month: "long" });
const year = now.getFullYear();
// Format time as HH:MM:SS (24-hour)
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
const seconds = String(now.getSeconds()).padStart(2, "0");
currentTime = `${day} ${month} ${year}, ${hours}:${minutes}:${seconds}`;
}
// Update immediately on mount
updateTime();
// Update every second
const interval = setInterval(updateTime, 1000);
// Cleanup on component destroy
$effect(() => {
return () => {
clearInterval(interval);
};
});
</script>
<div class="system-clock">
<svg
class="clock-icon"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span class="clock-text">{currentTime}</span>
</div>
<style>
.system-clock {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
font-size: 0.85rem;
font-family: var(--font-mono, monospace);
transition: all 0.2s;
margin-left: auto;
}
.system-clock:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.clock-icon {
flex-shrink: 0;
opacity: 0.7;
}
.clock-text {
white-space: nowrap;
}
</style>
-149
View File
@@ -1,149 +0,0 @@
/**
* SystemClock Component Tests
*
* Note: This file tests the time formatting logic used by the SystemClock component.
* Full component rendering tests are challenging with Svelte 5 + @testing-library/svelte
* due to SSR/CSR compatibility issues. The component itself is simple and visually
* testable - it displays the current date and time, updating every second.
*
* What this component does:
* - Displays date in British format: "7 February 2026"
* - Displays time in 24-hour format: "14:35:42"
* - Updates every second via setInterval
* - Cleans up interval on unmount via $effect
*
* Manual testing checklist:
* - [ ] Clock appears above the Send button
* - [ ] Time updates every second
* - [ ] Date format is "DD Month YYYY"
* - [ ] Time format is "HH:MM:SS" (24-hour)
* - [ ] Hover effect works (border turns accent colour)
*/
import { describe, it, expect } from "vitest";
// Helper function that mirrors the component's formatting logic
function formatDateTime(date: Date): string {
const day = date.getDate();
const month = date.toLocaleString("en-GB", { month: "long" });
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${day} ${month} ${year}, ${hours}:${minutes}:${seconds}`;
}
describe("SystemClock date/time formatting", () => {
it("formats date in British format (DD Month YYYY)", () => {
// Use local timezone (not UTC) since the component uses local time
const date = new Date(2026, 1, 7, 14, 35, 42); // Feb 7, 2026 14:35:42 local
const formatted = formatDateTime(date);
expect(formatted).toContain("7 February 2026");
});
it("formats time in 24-hour format (HH:MM:SS)", () => {
const date = new Date(2026, 1, 7, 14, 35, 42);
const formatted = formatDateTime(date);
// Should have the pattern HH:MM:SS
expect(formatted).toMatch(/\d{2}:\d{2}:\d{2}/);
expect(formatted).toContain("14:35:42");
});
it("combines date and time with comma separator", () => {
const date = new Date(2026, 1, 7, 14, 35, 42);
const formatted = formatDateTime(date);
expect(formatted).toBe("7 February 2026, 14:35:42");
});
it("pads single-digit hours, minutes, and seconds with zeros", () => {
const date = new Date(2026, 1, 7, 3, 5, 8);
const formatted = formatDateTime(date);
// Should have leading zeros: 03:05:08, not 3:5:8
expect(formatted).toContain("03:05:08");
});
it("handles different months correctly", () => {
const date = new Date(2026, 11, 25, 12, 0, 0); // December is month 11
const formatted = formatDateTime(date);
expect(formatted).toContain("25 December 2026");
});
it("handles year changes correctly", () => {
const date = new Date(2027, 0, 1, 0, 0, 0); // January is month 0
const formatted = formatDateTime(date);
expect(formatted).toContain("1 January 2027");
expect(formatted).toContain("00:00:00");
});
it("handles midnight correctly", () => {
const date = new Date(2026, 1, 7, 0, 0, 0);
const formatted = formatDateTime(date);
expect(formatted).toContain("00:00:00");
});
it("handles noon correctly", () => {
const date = new Date(2026, 1, 7, 12, 0, 0);
const formatted = formatDateTime(date);
// 24-hour format, so noon is 12:00:00, not 00:00:00
expect(formatted).toContain("12:00:00");
});
it("handles end of day correctly", () => {
const date = new Date(2026, 1, 7, 23, 59, 59);
const formatted = formatDateTime(date);
expect(formatted).toContain("23:59:59");
});
it("handles month boundaries correctly", () => {
// Last day of January
const jan31 = new Date(2026, 0, 31, 23, 59, 59);
expect(formatDateTime(jan31)).toContain("31 January 2026");
// First day of February
const feb1 = new Date(2026, 1, 1, 0, 0, 0);
expect(formatDateTime(feb1)).toContain("1 February 2026");
});
it("handles leap year February correctly", () => {
// 2024 is a leap year
const feb29 = new Date(2024, 1, 29, 12, 0, 0);
const formatted = formatDateTime(feb29);
expect(formatted).toContain("29 February 2024");
});
it("handles all 12 months correctly", () => {
const months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
months.forEach((month, index) => {
const date = new Date(2026, index, 15, 12, 0, 0);
const formatted = formatDateTime(date);
expect(formatted).toContain(month);
});
});
});
+1 -15
View File
@@ -4,10 +4,9 @@
import ConversationTabs from "./ConversationTabs.svelte";
import Markdown from "./Markdown.svelte";
import HighlightedText from "./HighlightedText.svelte";
import ThinkingBlock from "./ThinkingBlock.svelte";
import { searchState, searchQuery } from "$lib/stores/search";
import { clipboardStore } from "$lib/stores/clipboard";
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
import { shouldHidePaths, maskPaths } from "$lib/stores/config";
let terminalElement: HTMLDivElement;
let shouldAutoScroll = true;
@@ -25,11 +24,6 @@
hidePaths = value;
});
let showThinking = true;
showThinkingBlocks.subscribe((value) => {
showThinking = value;
});
claudeStore.terminalLines.subscribe((value) => {
lines = value;
});
@@ -90,8 +84,6 @@
return "terminal-tool";
case "error":
return "terminal-error";
case "thinking":
return "terminal-thinking";
default:
return "terminal-default";
}
@@ -217,11 +209,6 @@
</div>
{:else}
{#each lines as line (line.id)}
{#if line.type === "thinking"}
{#if showThinking}
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
{/if}
{:else}
<div
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
style={line.parentToolUseId
@@ -296,7 +283,6 @@
/>
{/if}
</div>
{/if}
{/each}
{/if}
</div>
-125
View File
@@ -1,125 +0,0 @@
<script lang="ts">
interface Props {
content: string;
timestamp: Date;
}
let { content, timestamp }: Props = $props();
let isExpanded = $state(false);
function toggleExpanded() {
isExpanded = !isExpanded;
}
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
}
</script>
<div class="thinking-block">
<button class="thinking-header" onclick={toggleExpanded} type="button">
<span class="thinking-timestamp">{formatTime(timestamp)}</span>
<svg
class="thinking-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
width="16"
height="16"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
<span class="thinking-label">Extended Thinking</span>
<svg
class="chevron"
class:expanded={isExpanded}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
width="14"
height="14"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if isExpanded}
<div class="thinking-content">
{content}
</div>
{/if}
</div>
<style>
.thinking-block {
margin-bottom: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
background: var(--bg-secondary);
opacity: 0.85;
}
.thinking-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
cursor: pointer;
color: var(--text-secondary);
font-size: 0.875rem;
transition: all 0.2s;
}
.thinking-header:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
.thinking-timestamp {
font-family: monospace;
font-size: 0.75rem;
opacity: 0.7;
}
.thinking-icon {
flex-shrink: 0;
}
.thinking-label {
flex: 1;
text-align: left;
font-style: italic;
}
.chevron {
flex-shrink: 0;
transition: transform 0.2s;
}
.chevron.expanded {
transform: rotate(180deg);
}
.thinking-content {
padding: 0.75rem;
border-top: 1px solid var(--border-color);
color: var(--text-secondary);
font-family: monospace;
font-size: 0.875rem;
font-style: italic;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
</style>
-182
View File
@@ -1,182 +0,0 @@
<script lang="ts">
import { todos } from "$lib/stores/todos";
import { CheckCircle, Circle, Loader } from "lucide-svelte";
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
const currentTodos = $derived($todos);
const hasTodos = $derived(currentTodos.length > 0);
const completedCount = $derived(currentTodos.filter((t) => t.status === "completed").length);
const totalCount = $derived(currentTodos.length);
</script>
<div
class="fixed top-0 right-0 h-full w-96 bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
>
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
<div class="flex items-center gap-3">
<div class="text-[var(--accent-primary)]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Hikari's Todo List</h2>
{#if hasTodos}
<p class="text-xs text-[var(--text-secondary)]">
{completedCount} of {totalCount} completed
</p>
{/if}
</div>
</div>
<button
onclick={onClose}
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
aria-label="Close todo panel"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-4">
{#if !hasTodos}
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 mb-4 opacity-50"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p class="text-center">No active todos</p>
<p class="text-sm text-center mt-2">I'll update this when I start working on tasks!</p>
</div>
{:else}
<div class="space-y-2">
{#each currentTodos as todo (todo.content)}
<div
class="group bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all"
class:opacity-60={todo.status === "completed"}
>
<div class="flex items-start gap-3">
<!-- Status Icon -->
<div class="mt-0.5 flex-shrink-0">
{#if todo.status === "completed"}
<CheckCircle class="w-5 h-5 text-[var(--success-color)]" />
{:else if todo.status === "in_progress"}
<Loader class="w-5 h-5 text-[var(--accent-primary)] animate-spin" />
{:else}
<Circle class="w-5 h-5 text-[var(--text-secondary)]" />
{/if}
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<p
class="text-sm font-medium"
class:text-[var(--text-secondary)]={todo.status === "completed"}
class:line-through={todo.status === "completed"}
class:text-[var(--text-primary)]={todo.status !== "completed"}
>
{todo.status === "in_progress" ? todo.activeForm : todo.content}
</p>
<!-- Status Badge -->
<div class="mt-1">
{#if todo.status === "completed"}
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[var(--success-color)]/20 text-[var(--success-color)] border border-[var(--success-color)]/30"
>
âś“ Completed
</span>
{:else if todo.status === "in_progress"}
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] border border-[var(--accent-primary)]/30 animate-pulse"
>
⚡ In Progress
</span>
{:else}
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-color)]"
>
â—‹ Pending
</span>
{/if}
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Footer with Progress Bar -->
{#if hasTodos}
<div class="border-t border-[var(--accent-primary)]/30 p-4 bg-[var(--bg-secondary)]/50">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-[var(--text-secondary)]">Progress</span>
<span class="text-xs font-medium text-[var(--accent-primary)]">
{Math.round((completedCount / totalCount) * 100)}%
</span>
</div>
<div class="w-full bg-[var(--bg-secondary)] rounded-full h-2 overflow-hidden">
<div
class="bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] h-2 rounded-full transition-all duration-500 ease-out"
style="width: {(completedCount / totalCount) * 100}%"
></div>
</div>
</div>
{/if}
</div>
<style>
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
</style>
-325
View File
@@ -1,325 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import { agentStore, getAgentsForConversation, runningAgentCount } from "./agents";
import { get } from "svelte/store";
import type { AgentInfo } from "$lib/types/agents";
describe("agents store", () => {
const conversationId = "test-conversation-1";
const otherConversationId = "test-conversation-2";
const createMockAgent = (overrides?: Partial<AgentInfo>): AgentInfo => ({
toolUseId: "toolu_test123",
description: "Test agent",
subagentType: "Explore",
startedAt: Date.now(),
status: "running",
...overrides,
});
beforeEach(() => {
// Clear all conversations by subscribing and getting state
let state: Record<string, AgentInfo[]> = {};
const unsub = agentStore.subscribe((s) => {
state = s;
});
unsub();
// Clear each conversation
for (const convId of Object.keys(state)) {
agentStore.clearConversation(convId);
}
});
describe("addAgent", () => {
it("adds an agent to a conversation", () => {
const agent = createMockAgent();
agentStore.addAgent(conversationId, agent);
const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(1);
expect(agents[0]).toEqual(agent);
});
it("adds multiple agents to the same conversation", () => {
const agent1 = createMockAgent({ toolUseId: "tool1" });
const agent2 = createMockAgent({ toolUseId: "tool2" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(conversationId, agent2);
const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(2);
expect(agents[0]).toEqual(agent1);
expect(agents[1]).toEqual(agent2);
});
it("keeps agents in different conversations separate", () => {
const agent1 = createMockAgent({ toolUseId: "tool1" });
const agent2 = createMockAgent({ toolUseId: "tool2" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(otherConversationId, agent2);
const agents1 = get(getAgentsForConversation(conversationId));
const agents2 = get(getAgentsForConversation(otherConversationId));
expect(agents1).toHaveLength(1);
expect(agents2).toHaveLength(1);
expect(agents1[0]).toEqual(agent1);
expect(agents2[0]).toEqual(agent2);
});
});
describe("updateAgentId", () => {
it("updates the agent_id for a specific agent", () => {
const agent = createMockAgent({ agentId: undefined });
agentStore.addAgent(conversationId, agent);
agentStore.updateAgentId(conversationId, agent.toolUseId, "agent-abc123");
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].agentId).toBe("agent-abc123");
});
it("does nothing if conversation doesn't exist", () => {
agentStore.updateAgentId("nonexistent", "tool1", "agent1");
// Should not throw
expect(true).toBe(true);
});
it("does nothing if tool_use_id doesn't exist", () => {
const agent = createMockAgent();
agentStore.addAgent(conversationId, agent);
agentStore.updateAgentId(conversationId, "nonexistent-tool", "agent1");
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].agentId).toBeUndefined();
});
});
describe("endAgent", () => {
it("marks an agent as completed", () => {
const agent = createMockAgent({ status: "running" });
agentStore.addAgent(conversationId, agent);
const endTime = Date.now();
agentStore.endAgent(conversationId, agent.toolUseId, endTime, false);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].status).toBe("completed");
expect(agents[0].endedAt).toBe(endTime);
expect(agents[0].durationMs).toBeGreaterThanOrEqual(0); // Duration can be 0 if timestamps are the same
});
it("marks an agent as errored", () => {
const agent = createMockAgent({ status: "running" });
agentStore.addAgent(conversationId, agent);
const endTime = Date.now();
agentStore.endAgent(conversationId, agent.toolUseId, endTime, true);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].status).toBe("errored");
expect(agents[0].endedAt).toBe(endTime);
});
it("calculates duration correctly", () => {
const startTime = Date.now() - 5000; // 5 seconds ago
const agent = createMockAgent({ startedAt: startTime, status: "running" });
agentStore.addAgent(conversationId, agent);
const endTime = Date.now();
agentStore.endAgent(conversationId, agent.toolUseId, endTime, false);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].durationMs).toBeGreaterThanOrEqual(5000);
expect(agents[0].durationMs).toBeLessThanOrEqual(6000); // Allow some buffer
});
it("does nothing if conversation doesn't exist", () => {
agentStore.endAgent("nonexistent", "tool1", Date.now(), false);
// Should not throw
expect(true).toBe(true);
});
it("does nothing if agent doesn't exist", () => {
const agent = createMockAgent();
agentStore.addAgent(conversationId, agent);
agentStore.endAgent(conversationId, "nonexistent-tool", Date.now(), false);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].status).toBe("running"); // Status unchanged
});
});
describe("markAllErrored", () => {
it("marks all running agents as errored", () => {
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
const agent3 = createMockAgent({ toolUseId: "tool3", status: "completed" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(conversationId, agent2);
agentStore.addAgent(conversationId, agent3);
agentStore.markAllErrored(conversationId);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].status).toBe("errored");
expect(agents[0].endedAt).toBeGreaterThan(0);
expect(agents[1].status).toBe("errored");
expect(agents[1].endedAt).toBeGreaterThan(0);
expect(agents[2].status).toBe("completed"); // Already completed, unchanged
});
it("does nothing if conversation doesn't exist", () => {
agentStore.markAllErrored("nonexistent");
// Should not throw
expect(true).toBe(true);
});
it("does nothing if conversation has no running agents", () => {
const agent = createMockAgent({ status: "completed" });
agentStore.addAgent(conversationId, agent);
agentStore.markAllErrored(conversationId);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].status).toBe("completed"); // Unchanged
});
});
describe("clearCompleted", () => {
it("removes completed and errored agents", () => {
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
const agent2 = createMockAgent({ toolUseId: "tool2", status: "completed" });
const agent3 = createMockAgent({ toolUseId: "tool3", status: "errored" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(conversationId, agent2);
agentStore.addAgent(conversationId, agent3);
agentStore.clearCompleted(conversationId);
const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(1);
expect(agents[0].toolUseId).toBe("tool1"); // Only running agent remains
});
it("does nothing if conversation doesn't exist", () => {
agentStore.clearCompleted("nonexistent");
// Should not throw
expect(true).toBe(true);
});
it("clears all agents if all are completed", () => {
const agent1 = createMockAgent({ toolUseId: "tool1", status: "completed" });
const agent2 = createMockAgent({ toolUseId: "tool2", status: "errored" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(conversationId, agent2);
agentStore.clearCompleted(conversationId);
const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(0);
});
});
describe("clearConversation", () => {
it("removes all agents from a conversation", () => {
const agent1 = createMockAgent({ toolUseId: "tool1" });
const agent2 = createMockAgent({ toolUseId: "tool2" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(conversationId, agent2);
agentStore.clearConversation(conversationId);
const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(0);
});
it("only removes agents from the specified conversation", () => {
const agent1 = createMockAgent({ toolUseId: "tool1" });
const agent2 = createMockAgent({ toolUseId: "tool2" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(otherConversationId, agent2);
agentStore.clearConversation(conversationId);
const agents1 = get(getAgentsForConversation(conversationId));
const agents2 = get(getAgentsForConversation(otherConversationId));
expect(agents1).toHaveLength(0);
expect(agents2).toHaveLength(1);
expect(agents2[0]).toEqual(agent2);
});
it("does nothing if conversation doesn't exist", () => {
agentStore.clearConversation("nonexistent");
// Should not throw
expect(true).toBe(true);
});
});
describe("runningAgentCount", () => {
it("counts running agents across all conversations", () => {
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
const agent3 = createMockAgent({ toolUseId: "tool3", status: "completed" });
const agent4 = createMockAgent({ toolUseId: "tool4", status: "running" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(conversationId, agent2);
agentStore.addAgent(conversationId, agent3);
agentStore.addAgent(otherConversationId, agent4);
const count = get(runningAgentCount);
expect(count).toBe(3); // 2 from first conversation + 1 from second
});
it("returns 0 when no agents are running", () => {
const agent1 = createMockAgent({ status: "completed" });
const agent2 = createMockAgent({ status: "errored" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(otherConversationId, agent2);
const count = get(runningAgentCount);
expect(count).toBe(0);
});
it("updates when agents complete", () => {
const agent = createMockAgent({ status: "running" });
agentStore.addAgent(conversationId, agent);
let count = get(runningAgentCount);
expect(count).toBe(1);
agentStore.endAgent(conversationId, agent.toolUseId, Date.now(), false);
count = get(runningAgentCount);
expect(count).toBe(0);
});
it("updates when conversation is cleared", () => {
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(conversationId, agent2);
let count = get(runningAgentCount);
expect(count).toBe(2);
agentStore.clearConversation(conversationId);
count = get(runningAgentCount);
expect(count).toBe(0);
});
});
});
-338
View File
@@ -14,7 +14,6 @@ import {
type Theme,
type CustomThemeColors,
} from "./config";
import { invoke } from "@tauri-apps/api/core";
// Mock Tauri APIs
vi.mock("@tauri-apps/api/core", () => ({
@@ -193,7 +192,6 @@ describe("config store", () => {
budget_action: "warn",
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
show_thinking_blocks: true,
};
expect(config.model).toBe("claude-sonnet-4");
@@ -239,7 +237,6 @@ describe("config store", () => {
budget_action: "warn",
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
show_thinking_blocks: true,
};
expect(config.model).toBeNull();
@@ -490,339 +487,4 @@ 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);
// Mock console.error to suppress expected error output
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// 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);
// Verify error was logged
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to save config:", expect.any(Error));
// Restore console.error
consoleErrorSpy.mockRestore();
});
});
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,
show_thinking_blocks: true,
};
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);
});
});
});
-7
View File
@@ -45,8 +45,6 @@ export interface HikariConfig {
budget_warning_threshold: number;
// Discord RPC settings
discord_rpc_enabled: boolean;
// Thinking blocks settings
show_thinking_blocks: boolean;
}
const defaultConfig: HikariConfig = {
@@ -86,7 +84,6 @@ const defaultConfig: HikariConfig = {
budget_action: "warn",
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
show_thinking_blocks: true,
};
function createConfigStore() {
@@ -300,10 +297,6 @@ export const shouldHidePaths = derived(
configStore.config,
($config) => $config.streamer_mode && $config.streamer_hide_paths
);
export const showThinkingBlocks = derived(
configStore.config,
($config) => $config.show_thinking_blocks
);
/**
* Masks file paths in text when streamer mode with hide paths is enabled.
-5
View File
@@ -10,7 +10,6 @@ import type { CharacterState } from "$lib/types/states";
import { cleanupConversationTracking } from "$lib/tauri";
import { characterState } from "$lib/stores/character";
import { sessionsStore } from "$lib/stores/sessions";
import { agentStore } from "$lib/stores/agents";
export interface ConversationSummary {
generatedAt: Date;
@@ -334,10 +333,6 @@ function createConversationsStore() {
// Clean up tracking for this conversation (including temp files)
await cleanupConversationTracking(id);
// Clean up agent tracking for this conversation
// This prevents the badge from persisting after tab close
agentStore.clearConversation(id);
conversations.update((c) => {
c.delete(id);
return c;
-67
View File
@@ -285,73 +285,6 @@ 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", () => {
-44
View File
@@ -1,44 +0,0 @@
import { writable } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
export interface TodoItem {
content: string;
status: "pending" | "in_progress" | "completed";
activeForm: string;
}
interface TodoUpdatePayload {
todos: TodoItem[];
conversation_id?: string;
}
// Create the writable store
const { subscribe, set, update } = writable<TodoItem[]>([]);
// Listen for todo updates from the backend
let unlisten: (() => void) | undefined;
export async function initializeTodoListener(): Promise<void> {
if (unlisten) {
return; // Already initialized
}
unlisten = await listen<TodoUpdatePayload>("claude:todo-update", (event) => {
set(event.payload.todos);
});
}
export function cleanupTodoListener(): void {
if (unlisten) {
unlisten();
unlisten = undefined;
}
}
// Export the store
export const todos = {
subscribe,
set,
update,
clear: () => set([]),
};
+17 -10
View File
@@ -14,7 +14,6 @@ import type {
import type { CharacterState } from "$lib/types/states";
import type { AgentStartPayload, AgentEndPayload } from "$lib/types/agents";
import { agentStore } from "$lib/stores/agents";
import { todos } from "$lib/stores/todos";
import {
initializeNotificationRules,
cleanupNotificationRules,
@@ -183,9 +182,6 @@ export async function initializeTauriListeners() {
// (permission prompts trigger reconnects and agents may complete before reconnect)
if (!skipNextGreeting && targetConversationId) {
agentStore.markAllErrored(targetConversationId);
// Clear the conversation's agents from the store on real disconnect
// This prevents agents from persisting across sessions
agentStore.clearConversation(targetConversationId);
}
// Only remove from connected set if we're not about to reconnect
@@ -200,9 +196,6 @@ export async function initializeTauriListeners() {
"system",
"Disconnected from Claude Code"
);
// Clear todos on real disconnect (not on reconnects for permissions)
todos.clear();
}
// Update character state for this conversation
@@ -279,7 +272,7 @@ export async function initializeTauriListeners() {
if (conversation_id) {
claudeStore.addLineToConversation(
conversation_id,
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
line_type as "user" | "assistant" | "system" | "tool" | "error",
content,
tool_name || undefined,
costData,
@@ -288,7 +281,7 @@ export async function initializeTauriListeners() {
} else {
// Fallback to active conversation if no conversation_id provided
claudeStore.addLine(
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
line_type as "user" | "assistant" | "system" | "tool" | "error",
content,
tool_name || undefined,
costData,
@@ -455,6 +448,10 @@ export async function initializeDiscordRpc() {
const startedAtUnixSeconds = Math.floor(startedAt.getTime() / 1000);
const model = config.model || "claude";
await invoke("log_discord_rpc", {
message: `[FRONTEND] Attempting to initialize Discord RPC: session='Idle', model='${model}', timestamp=${startedAtUnixSeconds}`,
});
console.log("Initializing Discord RPC with initial activity:", {
session_name: "Idle",
model,
@@ -467,13 +464,23 @@ export async function initializeDiscordRpc() {
startedAt: startedAtUnixSeconds,
});
await invoke("log_discord_rpc", {
message: "[FRONTEND] Discord RPC initialized successfully!",
});
console.log("Discord RPC initialized successfully with initial presence");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await invoke("log_discord_rpc", {
message: `[FRONTEND] ERROR: Failed to initialize Discord RPC: ${errorMessage}`,
});
console.error("Failed to initialize Discord RPC:", error);
console.warn("Discord RPC will be unavailable. Make sure Discord is running.");
}
} else {
console.log("Discord RPC is disabled in config, skipping initialization");
await invoke("log_discord_rpc", {
message: "[FRONTEND] Discord RPC is disabled in config, skipping initialization",
});
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
export interface TerminalLine {
id: string;
type: "user" | "assistant" | "system" | "tool" | "error" | "thinking";
type: "user" | "assistant" | "system" | "tool" | "error";
content: string;
timestamp: Date;
toolName?: string;
-175
View File
@@ -135,151 +135,6 @@ 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", () => {
@@ -337,36 +192,6 @@ 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", () => {
-7
View File
@@ -33,9 +33,7 @@
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
import UpdateNotification from "$lib/components/UpdateNotification.svelte";
import CloseAppConfirmModal from "$lib/components/CloseAppConfirmModal.svelte";
import MemoryBrowserPanel from "$lib/components/MemoryBrowserPanel.svelte";
import { debugConsoleStore } from "$lib/stores/debugConsole";
import { initializeTodoListener, cleanupTodoListener } from "$lib/stores/todos";
let initialized = false;
let updateNotification: UpdateNotification | undefined = $state(undefined);
@@ -446,9 +444,6 @@
// Initialize Discord RPC
await initializeDiscordRpc();
// Initialize todo listener
await initializeTodoListener();
// Listen for window close requests
const unlisten = await listen("window-close-requested", () => {
handleCloseRequest();
@@ -465,7 +460,6 @@
if (initialized) {
cleanupTauriListeners();
cleanupNotificationSync();
cleanupTodoListener();
stopDiscordRpc();
window.removeEventListener("keydown", handleGlobalKeydown);
initialized = false;
@@ -519,7 +513,6 @@
<PermissionModal />
<UserQuestionModal />
<ConfigSidebar />
<MemoryBrowserPanel />
<AchievementNotification />
<AchievementsPanel
bind:isOpen={achievementPanelOpen}