Compare commits

..

8 Commits

Author SHA1 Message Date
minori 3b15384e1b deps: update @codemirror/view to 6.39.13
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m24s
CI / Lint & Test (pull_request) Successful in 17m9s
CI / Build Linux (pull_request) Successful in 20m13s
CI / Build Windows (cross-compile) (pull_request) Successful in 30m7s
2026-02-18 07:11:05 -08:00
minori 9ee89c94e2 deps: update @codemirror/view to 6.39.12
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m15s
CI / Lint & Test (pull_request) Successful in 20m30s
CI / Build Linux (pull_request) Successful in 26m55s
CI / Build Windows (cross-compile) (pull_request) Successful in 39m15s
2026-02-10 07:06:42 -08:00
naomi bd3438c7be release: v1.5.1
CI / Lint & Test (push) Successful in 17m29s
CI / Build Linux (push) Successful in 21m16s
CI / Build Windows (cross-compile) (push) Successful in 31m1s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m0s
2026-02-08 13:56:48 -08:00
hikari 778e016bf5 fix: memory files tab empty on Windows (#140)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 3m39s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
## Summary

Fixes the memory files tab showing as empty on Windows production builds and the "forbidden path" error when trying to read memory files.

## Changes

### 1. List memory files from WSL home directory (commit 1)
- Split `list_memory_files()` into platform-specific implementations
- **Windows**: Use WSL command with `bash -l` to find memory files in WSL home (`~/.claude/projects/.../memory/`)
- **Linux/Mac**: Continue using native filesystem access
- Previously used `dirs::home_dir()` which returns Windows home (`C:\Users\...`), but Claude Code stores files in WSL home

### 2. Use backend command for reading files (commit 2)
- Changed frontend from Tauri's `readTextFile` plugin to `read_file_content` backend command
- Tauri plugin enforces scope restrictions and can't access WSL paths on Windows
- Our backend command already handles WSL paths correctly via `read_file_via_wsl()`
- Matches the pattern used throughout the app for other file operations

## Testing

-  All 426 backend tests pass
-  All frontend tests pass
-  Lint, format, and type checks pass
-  Follows existing WSL file operation patterns in codebase

## Related Issues

Fixes the memory files tab functionality on Windows whilst maintaining full compatibility with Linux/Mac.

 This PR was created by Hikari~ 🌸

Reviewed-on: #140
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-08 13:51:09 -08:00
hikari 0ea7861047 fix: execute Claude CLI commands through WSL on Windows (#139)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m51s
CI / Lint & Test (push) Has started running
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Resolves #137

## Summary

Claude CLI commands (plugin list, MCP list, version check, etc.) were being executed directly in Windows context where the `claude` binary doesn't exist, causing "program not found" errors across the UI.

This PR adds a helper function that automatically prefixes commands with `wsl` on Windows builds, ensuring all Claude CLI commands execute in the correct context.

## Changes

- **Added `create_claude_command()` helper function** that:
  - On Windows: Creates command with `wsl claude` prefix
  - On Linux/Mac: Creates command with `claude` directly

- **Updated 8 command functions** to use the helper:
  - `get_claude_version`
  - `list_plugins`
  - `install_plugin`
  - `uninstall_plugin`
  - `list_mcp_servers`
  - `remove_mcp_server`
  - `add_mcp_server`
  - `get_mcp_server_details`

- **Added comprehensive tests** for both Windows and Linux contexts

## What This Fixes

 Memory pane will now display files correctly
 CLI version will be detected properly
 Plugin pane will work correctly
 MCP servers pane will function properly
 All Claude CLI commands will execute in the correct context on Windows

## Testing

-  All 427 backend tests pass (added 1 new test)
-  All 387 frontend tests pass
-  All linting and formatting checks pass
-  `check-all.sh` reports: " All checks passed!"

 This fix was created by Hikari~ 🌸

Reviewed-on: #139
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-08 13:48:03 -08:00
hikari 381bc8410a fix: validate Claude binary installation before connection (#138)
CI / Build Linux (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
## Summary

Add validation to check that the Claude CLI is installed before attempting to start a connection. If the `claude` binary is not found, users receive a helpful error message with installation instructions.

## Changes

-  Add Claude binary check using `which` command in `WslBridge::start()`
-  Return clear error message with installation command if not found
-  Add test coverage for the binary check logic (`test_claude_binary_check_command_structure`)
-  Update `CLAUDE.md` with Quality Assurance section documenting `check-all.sh`

## Error Message

If Claude Code is not installed, users will see:
```
Claude Code is not installed. Please install it using:

curl -fsSL https://claude.ai/install.sh | bash
```

## Testing

- All 427 backend tests pass 
- All 387 frontend tests pass 
- `check-all.sh` passes with no errors 
- New test validates the `which claude` command structure

## Documentation Updates

Added comprehensive Quality Assurance section to `CLAUDE.md` explaining:
- How to run `check-all.sh` before committing
- What checks are included and their order
- How to source necessary binaries (nvm for Node.js)
- Troubleshooting steps for failures

 This pull request was created by Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #138
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-08 13:47:43 -08:00
naomi fdb356a62c release: v1.5.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m6s
CI / Lint & Test (push) Successful in 16m27s
CI / Build Linux (push) Successful in 20m36s
CI / Build Windows (cross-compile) (push) Successful in 30m33s
2026-02-07 21:20:11 -08:00
hikari f173892aaa feat: major feature additions and improvements (#135)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
## Summary

This PR includes major feature additions, bug fixes, comprehensive testing improvements, and responsive design enhancements!

## New Features 

### Plugin & MCP Management (#133, #134)
- **Plugin Management Panel**: Install, uninstall, enable/disable, and update plugins
- **MCP Server Management Panel**: Add/remove MCP servers, view detailed configuration
- **Marketplace Management**: Add/remove plugin marketplaces from GitHub
- Backend commands for full CLI integration (`list_plugins`, `install_plugin`, `add_mcp_server`, etc.)
- Beautiful UI with proper loading states, error handling, and theme support

### Visual Todo List Panel (#132)
- Real-time todo list display when Hikari uses the `TodoWrite` tool
- Shows pending/in-progress/completed status with visual indicators
- Progress bar and completion count
- Automatically clears on disconnect
- Theme-aware styling

### Clear Session History Button (#130)
- "Clear All Sessions" button in Session History panel
- Confirmation dialog with session count
- Keyboard support and accessibility features
- Gives users control over disk usage

### CLI Version Display (#131)
- Displays Claude CLI version in status bar
- Auto-polls every 30 seconds for updates
- Useful for debugging and feature compatibility

## Bug Fixes 🐛

### Stats Panel Scrolling (#136)
- **Fixed stats panel overflow**: Added scrollable container with `max-height` constraint
- Stats panel now scrolls when content (Tools Used, Historical Costs, Budget sections) gets too long
- Prevents content from overflowing off screen

### Agent Monitor Fixes (#122)
- **Fixed agents stuck in "running" state**: Added `SubagentStop` hook parsing
- **Fixed agents persisting after disconnect**: Call `clearConversation()` on disconnect
- **Fixed "Kill All" button**: Now properly marks all agents as errored
- **Fixed badge persisting after tab close**: Cleanup agents when conversation is deleted
- Comprehensive tests for agent lifecycle management

### Discord RPC Cleanup (#129)
- Removed file-based logging for Discord RPC
- Replaced with proper `tracing` framework usage
- Reduces disk usage and eliminates maintenance burden

### Close Modal Bug Fix (#128)
- Fixed close confirmation modal not triggering after Discord RPC refactor
- Removed frontend calls to deleted `log_discord_rpc` command
- Modal now works correctly after all operations

### Responsive Design Fixes (#118)
- Fixed top navigation icons getting cut off at small screen widths
- Fixed Connect button disappearing on narrow screens
- Fixed bottom status info (clock, CLI version) getting cut off
- Added flex-wrap and mobile-optimised layouts
- Icons-only mode on screens < 640px
- Vertical stacking on screens < 768px

## Testing Improvements 🧪

### Comprehensive Test Coverage (#114)
- **417 backend tests** (up from 408)
- **387 frontend tests** (up from 363)
- **61%+ backend code coverage**
- Added E2E integration tests for cross-platform notification commands
- New test files: `agents.test.ts`, comprehensive CLI parsing tests
- Tests for `debug_logger.rs`, `bridge_manager.rs`, `notifications.rs`
- Console mocking for cleaner test output
- Fixed flaky frontend tests

### Testing Documentation
- Updated CLAUDE.md with comprehensive testing guidelines
- Documented mocking approaches (console mocking, E2E command structure testing)
- Added step-by-step guide for adding tests to new features
- Goal to maintain ~100% test coverage documented

## Closes

Closes #114
Closes #118
Closes #122
Closes #128
Closes #129
Closes #130
Closes #131
Closes #132
Closes #133
Closes #134
Closes #136

## Technical Details

- All new backend commands properly registered in `lib.rs`
- CLI output parsing with comprehensive test coverage
- Cross-platform compatibility verified through E2E tests (Linux CI can test Windows commands)
- Theme-aware UI components using CSS variables throughout
- Proper TypeScript types for all new stores and components
- ESLint and Prettier compliant
- All Clippy warnings addressed

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #135
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-07 21:15:41 -08:00
23 changed files with 1899 additions and 230 deletions
+101
View File
@@ -47,6 +47,65 @@ All new features, fixes, and significant changes should include tests whenever p
- Use descriptive test names that explain what behaviour is being tested - Use descriptive test names that explain what behaviour is being tested
- Include edge cases and error conditions in test coverage - Include edge cases and error conditions in test coverage
- Mock Tauri APIs using the patterns in `vitest.setup.ts` - 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 ### Example Test Structure
@@ -71,6 +130,48 @@ describe("FeatureName", () => {
}); });
``` ```
### 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 ## 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! 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!
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "hikari-desktop", "name": "hikari-desktop",
"version": "1.4.0", "version": "1.5.1",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -51,7 +51,7 @@
"@codemirror/legacy-modes": "^6.5.2", "@codemirror/legacy-modes": "^6.5.2",
"@codemirror/state": "^6.5.4", "@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.11", "@codemirror/view": "6.39.13",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-clipboard-manager": "^2.3.2",
+16 -16
View File
@@ -81,8 +81,8 @@ importers:
specifier: ^6.1.3 specifier: ^6.1.3
version: 6.1.3 version: 6.1.3
'@codemirror/view': '@codemirror/view':
specifier: ^6.39.11 specifier: 6.39.13
version: 6.39.11 version: 6.39.13
'@lezer/highlight': '@lezer/highlight':
specifier: ^1.2.3 specifier: ^1.2.3
version: 1.2.3 version: 1.2.3
@@ -323,8 +323,8 @@ packages:
'@codemirror/theme-one-dark@6.1.3': '@codemirror/theme-one-dark@6.1.3':
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
'@codemirror/view@6.39.11': '@codemirror/view@6.39.13':
resolution: {integrity: sha512-bWdeR8gWM87l4DB/kYSF9A+dVackzDb/V56Tq7QVrQ7rn86W0rgZFtlL3g3pem6AeGcb9NQNoy3ao4WpW4h5tQ==} resolution: {integrity: sha512-QBO8ZsgJLCbI28KdY0/oDy5NQLqOQVZCozBknxc2/7L98V+TVYFHnfaCsnGh1U+alpd2LOkStVwYY7nW2R1xbw==}
'@csstools/color-helpers@5.1.0': '@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
@@ -2192,14 +2192,14 @@ snapshots:
dependencies: dependencies:
'@codemirror/language': 6.12.1 '@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11 '@codemirror/view': 6.39.13
'@lezer/common': 1.5.0 '@lezer/common': 1.5.0
'@codemirror/commands@6.8.1': '@codemirror/commands@6.8.1':
dependencies: dependencies:
'@codemirror/language': 6.12.1 '@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11 '@codemirror/view': 6.39.13
'@lezer/common': 1.5.0 '@lezer/common': 1.5.0
'@codemirror/lang-angular@0.1.4': '@codemirror/lang-angular@0.1.4':
@@ -2239,7 +2239,7 @@ snapshots:
'@codemirror/lang-javascript': 6.2.4 '@codemirror/lang-javascript': 6.2.4
'@codemirror/language': 6.12.1 '@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11 '@codemirror/view': 6.39.13
'@lezer/common': 1.5.0 '@lezer/common': 1.5.0
'@lezer/css': 1.3.0 '@lezer/css': 1.3.0
'@lezer/html': 1.3.13 '@lezer/html': 1.3.13
@@ -2255,7 +2255,7 @@ snapshots:
'@codemirror/language': 6.12.1 '@codemirror/language': 6.12.1
'@codemirror/lint': 6.9.3 '@codemirror/lint': 6.9.3
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11 '@codemirror/view': 6.39.13
'@lezer/common': 1.5.0 '@lezer/common': 1.5.0
'@lezer/javascript': 1.5.4 '@lezer/javascript': 1.5.4
@@ -2278,7 +2278,7 @@ snapshots:
'@codemirror/lang-html': 6.4.11 '@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.1 '@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11 '@codemirror/view': 6.39.13
'@lezer/common': 1.5.0 '@lezer/common': 1.5.0
'@lezer/markdown': 1.6.3 '@lezer/markdown': 1.6.3
@@ -2341,7 +2341,7 @@ snapshots:
'@codemirror/autocomplete': 6.20.0 '@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1 '@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11 '@codemirror/view': 6.39.13
'@lezer/common': 1.5.0 '@lezer/common': 1.5.0
'@lezer/xml': 1.0.6 '@lezer/xml': 1.0.6
@@ -2358,7 +2358,7 @@ snapshots:
'@codemirror/language@6.12.1': '@codemirror/language@6.12.1':
dependencies: dependencies:
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11 '@codemirror/view': 6.39.13
'@lezer/common': 1.5.0 '@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3 '@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8 '@lezer/lr': 1.4.8
@@ -2371,13 +2371,13 @@ snapshots:
'@codemirror/lint@6.9.3': '@codemirror/lint@6.9.3':
dependencies: dependencies:
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11 '@codemirror/view': 6.39.13
crelt: 1.0.6 crelt: 1.0.6
'@codemirror/search@6.6.0': '@codemirror/search@6.6.0':
dependencies: dependencies:
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11 '@codemirror/view': 6.39.13
crelt: 1.0.6 crelt: 1.0.6
'@codemirror/state@6.5.4': '@codemirror/state@6.5.4':
@@ -2388,10 +2388,10 @@ snapshots:
dependencies: dependencies:
'@codemirror/language': 6.12.1 '@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11 '@codemirror/view': 6.39.13
'@lezer/highlight': 1.2.3 '@lezer/highlight': 1.2.3
'@codemirror/view@6.39.11': '@codemirror/view@6.39.13':
dependencies: dependencies:
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
crelt: 1.0.6 crelt: 1.0.6
@@ -3230,7 +3230,7 @@ snapshots:
'@codemirror/lint': 6.9.3 '@codemirror/lint': 6.9.3
'@codemirror/search': 6.6.0 '@codemirror/search': 6.6.0
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11 '@codemirror/view': 6.39.13
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
+1 -1
View File
@@ -1636,7 +1636,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]] [[package]]
name = "hikari-desktop" name = "hikari-desktop"
version = "1.4.0" version = "1.5.1"
dependencies = [ dependencies = [
"chrono", "chrono",
"dirs 5.0.1", "dirs 5.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "hikari-desktop" name = "hikari-desktop"
version = "1.4.0" version = "1.5.1"
description = "Hikari - Claude Code Visual Assistant" description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"] authors = ["Naomi Carrigan"]
edition = "2021" edition = "2021"
+124
View File
@@ -173,3 +173,127 @@ pub type SharedBridgeManager = Arc<Mutex<BridgeManager>>;
pub fn create_shared_bridge_manager() -> SharedBridgeManager { pub fn create_shared_bridge_manager() -> SharedBridgeManager {
Arc::new(Mutex::new(BridgeManager::new())) 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());
}
}
+663 -153
View File
@@ -49,6 +49,59 @@ fn wsl_path_to_windows(wsl_path: &str) -> Option<String> {
} }
} }
/// Create a Command instance for executing Claude CLI commands
/// On Windows, this will use WSL to execute the command
/// On other platforms, it executes directly
fn create_claude_command() -> std::process::Command {
#[cfg(target_os = "windows")]
{
// Use `which` inside WSL to find the claude binary dynamically
// Non-login shells launched by `wsl` don't inherit the full user PATH,
// so we need to use a login shell to get the correct PATH
let which_output = std::process::Command::new("wsl")
.args(["-e", "bash", "-l", "-c", "which claude"])
.output();
match which_output {
Ok(output) if output.status.success() => {
let claude_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
let mut cmd = std::process::Command::new("wsl");
cmd.arg(claude_path);
cmd
}
_ => {
// Fallback to just "claude" if which fails
// This maintains backwards compatibility
let mut cmd = std::process::Command::new("wsl");
cmd.arg("claude");
cmd
}
}
}
#[cfg(not(target_os = "windows"))]
{
// Use `which` to find the claude binary dynamically
// This works regardless of how Claude Code was installed (standalone, npm, etc.)
// and avoids hardcoding paths
let which_output = std::process::Command::new("which")
.arg("claude")
.output();
match which_output {
Ok(output) if output.status.success() => {
let claude_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
std::process::Command::new(claude_path)
}
_ => {
// Fallback to just "claude" if which fails
// This maintains backwards compatibility
std::process::Command::new("claude")
}
}
}
}
#[tauri::command] #[tauri::command]
pub async fn start_claude( pub async fn start_claude(
bridge_manager: State<'_, SharedBridgeManager>, bridge_manager: State<'_, SharedBridgeManager>,
@@ -1166,6 +1219,55 @@ pub struct MemoryFilesResponse {
#[tauri::command] #[tauri::command]
pub async fn list_memory_files() -> Result<MemoryFilesResponse, String> { pub async fn list_memory_files() -> Result<MemoryFilesResponse, String> {
// On Windows, we need to look in the WSL home directory
// On Linux/Mac, use the native home directory
#[cfg(target_os = "windows")]
{
list_memory_files_via_wsl().await
}
#[cfg(not(target_os = "windows"))]
{
list_memory_files_native().await
}
}
/// List memory files via WSL (for Windows)
#[cfg(target_os = "windows")]
async fn list_memory_files_via_wsl() -> Result<MemoryFilesResponse, String> {
use std::process::Command;
// Use WSL to find all memory files in the WSL home directory
// This script finds all "memory" directories and lists their files
let script = r#"
find ~/.claude/projects -type d -name memory 2>/dev/null | while read dir; do
find "$dir" -maxdepth 1 -type f 2>/dev/null
done | sort
"#;
let output = Command::new("wsl")
.args(["-e", "bash", "-l", "-c", script])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to list memory files: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let files: Vec<String> = stdout
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| line.trim().to_string())
.collect();
Ok(MemoryFilesResponse { files })
}
/// List memory files using native filesystem (for Linux/Mac)
#[cfg(not(target_os = "windows"))]
async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
use std::fs; use std::fs;
// Get the .claude directory in the user's home // Get the .claude directory in the user's home
@@ -1233,7 +1335,7 @@ pub async fn list_memory_files() -> Result<MemoryFilesResponse, String> {
pub async fn get_claude_version() -> Result<String, String> { pub async fn get_claude_version() -> Result<String, String> {
tracing::debug!("Getting Claude CLI version"); tracing::debug!("Getting Claude CLI version");
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("--version") .arg("--version")
.output(); .output();
@@ -1268,11 +1370,62 @@ pub struct PluginInfo {
pub enabled: bool, pub enabled: bool,
} }
/// Parse plugin list output from Claude CLI
fn parse_plugin_list(stdout: &str) -> Vec<PluginInfo> {
let mut plugins = Vec::new();
// Parse text output format:
// macrodata@macrodata
// Version: 0.1.3
// Scope: user
// Status: ✔ enabled
let lines: Vec<&str> = stdout.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim();
// Look for plugin name line (starts with )
if line.starts_with("") {
let name = line.trim_start_matches("").trim().to_string();
let mut version = String::new();
let mut enabled = false;
// Parse following lines for metadata
i += 1;
while i < lines.len() {
let meta_line = lines[i].trim();
if meta_line.is_empty() || meta_line.starts_with("") {
break;
}
if meta_line.starts_with("Version:") {
version = meta_line.trim_start_matches("Version:").trim().to_string();
} else if meta_line.starts_with("Status:") {
enabled = meta_line.contains("enabled");
}
i += 1;
}
plugins.push(PluginInfo {
name,
version,
description: None,
enabled,
});
continue;
}
i += 1;
}
plugins
}
#[tauri::command] #[tauri::command]
pub async fn list_plugins() -> Result<Vec<PluginInfo>, String> { pub async fn list_plugins() -> Result<Vec<PluginInfo>, String> {
tracing::debug!("Listing Claude Code plugins"); tracing::debug!("Listing Claude Code plugins");
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("plugin") .arg("plugin")
.arg("list") .arg("list")
.output(); .output();
@@ -1281,52 +1434,7 @@ pub async fn list_plugins() -> Result<Vec<PluginInfo>, String> {
Ok(output) => { Ok(output) => {
if output.status.success() { if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
let mut plugins = Vec::new(); let plugins = parse_plugin_list(&stdout);
// Parse text output format:
// macrodata@macrodata
// Version: 0.1.3
// Scope: user
// Status: ✔ enabled
let lines: Vec<&str> = stdout.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim();
// Look for plugin name line (starts with )
if line.starts_with("") {
let name = line.trim_start_matches("").trim().to_string();
let mut version = String::new();
let mut enabled = false;
// Parse following lines for metadata
i += 1;
while i < lines.len() {
let meta_line = lines[i].trim();
if meta_line.is_empty() || meta_line.starts_with("") {
break;
}
if meta_line.starts_with("Version:") {
version = meta_line.trim_start_matches("Version:").trim().to_string();
} else if meta_line.starts_with("Status:") {
enabled = meta_line.contains("enabled");
}
i += 1;
}
plugins.push(PluginInfo {
name,
version,
description: None,
enabled,
});
continue;
}
i += 1;
}
tracing::info!("Listed {} plugins", plugins.len()); tracing::info!("Listed {} plugins", plugins.len());
Ok(plugins) Ok(plugins)
} else { } else {
@@ -1346,7 +1454,7 @@ pub async fn list_plugins() -> Result<Vec<PluginInfo>, String> {
pub async fn install_plugin(plugin_name: String) -> Result<String, String> { pub async fn install_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Installing plugin: {}", plugin_name); tracing::debug!("Installing plugin: {}", plugin_name);
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("plugin") .arg("plugin")
.arg("install") .arg("install")
.arg(&plugin_name) .arg(&plugin_name)
@@ -1375,7 +1483,7 @@ pub async fn install_plugin(plugin_name: String) -> Result<String, String> {
pub async fn uninstall_plugin(plugin_name: String) -> Result<String, String> { pub async fn uninstall_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Uninstalling plugin: {}", plugin_name); tracing::debug!("Uninstalling plugin: {}", plugin_name);
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("plugin") .arg("plugin")
.arg("uninstall") .arg("uninstall")
.arg(&plugin_name) .arg(&plugin_name)
@@ -1404,7 +1512,7 @@ pub async fn uninstall_plugin(plugin_name: String) -> Result<String, String> {
pub async fn enable_plugin(plugin_name: String) -> Result<String, String> { pub async fn enable_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Enabling plugin: {}", plugin_name); tracing::debug!("Enabling plugin: {}", plugin_name);
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("plugin") .arg("plugin")
.arg("enable") .arg("enable")
.arg(&plugin_name) .arg(&plugin_name)
@@ -1433,7 +1541,7 @@ pub async fn enable_plugin(plugin_name: String) -> Result<String, String> {
pub async fn disable_plugin(plugin_name: String) -> Result<String, String> { pub async fn disable_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Disabling plugin: {}", plugin_name); tracing::debug!("Disabling plugin: {}", plugin_name);
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("plugin") .arg("plugin")
.arg("disable") .arg("disable")
.arg(&plugin_name) .arg(&plugin_name)
@@ -1462,7 +1570,7 @@ pub async fn disable_plugin(plugin_name: String) -> Result<String, String> {
pub async fn update_plugin(plugin_name: String) -> Result<String, String> { pub async fn update_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Updating plugin: {}", plugin_name); tracing::debug!("Updating plugin: {}", plugin_name);
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("plugin") .arg("plugin")
.arg("update") .arg("update")
.arg(&plugin_name) .arg(&plugin_name)
@@ -1495,11 +1603,46 @@ pub struct MarketplaceInfo {
pub source: String, pub source: String,
} }
/// Parse marketplace list output from Claude CLI
fn parse_marketplace_list(stdout: &str) -> Vec<MarketplaceInfo> {
let mut marketplaces = Vec::new();
// Parse format:
// Configured marketplaces:
//
// claude-plugins-official
// Source: GitHub (anthropics/claude-plugins-official)
//
// macrodata
// Source: GitHub (ascorbic/macrodata)
let mut current_name: Option<String> = None;
for line in stdout.lines() {
let trimmed = line.trim();
// Look for marketplace names starting with
if trimmed.starts_with("") {
current_name = Some(trimmed.trim_start_matches("").trim().to_string());
}
// Look for Source line
else if trimmed.starts_with("Source:") && current_name.is_some() {
let source = trimmed.trim_start_matches("Source:").trim().to_string();
marketplaces.push(MarketplaceInfo {
name: current_name.take().unwrap(),
source,
});
}
}
marketplaces
}
#[tauri::command] #[tauri::command]
pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> { pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> {
tracing::debug!("Listing plugin marketplaces"); tracing::debug!("Listing plugin marketplaces");
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("plugin") .arg("plugin")
.arg("marketplace") .arg("marketplace")
.arg("list") .arg("list")
@@ -1514,36 +1657,7 @@ pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> {
} }
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
let mut marketplaces = Vec::new(); let marketplaces = parse_marketplace_list(&stdout);
// Parse format:
// Configured marketplaces:
//
// claude-plugins-official
// Source: GitHub (anthropics/claude-plugins-official)
//
// macrodata
// Source: GitHub (ascorbic/macrodata)
let mut current_name: Option<String> = None;
for line in stdout.lines() {
let trimmed = line.trim();
// Look for marketplace names starting with
if trimmed.starts_with(" ") {
current_name = Some(trimmed[2..].trim().to_string());
}
// Look for Source line
else if trimmed.starts_with("Source: ") && current_name.is_some() {
let source = trimmed[8..].trim().to_string();
marketplaces.push(MarketplaceInfo {
name: current_name.take().unwrap(),
source,
});
}
}
tracing::info!("Found {} marketplaces", marketplaces.len()); tracing::info!("Found {} marketplaces", marketplaces.len());
Ok(marketplaces) Ok(marketplaces)
} }
@@ -1561,7 +1675,7 @@ pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> {
pub async fn add_marketplace(source: String) -> Result<String, String> { pub async fn add_marketplace(source: String) -> Result<String, String> {
tracing::debug!("Adding marketplace: {}", source); tracing::debug!("Adding marketplace: {}", source);
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("plugin") .arg("plugin")
.arg("marketplace") .arg("marketplace")
.arg("add") .arg("add")
@@ -1594,7 +1708,7 @@ pub async fn add_marketplace(source: String) -> Result<String, String> {
pub async fn remove_marketplace(name: String) -> Result<String, String> { pub async fn remove_marketplace(name: String) -> Result<String, String> {
tracing::debug!("Removing marketplace: {}", name); tracing::debug!("Removing marketplace: {}", name);
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("plugin") .arg("plugin")
.arg("marketplace") .arg("marketplace")
.arg("remove") .arg("remove")
@@ -1635,11 +1749,106 @@ pub struct McpServerInfo {
pub status: Option<String>, // "Connected" or "Failed to connect" pub status: Option<String>, // "Connected" or "Failed to connect"
} }
/// Parse MCP server list output from Claude CLI
fn parse_mcp_server_list(stdout: &str) -> Vec<McpServerInfo> {
let mut servers = Vec::new();
// Parse text output format:
// asana: https://mcp.asana.com/sse (SSE) - ✓ Connected
// gitea: gitea-mcp -t stdio --host https://git.nhcarrigan.com - ✓ Connected
// plugin:macrodata:macrodata: ... - ✓ Connected
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with("Checking") {
continue;
}
// Find the last occurrence of " - ✓" or " - ✗" to split status from the rest
let (content, status) = if let Some(pos) = line.rfind(" - ✓").or_else(|| line.rfind(" - ✗")) {
let status_str = line[pos + 3..].trim().trim_start_matches("").trim_start_matches("").trim();
(line[..pos].trim(), Some(status_str.to_string()))
} else {
(line, None)
};
// Now find the name by looking for the first colon followed by either http or a command
// The format is: "name: command/url"
// But name can contain colons (e.g., "plugin:macrodata:macrodata")
// Strategy: Find the colon that separates name from content
// - If content after colon starts with "http", it's a URL (name is before first colon)
// - If content is a command, name might have colons, so find the last colon before a non-URL space-separated part
let (name, rest) = if let Some(first_colon) = content.find(':') {
let after_first_colon = content[first_colon + 1..].trim_start();
// Check if it's a URL (starts with http)
if after_first_colon.starts_with("http") {
// Name is everything before the first colon
(content[..first_colon].to_string(), after_first_colon.to_string())
} else {
// It's a command - name might contain colons (like plugin:foo:bar)
// Strategy: Commands start with a letter/word, not with a colon
// Find the rightmost colon that has whitespace after it (indicating start of command)
let mut split_pos = first_colon;
for (idx, _) in content.match_indices(':') {
let after = content[idx + 1..].trim_start();
// If what comes after this colon is NOT another colon-prefixed part,
// and doesn't start with "//" (part of URL), this is our split point
if !after.is_empty() && !after.starts_with(':') && !after.starts_with("//") {
// Check if this looks like a command (starts with letter/number)
if after.chars().next().map(|c| c.is_alphanumeric()).unwrap_or(false) {
split_pos = idx;
}
}
}
(content[..split_pos].to_string(), content[split_pos + 1..].trim_start().to_string())
}
} else {
continue; // Skip lines without colons
};
let name = name.trim().to_string();
let rest = rest.trim();
// Determine if it's a URL or command
let (url, command, transport) = if rest.starts_with("http") {
// HTTP/SSE server: "https://mcp.asana.com/sse (SSE)"
// Extract URL and transport type
let (url, transport) = if let Some((url_part, transport_part)) = rest.rsplit_once('(') {
let url = url_part.trim().to_string();
let transport = transport_part.trim_end_matches(')').trim().to_lowercase();
(Some(url), transport)
} else {
(Some(rest.to_string()), "http".to_string())
};
(url, None, transport)
} else {
// stdio server: "gitea-mcp -t stdio --host https://git.nhcarrigan.com"
// Command is everything in rest
(None, Some(rest.to_string()), "stdio".to_string())
};
servers.push(McpServerInfo {
name,
command,
url,
transport,
env: None,
status,
});
}
servers
}
#[tauri::command] #[tauri::command]
pub async fn list_mcp_servers() -> Result<Vec<McpServerInfo>, String> { pub async fn list_mcp_servers() -> Result<Vec<McpServerInfo>, String> {
tracing::debug!("Listing MCP servers"); tracing::debug!("Listing MCP servers");
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("mcp") .arg("mcp")
.arg("list") .arg("list")
.output(); .output();
@@ -1648,69 +1857,7 @@ pub async fn list_mcp_servers() -> Result<Vec<McpServerInfo>, String> {
Ok(output) => { Ok(output) => {
if output.status.success() { if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
let mut servers = Vec::new(); let servers = parse_mcp_server_list(&stdout);
// Parse text output format:
// asana: https://mcp.asana.com/sse (SSE) - ✓ Connected
// gitea: gitea-mcp -t stdio --host https://git.nhcarrigan.com - ✓ Connected
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with("Checking") {
continue;
}
// Split by colon to get name and rest
if let Some((name, rest)) = line.split_once(':') {
let name = name.trim().to_string();
let rest = rest.trim();
// Determine if it's a URL or command
let (url, command, transport, status) = if rest.starts_with("http") {
// HTTP/SSE server: "https://mcp.asana.com/sse (SSE) - ✓ Connected"
let parts: Vec<&str> = rest.split('-').collect();
let url_and_transport = parts[0].trim();
let status = if parts.len() > 1 {
Some(parts[1].trim().trim_start_matches("").trim_start_matches("").trim().to_string())
} else {
None
};
// Extract URL and transport type
let (url, transport) = if let Some((url_part, transport_part)) = url_and_transport.rsplit_once('(') {
let url = url_part.trim().to_string();
let transport = transport_part.trim_end_matches(')').trim().to_lowercase();
(Some(url), transport)
} else {
(Some(url_and_transport.to_string()), "http".to_string())
};
(url, None, transport, status)
} else {
// stdio server: "gitea-mcp -t stdio --host https://git.nhcarrigan.com - ✓ Connected"
let parts: Vec<&str> = rest.split('-').collect();
let command = parts[0].trim().to_string();
let status = if parts.len() > 1 {
let status_part = parts[parts.len() - 1];
Some(status_part.trim().trim_start_matches("").trim_start_matches("").trim().to_string())
} else {
None
};
(None, Some(command), "stdio".to_string(), status)
};
servers.push(McpServerInfo {
name,
command,
url,
transport,
env: None,
status,
});
}
}
tracing::info!("Listed {} MCP servers", servers.len()); tracing::info!("Listed {} MCP servers", servers.len());
Ok(servers) Ok(servers)
} else { } else {
@@ -1743,7 +1890,7 @@ pub async fn get_mcp_server(name: String) -> Result<McpServerInfo, String> {
pub async fn remove_mcp_server(name: String) -> Result<String, String> { pub async fn remove_mcp_server(name: String) -> Result<String, String> {
tracing::debug!("Removing MCP server: {}", name); tracing::debug!("Removing MCP server: {}", name);
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("mcp") .arg("mcp")
.arg("remove") .arg("remove")
.arg(&name) .arg(&name)
@@ -1778,7 +1925,7 @@ pub async fn add_mcp_server(
) -> Result<String, String> { ) -> Result<String, String> {
tracing::debug!("Adding MCP server: {} with transport {}", name, transport); tracing::debug!("Adding MCP server: {} with transport {}", name, transport);
let mut cmd = std::process::Command::new("claude"); let mut cmd = create_claude_command();
cmd.arg("mcp").arg("add"); cmd.arg("mcp").arg("add");
// Add transport flag // Add transport flag
@@ -1826,7 +1973,7 @@ pub async fn add_mcp_server(
pub async fn get_mcp_server_details(name: String) -> Result<String, String> { pub async fn get_mcp_server_details(name: String) -> Result<String, String> {
tracing::debug!("Getting detailed info for MCP server: {}", name); tracing::debug!("Getting detailed info for MCP server: {}", name);
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("mcp") .arg("mcp")
.arg("get") .arg("get")
.arg(&name) .arg(&name)
@@ -1863,6 +2010,49 @@ mod tests {
tokio::runtime::Runtime::new().unwrap().block_on(f) tokio::runtime::Runtime::new().unwrap().block_on(f)
} }
// ==================== create_claude_command tests ====================
#[test]
#[cfg(target_os = "windows")]
fn test_create_claude_command_windows() {
// On Windows, should create a command that uses wsl with full path to claude
// The path is resolved dynamically via `which` in a login shell
let cmd = create_claude_command();
let program = cmd.get_program();
assert_eq!(program, "wsl");
// Verify the first argument is a path to claude (full path from `which`)
// or fallback to just "claude" if which fails
let args: Vec<&std::ffi::OsStr> = cmd.get_args().collect();
assert_eq!(args.len(), 1);
let arg_str = args[0].to_string_lossy();
assert!(
arg_str.contains("claude"),
"Expected argument to contain 'claude', got: {}",
arg_str
);
}
#[test]
#[cfg(not(target_os = "windows"))]
fn test_create_claude_command_linux() {
// On Linux/Mac, should create a command that uses the full path to claude
// (resolved via `which` command)
let cmd = create_claude_command();
let program = cmd.get_program();
// The program should be the full path to claude (from `which`)
// or fallback to "claude" if which fails
let program_str = program.to_string_lossy();
assert!(
program_str.ends_with("claude"),
"Expected program to end with 'claude', got: {}",
program_str
);
}
// ==================== validate_directory tests ==================== // ==================== validate_directory tests ====================
#[test] #[test]
@@ -2112,4 +2302,324 @@ mod tests {
assert!(json.contains("/tmp/test.txt")); assert!(json.contains("/tmp/test.txt"));
assert!(json.contains("test.txt")); assert!(json.contains("test.txt"));
} }
// ==================== CLI Parser Tests ====================
#[test]
fn test_parse_plugin_list_single_enabled() {
let output = r#" macrodata@macrodata
Version: 0.1.3
Scope: user
Status: ✔ enabled"#;
let plugins = parse_plugin_list(output);
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "macrodata@macrodata");
assert_eq!(plugins[0].version, "0.1.3");
assert!(plugins[0].enabled);
assert_eq!(plugins[0].description, None);
}
#[test]
fn test_parse_plugin_list_single_disabled() {
let output = r#" test-plugin@official
Version: 2.0.0
Status: ✘ disabled"#;
let plugins = parse_plugin_list(output);
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "test-plugin@official");
assert_eq!(plugins[0].version, "2.0.0");
assert!(!plugins[0].enabled);
}
#[test]
fn test_parse_plugin_list_multiple() {
let output = r#" macrodata@macrodata
Version: 0.1.3
Status: ✔ enabled
another-plugin@official
Version: 1.5.0
Status: ✘ disabled
third-plugin@test
Version: 3.0.0-beta
Status: ✔ enabled"#;
let plugins = parse_plugin_list(output);
assert_eq!(plugins.len(), 3);
assert_eq!(plugins[0].name, "macrodata@macrodata");
assert_eq!(plugins[0].version, "0.1.3");
assert!(plugins[0].enabled);
assert_eq!(plugins[1].name, "another-plugin@official");
assert_eq!(plugins[1].version, "1.5.0");
assert!(!plugins[1].enabled);
assert_eq!(plugins[2].name, "third-plugin@test");
assert_eq!(plugins[2].version, "3.0.0-beta");
assert!(plugins[2].enabled);
}
#[test]
fn test_parse_plugin_list_empty() {
let output = "";
let plugins = parse_plugin_list(output);
assert_eq!(plugins.len(), 0);
}
#[test]
fn test_parse_marketplace_list_single() {
let output = r#"Configured marketplaces:
claude-plugins-official
Source: GitHub (anthropics/claude-plugins-official)"#;
let marketplaces = parse_marketplace_list(output);
assert_eq!(marketplaces.len(), 1);
assert_eq!(marketplaces[0].name, "claude-plugins-official");
assert_eq!(marketplaces[0].source, "GitHub (anthropics/claude-plugins-official)");
}
#[test]
fn test_parse_marketplace_list_multiple() {
let output = r#"Configured marketplaces:
claude-plugins-official
Source: GitHub (anthropics/claude-plugins-official)
macrodata
Source: GitHub (ascorbic/macrodata)
custom-marketplace
Source: GitHub (user/custom-marketplace)"#;
let marketplaces = parse_marketplace_list(output);
assert_eq!(marketplaces.len(), 3);
assert_eq!(marketplaces[0].name, "claude-plugins-official");
assert_eq!(marketplaces[1].name, "macrodata");
assert_eq!(marketplaces[2].name, "custom-marketplace");
}
#[test]
fn test_parse_marketplace_list_empty() {
let output = "Configured marketplaces:\n\n";
let marketplaces = parse_marketplace_list(output);
assert_eq!(marketplaces.len(), 0);
}
#[test]
fn test_parse_mcp_server_list_sse_connected() {
let output = "asana: https://mcp.asana.com/sse (SSE) - ✓ Connected";
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "asana");
assert_eq!(servers[0].url, Some("https://mcp.asana.com/sse".to_string()));
assert_eq!(servers[0].command, None);
assert_eq!(servers[0].transport, "sse");
assert_eq!(servers[0].status, Some("Connected".to_string()));
}
#[test]
fn test_parse_mcp_server_list_http_connected() {
let output = "test-server: https://api.example.com/mcp (HTTP) - ✓ Connected";
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "test-server");
assert_eq!(servers[0].url, Some("https://api.example.com/mcp".to_string()));
assert_eq!(servers[0].transport, "http");
assert_eq!(servers[0].status, Some("Connected".to_string()));
}
#[test]
fn test_parse_mcp_server_list_stdio_connected() {
let output = "gitea: gitea-mcp -t stdio --host https://git.nhcarrigan.com - ✓ Connected";
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "gitea");
assert_eq!(servers[0].url, None);
assert_eq!(servers[0].command, Some("gitea-mcp -t stdio --host https://git.nhcarrigan.com".to_string()));
assert_eq!(servers[0].transport, "stdio");
assert_eq!(servers[0].status, Some("Connected".to_string()));
}
#[test]
fn test_parse_mcp_server_list_failed_connection() {
let output = "broken-server: https://invalid.com (SSE) - ✗ Failed to connect";
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "broken-server");
assert_eq!(servers[0].status, Some("Failed to connect".to_string()));
}
#[test]
fn test_parse_mcp_server_list_multiple() {
let output = r#"asana: https://mcp.asana.com/sse (SSE) - ✓ Connected
gitea: gitea-mcp -t stdio (STDIO) - ✓ Connected
notion: https://mcp.notion.so (HTTP) - ✓ Connected"#;
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 3);
assert_eq!(servers[0].name, "asana");
assert_eq!(servers[0].transport, "sse");
assert_eq!(servers[1].name, "gitea");
assert_eq!(servers[1].transport, "stdio");
assert_eq!(servers[2].name, "notion");
assert_eq!(servers[2].transport, "http");
}
#[test]
fn test_parse_mcp_server_list_with_checking_line() {
let output = r#"Checking MCP servers...
asana: https://mcp.asana.com/sse (SSE) - ✓ Connected"#;
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "asana");
}
#[test]
fn test_parse_mcp_server_list_empty() {
let output = "";
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 0);
}
#[test]
fn test_parse_mcp_server_list_plugin_provided() {
let output = "plugin:macrodata:macrodata: plugin macrodata - ✗ Failed to connect";
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "plugin:macrodata:macrodata");
assert_eq!(servers[0].command, Some("plugin macrodata".to_string()));
assert_eq!(servers[0].transport, "stdio");
}
// ==================== Edge Case Tests ====================
#[test]
fn test_parse_plugin_list_with_unicode_names() {
let output = r#" 日本語-plugin@marketplace
Version: 1.0.0
Status: ✔ enabled
émoji-🎉-plugin@marketplace
Version: 2.0.0
Status: ✗ disabled"#;
let plugins = parse_plugin_list(output);
assert_eq!(plugins.len(), 2);
assert_eq!(plugins[0].name, "日本語-plugin@marketplace");
assert!(plugins[0].enabled);
assert_eq!(plugins[1].name, "émoji-🎉-plugin@marketplace");
assert!(!plugins[1].enabled);
}
#[test]
fn test_parse_plugin_list_missing_version() {
let output = r#" broken-plugin@marketplace
Status: ✔ enabled"#;
let plugins = parse_plugin_list(output);
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "broken-plugin@marketplace");
assert_eq!(plugins[0].version, ""); // Empty version
assert!(plugins[0].enabled);
}
#[test]
fn test_parse_plugin_list_missing_status() {
let output = r#" incomplete-plugin@marketplace
Version: 1.0.0"#;
let plugins = parse_plugin_list(output);
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "incomplete-plugin@marketplace");
assert_eq!(plugins[0].version, "1.0.0");
assert!(!plugins[0].enabled); // Defaults to false when status missing
}
#[test]
fn test_parse_marketplace_list_with_unicode() {
let output = r#" 日本語-marketplace
Source: github/日本語/repo
emoji-🚀-marketplace
Source: github/emoji/🚀-repo"#;
let marketplaces = parse_marketplace_list(output);
assert_eq!(marketplaces.len(), 2);
assert_eq!(marketplaces[0].name, "日本語-marketplace");
assert_eq!(marketplaces[0].source, "github/日本語/repo");
assert_eq!(marketplaces[1].name, "emoji-🚀-marketplace");
}
#[test]
fn test_parse_mcp_server_list_with_unicode_names() {
let output = "日本語-server: https://example.com/日本語 (SSE) - ✓ Connected";
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "日本語-server");
assert_eq!(servers[0].url, Some("https://example.com/日本語".to_string()));
}
#[test]
fn test_parse_mcp_server_list_very_long_command() {
let output = "long-cmd: some-binary --flag1 value1 --flag2 value2 --flag3 value3 --flag4 value4 --flag5 value5 --very-long-option with-a-very-long-value - ✓ Connected";
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "long-cmd");
assert_eq!(
servers[0].command,
Some("some-binary --flag1 value1 --flag2 value2 --flag3 value3 --flag4 value4 --flag5 value5 --very-long-option with-a-very-long-value".to_string())
);
}
#[test]
fn test_parse_mcp_server_list_no_status() {
let output = "pending-server: https://example.com (HTTP)";
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "pending-server");
assert_eq!(servers[0].status, None);
}
#[test]
fn test_parse_plugin_list_with_extra_whitespace() {
let output = r#" whitespace-plugin@marketplace
Version: 1.0.0
Status: ✔ enabled "#;
let plugins = parse_plugin_list(output);
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "whitespace-plugin@marketplace");
assert_eq!(plugins[0].version, "1.0.0");
assert!(plugins[0].enabled);
}
#[test]
fn test_parse_mcp_server_list_multiple_with_checking() {
let output = r#"Checking connections...
asana: https://mcp.asana.com/sse (SSE) - ✓ Connected
gitea: gitea-mcp -t stdio (STDIO) - ✓ Connected"#;
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 2); // Should ignore "Checking" line
assert_eq!(servers[0].name, "asana");
assert_eq!(servers[1].name, "gitea");
}
} }
+79
View File
@@ -76,3 +76,82 @@ where
let _ = self.app.emit("debug:log", log_event); 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));
}
}
}
+318 -29
View File
@@ -1,6 +1,83 @@
use std::process::Command; use std::process::Command;
use tauri::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] #[command]
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> { pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
// Use notify-send for Linux/WSL // Use notify-send for Linux/WSL
@@ -28,34 +105,7 @@ pub async fn send_notify_send(title: String, body: String) -> Result<(), String>
#[command] #[command]
pub async fn send_windows_notification(title: String, body: String) -> Result<(), String> { pub async fn send_windows_notification(title: String, body: String) -> Result<(), String> {
// Create PowerShell script for Windows Toast Notification // Create PowerShell script for Windows Toast Notification
let ps_script = format!( let ps_script = generate_powershell_toast_script(&title, &body);
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 // Try PowerShell Core first (pwsh), then fall back to Windows PowerShell
let output = Command::new("pwsh.exe") let output = Command::new("pwsh.exe")
@@ -87,7 +137,7 @@ $toast = New-Object Windows.UI.Notifications.ToastNotification $xml
// Alternative: Use Windows built-in MSG command for simple notifications // Alternative: Use Windows built-in MSG command for simple notifications
#[command] #[command]
pub async fn send_simple_notification(title: String, body: String) -> Result<(), String> { pub async fn send_simple_notification(title: String, body: String) -> Result<(), String> {
let message = format!("{}\n\n{}", title, body); let message = format_simple_notification(&title, &body);
Command::new("cmd.exe") Command::new("cmd.exe")
.arg("/c") .arg("/c")
@@ -99,3 +149,242 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(),
Ok(()) 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");
}
}
+167
View File
@@ -129,6 +129,11 @@ impl WslBridge {
return Err("Process already running".to_string()); 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 // Load saved achievements and stats when starting a new session
let app_clone = app.clone(); let app_clone = app.clone();
let stats = self.stats.clone(); let stats = self.stats.clone();
@@ -678,6 +683,34 @@ 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 // Still emit the stderr line as output
let _ = app.emit( let _ = app.emit(
"claude:output", "claude:output",
@@ -732,6 +765,30 @@ 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( fn process_json_line(
line: &str, line: &str,
app: &AppHandle, app: &AppHandle,
@@ -1816,6 +1873,22 @@ mod tests {
assert!(!bridge.is_running()); 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] #[test]
fn test_create_shared_bridge_manager() { fn test_create_shared_bridge_manager() {
use crate::bridge_manager::create_shared_bridge_manager; use crate::bridge_manager::create_shared_bridge_manager;
@@ -1823,4 +1896,98 @@ mod tests {
let manager = shared.lock(); let manager = shared.lock();
assert!(manager.get_active_conversations().is_empty()); 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", "$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop", "productName": "hikari-desktop",
"version": "1.4.0", "version": "1.5.1",
"identifier": "com.naomi.hikari-desktop", "identifier": "com.naomi.hikari-desktop",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
@@ -118,6 +118,8 @@
try { try {
await invoke("interrupt_claude", { conversationId: currentConversationId }); await invoke("interrupt_claude", { conversationId: currentConversationId });
// Mark all running agents as errored after killing the process
agentStore.markAllErrored(currentConversationId);
} catch (error) { } catch (error) {
console.error("Failed to kill Claude process:", error); console.error("Failed to kill Claude process:", error);
} }
+13
View File
@@ -1071,6 +1071,7 @@ User: ${formattedMessage}`;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-wrap: wrap;
} }
.control-button { .control-button {
@@ -1087,6 +1088,18 @@ User: ${formattedMessage}`;
transition: all 0.2s; transition: all 0.2s;
font-size: 14px; font-size: 14px;
white-space: nowrap; 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 { .control-button:hover {
+14 -6
View File
@@ -12,7 +12,7 @@
command: string | null; command: string | null;
url: string | null; url: string | null;
transport: string; // "stdio", "http", or "sse" transport: string; // "stdio", "http", or "sse"
env: any | null; env: Record<string, string> | null;
status: string | null; // "Connected" or "Failed to connect" status: string | null; // "Connected" or "Failed to connect"
} }
@@ -184,7 +184,9 @@
<!-- Add Server Form --> <!-- Add Server Form -->
{#if showAddForm} {#if showAddForm}
<div class="mx-4 mt-4 p-4 bg-[var(--bg-secondary)]/50 border border-[var(--border-color)] rounded-lg"> <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> <h3 class="text-sm font-medium text-[var(--text-primary)] mb-3">Add MCP Server</h3>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
@@ -311,7 +313,9 @@
<!-- Server Details Panel --> <!-- Server Details Panel -->
{#if selectedServer} {#if selectedServer}
<div class="w-80 bg-[var(--bg-secondary)]/50 rounded-lg p-4 border border-[var(--border-color)]"> <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> <h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Server Details</h3>
{#if isLoadingDetails} {#if isLoadingDetails}
@@ -376,7 +380,11 @@
>Environment</label >Environment</label
> >
<pre <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> 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> </div>
{/if} {/if}
@@ -394,8 +402,8 @@
<!-- Actions --> <!-- Actions -->
<div class="pt-4 border-t border-[var(--border-color)]"> <div class="pt-4 border-t border-[var(--border-color)]">
<button <button
onclick={() => removeServer(selectedServer.name)} onclick={() => selectedServer && removeServer(selectedServer.name)}
disabled={actionInProgress === 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" 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" /> <Trash2 class="w-4 h-4" />
+2 -2
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { readTextFile } from "@tauri-apps/plugin-fs";
import Markdown from "./Markdown.svelte"; import Markdown from "./Markdown.svelte";
let memoryFiles: string[] = $state([]); let memoryFiles: string[] = $state([]);
@@ -33,7 +32,8 @@
isLoading = true; isLoading = true;
error = null; error = null;
try { try {
const content = await readTextFile(filePath); // Use our backend command instead of Tauri plugin to handle WSL paths
const content = await invoke<string>("read_file_content", { path: filePath });
fileContent = content; fileContent = content;
selectedFile = filePath; selectedFile = filePath;
} catch (e) { } catch (e) {
+18 -7
View File
@@ -17,8 +17,21 @@
let isImporting = $state(false); let isImporting = $state(false);
let showClearAllConfirm = $state(false); let showClearAllConfirm = $state(false);
const sessions = $derived(sessionsStore.sessions); let sessions = $state<SessionListItem[]>([]);
const isLoading = $derived(sessionsStore.isLoading); let isLoading = $state(false);
$effect(() => {
const unsubSessions = sessionsStore.sessions.subscribe((value) => {
sessions = value;
});
const unsubLoading = sessionsStore.isLoading.subscribe((value) => {
isLoading = value;
});
return () => {
unsubSessions();
unsubLoading();
};
});
onMount(() => { onMount(() => {
sessionsStore.loadSessions(); sessionsStore.loadSessions();
@@ -303,11 +316,11 @@
</div> </div>
<div class="overflow-y-auto flex-1"> <div class="overflow-y-auto flex-1">
{#if $isLoading} {#if isLoading}
<div class="flex items-center justify-center p-8"> <div class="flex items-center justify-center p-8">
<div class="text-[var(--text-tertiary)]">Loading sessions...</div> <div class="text-[var(--text-tertiary)]">Loading sessions...</div>
</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"> <div class="flex flex-col items-center justify-center p-8 text-center">
<svg <svg
class="w-16 h-16 text-[var(--text-tertiary)] mb-4" class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
@@ -329,7 +342,7 @@
</div> </div>
{:else} {:else}
<div class="divide-y divide-[var(--border-color)]"> <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="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<button class="flex-1 text-left" onclick={() => handleViewSession(session)}> <button class="flex-1 text-left" onclick={() => handleViewSession(session)}>
@@ -451,7 +464,6 @@
</div> </div>
{#if showClearAllConfirm} {#if showClearAllConfirm}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-center justify-center p-4" class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
onclick={() => (showClearAllConfirm = false)} onclick={() => (showClearAllConfirm = false)}
@@ -459,7 +471,6 @@
tabindex="0" tabindex="0"
onkeydown={(e) => e.key === "Escape" && (showClearAllConfirm = false)} onkeydown={(e) => e.key === "Escape" && (showClearAllConfirm = false)}
> >
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div <div
class="bg-[var(--bg-primary)] border border-red-500/30 rounded-lg shadow-xl max-w-md w-full p-6" 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()} onclick={(e) => e.stopPropagation()}
+33 -4
View File
@@ -381,16 +381,16 @@
{/if} {/if}
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-2 flex-wrap min-w-0">
{#if streamerModeActive} {#if streamerModeActive}
<div <div
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse" class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse shrink-0"
title="Streamer mode active (Ctrl+Shift+S to toggle)" title="Streamer mode active (Ctrl+Shift+S to toggle)"
></div> ></div>
{/if} {/if}
<button <button
onclick={() => (showProfile = true)} onclick={() => (showProfile = true)}
class="p-1 text-gray-500 icon-trans-hover" class="p-1 text-gray-500 icon-trans-hover shrink-0"
title="Profile" title="Profile"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -696,7 +696,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div> <div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div>
<div class="fixed top-14 right-4 z-50"> <div class="fixed top-14 right-4 z-50 max-h-[calc(100vh-4rem)] overflow-y-auto">
<StatsDisplay <StatsDisplay
onRequestSummary={handleCompactConversation} onRequestSummary={handleCompactConversation}
onStartFreshWithContext={handleStartFreshWithContext} onStartFreshWithContext={handleStartFreshWithContext}
@@ -744,3 +744,32 @@
{#if showMcpPanel} {#if showMcpPanel}
<McpManagementPanel onClose={() => (showMcpPanel = false)} /> <McpManagementPanel onClose={() => (showMcpPanel = false)} />
{/if} {/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>
+1 -6
View File
@@ -47,12 +47,7 @@
width="14" width="14"
height="14" height="14"
> >
<path <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg> </svg>
</button> </button>
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { todos, type TodoItem } from "$lib/stores/todos"; import { todos } from "$lib/stores/todos";
import { CheckCircle, Circle, Loader } from "lucide-svelte"; import { CheckCircle, Circle, Loader } from "lucide-svelte";
interface Props { interface Props {
+325
View File
@@ -0,0 +1,325 @@
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);
});
});
});
+9
View File
@@ -722,6 +722,9 @@ describe("config store", () => {
it("handles save errors gracefully without losing data", async () => { it("handles save errors gracefully without losing data", async () => {
const mockInvokeImpl = vi.mocked(invoke); const mockInvokeImpl = vi.mocked(invoke);
// Mock console.error to suppress expected error output
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Set initial config // Set initial config
await configStore.updateConfig({ font_size: 14 }); await configStore.updateConfig({ font_size: 14 });
@@ -733,6 +736,12 @@ describe("config store", () => {
// Original config should still be accessible // Original config should still be accessible
expect(configStore.getConfig().font_size).toBe(14); 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();
}); });
}); });
+5
View File
@@ -10,6 +10,7 @@ import type { CharacterState } from "$lib/types/states";
import { cleanupConversationTracking } from "$lib/tauri"; import { cleanupConversationTracking } from "$lib/tauri";
import { characterState } from "$lib/stores/character"; import { characterState } from "$lib/stores/character";
import { sessionsStore } from "$lib/stores/sessions"; import { sessionsStore } from "$lib/stores/sessions";
import { agentStore } from "$lib/stores/agents";
export interface ConversationSummary { export interface ConversationSummary {
generatedAt: Date; generatedAt: Date;
@@ -333,6 +334,10 @@ function createConversationsStore() {
// Clean up tracking for this conversation (including temp files) // Clean up tracking for this conversation (including temp files)
await cleanupConversationTracking(id); 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) => { conversations.update((c) => {
c.delete(id); c.delete(id);
return c; return c;
+3 -1
View File
@@ -183,6 +183,9 @@ export async function initializeTauriListeners() {
// (permission prompts trigger reconnects and agents may complete before reconnect) // (permission prompts trigger reconnects and agents may complete before reconnect)
if (!skipNextGreeting && targetConversationId) { if (!skipNextGreeting && targetConversationId) {
agentStore.markAllErrored(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 // Only remove from connected set if we're not about to reconnect
@@ -466,7 +469,6 @@ export async function initializeDiscordRpc() {
console.log("Discord RPC initialized successfully with initial presence"); console.log("Discord RPC initialized successfully with initial presence");
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("Failed to initialize Discord RPC:", error); console.error("Failed to initialize Discord RPC:", error);
console.warn("Discord RPC will be unavailable. Make sure Discord is running."); console.warn("Discord RPC will be unavailable. Make sure Discord is running.");
} }