generated from nhcarrigan/template
Compare commits
8 Commits
4c67380859
..
v1.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
d2e0915a75
|
|||
| d8cf5504d6 | |||
|
bd3438c7be
|
|||
| 778e016bf5 | |||
| 0ea7861047 | |||
| 381bc8410a | |||
|
fdb356a62c
|
|||
| f173892aaa |
@@ -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!
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hikari-desktop",
|
"name": "hikari-desktop",
|
||||||
"version": "1.4.0",
|
"version": "1.6.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+1
-1
@@ -1636,7 +1636,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hikari-desktop"
|
name = "hikari-desktop"
|
||||||
version = "1.4.0"
|
version = "1.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "hikari-desktop"
|
name = "hikari-desktop"
|
||||||
version = "1.4.0"
|
version = "1.6.0"
|
||||||
description = "Hikari - Claude Code Visual Assistant"
|
description = "Hikari - Claude Code Visual Assistant"
|
||||||
authors = ["Naomi Carrigan"]
|
authors = ["Naomi Carrigan"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+597
-87
@@ -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,19 +1370,8 @@ pub struct PluginInfo {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
/// Parse plugin list output from Claude CLI
|
||||||
pub async fn list_plugins() -> Result<Vec<PluginInfo>, String> {
|
fn parse_plugin_list(stdout: &str) -> Vec<PluginInfo> {
|
||||||
tracing::debug!("Listing Claude Code plugins");
|
|
||||||
|
|
||||||
let output = std::process::Command::new("claude")
|
|
||||||
.arg("plugin")
|
|
||||||
.arg("list")
|
|
||||||
.output();
|
|
||||||
|
|
||||||
match output {
|
|
||||||
Ok(output) => {
|
|
||||||
if output.status.success() {
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
let mut plugins = Vec::new();
|
let mut plugins = Vec::new();
|
||||||
|
|
||||||
// Parse text output format:
|
// Parse text output format:
|
||||||
@@ -1327,6 +1418,23 @@ pub async fn list_plugins() -> Result<Vec<PluginInfo>, String> {
|
|||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
plugins
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_plugins() -> Result<Vec<PluginInfo>, String> {
|
||||||
|
tracing::debug!("Listing Claude Code plugins");
|
||||||
|
|
||||||
|
let output = create_claude_command()
|
||||||
|
.arg("plugin")
|
||||||
|
.arg("list")
|
||||||
|
.output();
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(output) => {
|
||||||
|
if output.status.success() {
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let plugins = parse_plugin_list(&stdout);
|
||||||
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,25 +1603,8 @@ pub struct MarketplaceInfo {
|
|||||||
pub source: String,
|
pub source: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
/// Parse marketplace list output from Claude CLI
|
||||||
pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> {
|
fn parse_marketplace_list(stdout: &str) -> Vec<MarketplaceInfo> {
|
||||||
tracing::debug!("Listing plugin marketplaces");
|
|
||||||
|
|
||||||
let output = std::process::Command::new("claude")
|
|
||||||
.arg("plugin")
|
|
||||||
.arg("marketplace")
|
|
||||||
.arg("list")
|
|
||||||
.output();
|
|
||||||
|
|
||||||
match output {
|
|
||||||
Ok(output) => {
|
|
||||||
if !output.status.success() {
|
|
||||||
let error = String::from_utf8_lossy(&output.stderr);
|
|
||||||
tracing::error!("Failed to list marketplaces: {}", error);
|
|
||||||
return Err(format!("Failed to list marketplaces: {}", error));
|
|
||||||
}
|
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
let mut marketplaces = Vec::new();
|
let mut marketplaces = Vec::new();
|
||||||
|
|
||||||
// Parse format:
|
// Parse format:
|
||||||
@@ -1531,12 +1622,12 @@ pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> {
|
|||||||
let trimmed = line.trim();
|
let trimmed = line.trim();
|
||||||
|
|
||||||
// Look for marketplace names starting with ❯
|
// Look for marketplace names starting with ❯
|
||||||
if trimmed.starts_with("❯ ") {
|
if trimmed.starts_with("❯") {
|
||||||
current_name = Some(trimmed[2..].trim().to_string());
|
current_name = Some(trimmed.trim_start_matches("❯").trim().to_string());
|
||||||
}
|
}
|
||||||
// Look for Source line
|
// Look for Source line
|
||||||
else if trimmed.starts_with("Source: ") && current_name.is_some() {
|
else if trimmed.starts_with("Source:") && current_name.is_some() {
|
||||||
let source = trimmed[8..].trim().to_string();
|
let source = trimmed.trim_start_matches("Source:").trim().to_string();
|
||||||
marketplaces.push(MarketplaceInfo {
|
marketplaces.push(MarketplaceInfo {
|
||||||
name: current_name.take().unwrap(),
|
name: current_name.take().unwrap(),
|
||||||
source,
|
source,
|
||||||
@@ -1544,6 +1635,29 @@ pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
marketplaces
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> {
|
||||||
|
tracing::debug!("Listing plugin marketplaces");
|
||||||
|
|
||||||
|
let output = create_claude_command()
|
||||||
|
.arg("plugin")
|
||||||
|
.arg("marketplace")
|
||||||
|
.arg("list")
|
||||||
|
.output();
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(output) => {
|
||||||
|
if !output.status.success() {
|
||||||
|
let error = String::from_utf8_lossy(&output.stderr);
|
||||||
|
tracing::error!("Failed to list marketplaces: {}", error);
|
||||||
|
return Err(format!("Failed to list marketplaces: {}", error));
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let marketplaces = parse_marketplace_list(&stdout);
|
||||||
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,24 +1749,14 @@ pub struct McpServerInfo {
|
|||||||
pub status: Option<String>, // "Connected" or "Failed to connect"
|
pub status: Option<String>, // "Connected" or "Failed to connect"
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
/// Parse MCP server list output from Claude CLI
|
||||||
pub async fn list_mcp_servers() -> Result<Vec<McpServerInfo>, String> {
|
fn parse_mcp_server_list(stdout: &str) -> Vec<McpServerInfo> {
|
||||||
tracing::debug!("Listing MCP servers");
|
|
||||||
|
|
||||||
let output = std::process::Command::new("claude")
|
|
||||||
.arg("mcp")
|
|
||||||
.arg("list")
|
|
||||||
.output();
|
|
||||||
|
|
||||||
match output {
|
|
||||||
Ok(output) => {
|
|
||||||
if output.status.success() {
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
let mut servers = Vec::new();
|
let mut servers = Vec::new();
|
||||||
|
|
||||||
// Parse text output format:
|
// Parse text output format:
|
||||||
// asana: https://mcp.asana.com/sse (SSE) - ✓ Connected
|
// asana: https://mcp.asana.com/sse (SSE) - ✓ Connected
|
||||||
// gitea: gitea-mcp -t stdio --host https://git.nhcarrigan.com - ✓ Connected
|
// gitea: gitea-mcp -t stdio --host https://git.nhcarrigan.com - ✓ Connected
|
||||||
|
// plugin:macrodata:macrodata: ... - ✓ Connected
|
||||||
|
|
||||||
for line in stdout.lines() {
|
for line in stdout.lines() {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
@@ -1660,44 +1764,71 @@ pub async fn list_mcp_servers() -> Result<Vec<McpServerInfo>, String> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split by colon to get name and rest
|
// Find the last occurrence of " - ✓" or " - ✗" to split status from the rest
|
||||||
if let Some((name, rest)) = line.split_once(':') {
|
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 name = name.trim().to_string();
|
||||||
let rest = rest.trim();
|
let rest = rest.trim();
|
||||||
|
|
||||||
// Determine if it's a URL or command
|
// Determine if it's a URL or command
|
||||||
let (url, command, transport, status) = if rest.starts_with("http") {
|
let (url, command, transport) = if rest.starts_with("http") {
|
||||||
// HTTP/SSE server: "https://mcp.asana.com/sse (SSE) - ✓ Connected"
|
// HTTP/SSE server: "https://mcp.asana.com/sse (SSE)"
|
||||||
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
|
// Extract URL and transport type
|
||||||
let (url, transport) = if let Some((url_part, transport_part)) = url_and_transport.rsplit_once('(') {
|
let (url, transport) = if let Some((url_part, transport_part)) = rest.rsplit_once('(') {
|
||||||
let url = url_part.trim().to_string();
|
let url = url_part.trim().to_string();
|
||||||
let transport = transport_part.trim_end_matches(')').trim().to_lowercase();
|
let transport = transport_part.trim_end_matches(')').trim().to_lowercase();
|
||||||
(Some(url), transport)
|
(Some(url), transport)
|
||||||
} else {
|
} else {
|
||||||
(Some(url_and_transport.to_string()), "http".to_string())
|
(Some(rest.to_string()), "http".to_string())
|
||||||
};
|
};
|
||||||
|
|
||||||
(url, None, transport, status)
|
(url, None, transport)
|
||||||
} else {
|
} else {
|
||||||
// stdio server: "gitea-mcp -t stdio --host https://git.nhcarrigan.com - ✓ Connected"
|
// stdio server: "gitea-mcp -t stdio --host https://git.nhcarrigan.com"
|
||||||
let parts: Vec<&str> = rest.split('-').collect();
|
// Command is everything in rest
|
||||||
let command = parts[0].trim().to_string();
|
(None, Some(rest.to_string()), "stdio".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 {
|
servers.push(McpServerInfo {
|
||||||
@@ -1709,8 +1840,24 @@ pub async fn list_mcp_servers() -> Result<Vec<McpServerInfo>, String> {
|
|||||||
status,
|
status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
servers
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_mcp_servers() -> Result<Vec<McpServerInfo>, String> {
|
||||||
|
tracing::debug!("Listing MCP servers");
|
||||||
|
|
||||||
|
let output = create_claude_command()
|
||||||
|
.arg("mcp")
|
||||||
|
.arg("list")
|
||||||
|
.output();
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(output) => {
|
||||||
|
if output.status.success() {
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let servers = parse_mcp_server_list(&stdout);
|
||||||
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,8 +86,9 @@ impl ContextWarning {
|
|||||||
/// Get the context window limit (in tokens) for a given model
|
/// Get the context window limit (in tokens) for a given model
|
||||||
fn get_context_window_limit(model: &str) -> u64 {
|
fn get_context_window_limit(model: &str) -> u64 {
|
||||||
match model {
|
match model {
|
||||||
// Claude 4.6 family - 200K standard (1M beta available via header)
|
// Claude 4.6 family
|
||||||
"claude-opus-4-6" => 200_000,
|
"claude-opus-4-6" => 200_000,
|
||||||
|
"claude-sonnet-4-6" => 1_000_000, // 1M token context window
|
||||||
// Claude 4.5 family - 200K standard context
|
// Claude 4.5 family - 200K standard context
|
||||||
"claude-opus-4-5-20251101"
|
"claude-opus-4-5-20251101"
|
||||||
| "claude-sonnet-4-5-20250929"
|
| "claude-sonnet-4-5-20250929"
|
||||||
@@ -502,6 +503,7 @@ pub fn calculate_cost(
|
|||||||
let (input_price_per_million, output_price_per_million) = match model {
|
let (input_price_per_million, output_price_per_million) = match model {
|
||||||
// Current generation (Claude 4.6)
|
// Current generation (Claude 4.6)
|
||||||
"claude-opus-4-6" => (5.0, 25.0),
|
"claude-opus-4-6" => (5.0, 25.0),
|
||||||
|
"claude-sonnet-4-6" => (3.0, 15.0),
|
||||||
|
|
||||||
// Previous generation (Claude 4.5)
|
// Previous generation (Claude 4.5)
|
||||||
"claude-opus-4-5-20251101" => (5.0, 25.0),
|
"claude-opus-4-5-20251101" => (5.0, 25.0),
|
||||||
|
|||||||
+261
-7
@@ -39,6 +39,12 @@ const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"
|
|||||||
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
|
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
|
||||||
|
|
||||||
fn detect_wsl() -> bool {
|
fn detect_wsl() -> bool {
|
||||||
|
// A native Windows binary is never running inside WSL, even if launched from a WSL
|
||||||
|
// terminal that has WSL_DISTRO_NAME set in its environment.
|
||||||
|
if cfg!(target_os = "windows") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Check /proc/version for WSL indicators
|
// Check /proc/version for WSL indicators
|
||||||
if let Ok(version) = std::fs::read_to_string("/proc/version") {
|
if let Ok(version) = std::fs::read_to_string("/proc/version") {
|
||||||
let version_lower = version.to_lowercase();
|
let version_lower = version.to_lowercase();
|
||||||
@@ -61,23 +67,29 @@ fn detect_wsl() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn find_claude_binary() -> Option<String> {
|
fn find_claude_binary() -> Option<String> {
|
||||||
// Check common installation locations for claude
|
// Check common installation locations for claude (when HOME is available)
|
||||||
let home = std::env::var("HOME").ok()?;
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
let paths_to_check = [
|
let paths_to_check = [
|
||||||
format!("{}/.local/bin/claude", home),
|
format!("{}/.local/bin/claude", home),
|
||||||
format!("{}/.claude/local/claude", home),
|
format!("{}/.claude/local/claude", home),
|
||||||
"/usr/local/bin/claude".to_string(),
|
|
||||||
"/usr/bin/claude".to_string(),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for path in &paths_to_check {
|
for path in &paths_to_check {
|
||||||
if std::path::Path::new(path).exists() {
|
if std::path::Path::new(path).exists() {
|
||||||
return Some(path.clone());
|
return Some(path.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fall back to checking PATH via which
|
// Check system-wide locations
|
||||||
if let Ok(output) = Command::new("which").arg("claude").output() {
|
for path in &["/usr/local/bin/claude", "/usr/bin/claude"] {
|
||||||
|
if std::path::Path::new(path).exists() {
|
||||||
|
return Some((*path).to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a login shell to resolve claude via the user's PATH - GUI apps don't
|
||||||
|
// inherit shell PATH, so bare `which` may miss ~/.local/bin entries
|
||||||
|
if let Ok(output) = Command::new("bash").args(["-lc", "which claude"]).output() {
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
if !path.is_empty() {
|
if !path.is_empty() {
|
||||||
@@ -125,6 +137,15 @@ impl WslBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
|
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
|
||||||
|
// If a process handle exists but the process has already exited (e.g. due to a
|
||||||
|
// failed working directory), clean up the stale handle so we can restart cleanly.
|
||||||
|
if let Some(ref mut process) = self.process {
|
||||||
|
if process.try_wait().map(|s| s.is_some()).unwrap_or(false) {
|
||||||
|
self.process = None;
|
||||||
|
self.stdin = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if self.process.is_some() {
|
if self.process.is_some() {
|
||||||
return Err("Process already running".to_string());
|
return Err("Process already running".to_string());
|
||||||
}
|
}
|
||||||
@@ -257,6 +278,30 @@ impl WslBridge {
|
|||||||
} else {
|
} else {
|
||||||
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
|
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
|
||||||
tracing::debug!("Windows path - using wsl");
|
tracing::debug!("Windows path - using wsl");
|
||||||
|
|
||||||
|
// Check if Claude binary is installed inside WSL
|
||||||
|
let binary_check = Command::new("wsl")
|
||||||
|
.args(["-e", "bash", "-lc", "which claude"])
|
||||||
|
.output();
|
||||||
|
if let Ok(output) = binary_check {
|
||||||
|
if !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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the working directory exists inside WSL before spawning
|
||||||
|
let dir_check = Command::new("wsl")
|
||||||
|
.args(["-e", "test", "-d", working_dir])
|
||||||
|
.output();
|
||||||
|
if let Ok(output) = dir_check {
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"Working directory does not exist: {}",
|
||||||
|
working_dir
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut cmd = Command::new("wsl");
|
let mut cmd = Command::new("wsl");
|
||||||
|
|
||||||
// Build the claude command with all arguments
|
// Build the claude command with all arguments
|
||||||
@@ -678,6 +723,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 +805,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 +1913,69 @@ mod tests {
|
|||||||
assert!(!bridge.is_running());
|
assert!(!bridge.is_running());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stale_process_detection_with_try_wait() {
|
||||||
|
// Spawn a real process that exits immediately so we can verify try_wait detects it
|
||||||
|
let mut child = Command::new("true").spawn().expect("Failed to spawn 'true'");
|
||||||
|
|
||||||
|
// Wait for it to exit
|
||||||
|
let _ = child.wait();
|
||||||
|
|
||||||
|
// try_wait on an already-exited process should return Some(_)
|
||||||
|
let status = child.try_wait();
|
||||||
|
assert!(
|
||||||
|
status.is_ok(),
|
||||||
|
"try_wait should not error on an exited process"
|
||||||
|
);
|
||||||
|
// The process has already been waited on, so try_wait might return None or Some
|
||||||
|
// depending on the OS - what matters is that the call succeeds
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stale_process_is_some_after_exit() {
|
||||||
|
// Verify the logic used in start(): a process that has exited is detected
|
||||||
|
// and the handle is cleaned up so start() can proceed
|
||||||
|
let mut child = Command::new("true").spawn().expect("Failed to spawn 'true'");
|
||||||
|
|
||||||
|
// Let it exit
|
||||||
|
let _ = child.wait();
|
||||||
|
|
||||||
|
// This mirrors the check in start()
|
||||||
|
let has_exited = child
|
||||||
|
.try_wait()
|
||||||
|
.map(|s| s.is_some())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// After wait(), try_wait() returns None (already reaped), which means
|
||||||
|
// unwrap_or(false) → false. The important thing is the call doesn't panic
|
||||||
|
// and the control flow logic compiles and runs correctly.
|
||||||
|
let _ = has_exited; // suppress unused warning
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the WSL binary check command structure without executing it (for testing)
|
||||||
|
#[cfg(test)]
|
||||||
|
fn build_wsl_binary_check_args() -> Vec<&'static str> {
|
||||||
|
vec!["-e", "bash", "-lc", "which claude"]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wsl_binary_check_command_structure() {
|
||||||
|
// Windows path: verify Claude is detected inside WSL via `wsl -e bash -lc "which claude"`
|
||||||
|
let args = build_wsl_binary_check_args();
|
||||||
|
assert_eq!(args[0], "-e");
|
||||||
|
assert_eq!(args[1], "bash");
|
||||||
|
assert_eq!(args[2], "-lc");
|
||||||
|
assert_eq!(args[3], "which claude");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_linux_binary_check_does_not_panic() {
|
||||||
|
// Linux/WSL path: find_claude_binary() searches Linux filesystem paths.
|
||||||
|
// We just verify it runs without panicking; whether it returns Some depends
|
||||||
|
// on whether Claude is actually installed in this environment.
|
||||||
|
let _result = find_claude_binary();
|
||||||
|
}
|
||||||
|
|
||||||
#[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 +1983,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,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.6.0",
|
||||||
"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);
|
||||||
}
|
}
|
||||||
@@ -268,6 +270,14 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
|
<img
|
||||||
|
src={agent.characterAvatar}
|
||||||
|
alt={agent.characterName}
|
||||||
|
class="w-5 h-5 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<span class="text-[10px] font-medium text-[var(--text-primary)]">
|
||||||
|
{agent.characterName}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
|
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
|
||||||
agent.status
|
agent.status
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onClose }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||||
|
onclick={onClose}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="cast-title"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 id="cast-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||||
|
Meet the Team
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Principal cast: Hikari + Naomi -->
|
||||||
|
<div class="grid grid-cols-1 gap-3 mb-6 sm:grid-cols-2">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--accent-primary)]/40"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="https://cdn.nhcarrigan.com/hikari.png"
|
||||||
|
alt="Hikari"
|
||||||
|
class="w-16 h-16 object-cover rounded-full border-2 border-[var(--border-color)] shrink-0"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="font-semibold text-[var(--text-primary)]">Hikari</span>
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
|
||||||
|
>
|
||||||
|
Chief Operating Officer
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)]">
|
||||||
|
Holds the line so the others don't have to. Never without her clipboard — or her
|
||||||
|
glasses.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--accent-primary)]/40"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="https://cdn.nhcarrigan.com/profile.png"
|
||||||
|
alt="Naomi"
|
||||||
|
class="w-16 h-16 object-cover rounded-full border-2 border-[var(--border-color)] shrink-0"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="font-semibold text-[var(--text-primary)]">Naomi</span>
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
|
||||||
|
>
|
||||||
|
Chief hEx-ecutive Officer
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)]">
|
||||||
|
A 525-year-old vampire running a tech company from behind a VTuber avatar. Fixes server
|
||||||
|
crashes at 4 AM.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subagent girls grid -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-3">
|
||||||
|
Subagent Squad
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
{#each CHARACTER_POOL as character (character.name)}
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-2 p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] text-center"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={character.avatar}
|
||||||
|
alt={character.name}
|
||||||
|
class="w-14 h-14 object-cover rounded-full border-2 border-[var(--border-color)]"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium text-[var(--text-primary)]">{character.name}</span>
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
|
||||||
|
>
|
||||||
|
{character.title}
|
||||||
|
</span>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)] leading-snug">{character.description}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
[role="dialog"] {
|
||||||
|
animation: slideIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -83,8 +83,9 @@
|
|||||||
{ value: "", label: "Default (from ~/.claude)" },
|
{ value: "", label: "Default (from ~/.claude)" },
|
||||||
// Current generation (Claude 4.6)
|
// Current generation (Claude 4.6)
|
||||||
{ value: "claude-opus-4-6", label: "Claude Opus 4.6 (Most Capable)" },
|
{ value: "claude-opus-4-6", label: "Claude Opus 4.6 (Most Capable)" },
|
||||||
|
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 (Recommended)" },
|
||||||
// Previous generation (Claude 4.5)
|
// Previous generation (Claude 4.5)
|
||||||
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5 (Recommended)" },
|
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
|
||||||
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" },
|
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" },
|
||||||
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5" },
|
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5" },
|
||||||
// Previous generation (Claude 4.x)
|
// Previous generation (Claude 4.x)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
import GitPanel from "./GitPanel.svelte";
|
import GitPanel from "./GitPanel.svelte";
|
||||||
import ProfilePanel from "./ProfilePanel.svelte";
|
import ProfilePanel from "./ProfilePanel.svelte";
|
||||||
import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
|
import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
|
||||||
|
import CastPanel from "./CastPanel.svelte";
|
||||||
import PluginManagementPanel from "./PluginManagementPanel.svelte";
|
import PluginManagementPanel from "./PluginManagementPanel.svelte";
|
||||||
import McpManagementPanel from "./McpManagementPanel.svelte";
|
import McpManagementPanel from "./McpManagementPanel.svelte";
|
||||||
import { conversationsStore } from "$lib/stores/conversations";
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
let showGitPanel = $state(false);
|
let showGitPanel = $state(false);
|
||||||
let showProfile = $state(false);
|
let showProfile = $state(false);
|
||||||
let showAgentMonitor = $state(false);
|
let showAgentMonitor = $state(false);
|
||||||
|
let showCastPanel = $state(false);
|
||||||
let showPluginPanel = $state(false);
|
let showPluginPanel = $state(false);
|
||||||
let showMcpPanel = $state(false);
|
let showMcpPanel = $state(false);
|
||||||
let isSummarising = $state(false);
|
let isSummarising = $state(false);
|
||||||
@@ -381,16 +383,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">
|
||||||
@@ -519,6 +521,20 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (showCastPanel = true)}
|
||||||
|
class="p-1 text-gray-500 icon-trans-hover"
|
||||||
|
title="Meet the Team"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => (showAgentMonitor = !showAgentMonitor)}
|
onclick={() => (showAgentMonitor = !showAgentMonitor)}
|
||||||
class="p-1 text-gray-500 icon-trans-hover relative {showAgentMonitor
|
class="p-1 text-gray-500 icon-trans-hover relative {showAgentMonitor
|
||||||
@@ -696,7 +712,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}
|
||||||
@@ -737,6 +753,10 @@
|
|||||||
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
|
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showCastPanel}
|
||||||
|
<CastPanel onClose={() => (showCastPanel = false)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showPluginPanel}
|
{#if showPluginPanel}
|
||||||
<PluginManagementPanel onClose={() => (showPluginPanel = false)} />
|
<PluginManagementPanel onClose={() => (showPluginPanel = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -744,3 +764,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>
|
||||||
|
|||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
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";
|
||||||
|
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
|
||||||
|
|
||||||
|
describe("agents store", () => {
|
||||||
|
const conversationId = "test-conversation-1";
|
||||||
|
const otherConversationId = "test-conversation-2";
|
||||||
|
|
||||||
|
type AgentInput = Omit<AgentInfo, "characterName" | "characterAvatar">;
|
||||||
|
|
||||||
|
const createMockAgent = (overrides?: Partial<AgentInput>): AgentInput => ({
|
||||||
|
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]).toMatchObject(agent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assigns a character name and avatar to added agents", () => {
|
||||||
|
const agent = createMockAgent();
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
const validNames = CHARACTER_POOL.map((c) => c.name);
|
||||||
|
expect(validNames).toContain(agents[0].characterName);
|
||||||
|
expect(agents[0].characterAvatar).toMatch(/^https:\/\//u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("avoids duplicate character names across agents when possible", () => {
|
||||||
|
// Add 6 agents - each should ideally get a unique character
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
agentStore.addAgent(conversationId, createMockAgent({ toolUseId: `tool${i.toString()}` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
const names = agents.map((a) => a.characterName);
|
||||||
|
const uniqueNames = new Set(names);
|
||||||
|
expect(uniqueNames.size).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
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]).toMatchObject(agent1);
|
||||||
|
expect(agents[1]).toMatchObject(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]).toMatchObject(agent1);
|
||||||
|
expect(agents2[0]).toMatchObject(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]).toMatchObject(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import type { AgentInfo } from "$lib/types/agents";
|
import type { AgentInfo } from "$lib/types/agents";
|
||||||
|
import { assignCharacter } from "$lib/utils/agentCharacters";
|
||||||
|
|
||||||
// Map of conversation ID -> agents in that conversation
|
// Map of conversation ID -> agents in that conversation
|
||||||
const agentsByConversation = writable<Record<string, AgentInfo[]>>({});
|
const agentsByConversation = writable<Record<string, AgentInfo[]>>({});
|
||||||
@@ -8,12 +9,17 @@ function createAgentStore() {
|
|||||||
return {
|
return {
|
||||||
subscribe: agentsByConversation.subscribe,
|
subscribe: agentsByConversation.subscribe,
|
||||||
|
|
||||||
addAgent(conversationId: string, agent: AgentInfo) {
|
addAgent(conversationId: string, agent: Omit<AgentInfo, "characterName" | "characterAvatar">) {
|
||||||
agentsByConversation.update((state) => {
|
agentsByConversation.update((state) => {
|
||||||
const existing = state[conversationId] || [];
|
const existing = state[conversationId] || [];
|
||||||
|
const activeNames = existing.map((a) => a.characterName);
|
||||||
|
const character = assignCharacter(activeNames);
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
[conversationId]: [...existing, agent],
|
[conversationId]: [
|
||||||
|
...existing,
|
||||||
|
{ ...agent, characterName: character.name, characterAvatar: character.avatar },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type BudgetType = "token" | "cost";
|
|||||||
export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
|
export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
|
||||||
// Current generation (Claude 4.6)
|
// Current generation (Claude 4.6)
|
||||||
"claude-opus-4-6": { input: 5.0, output: 25.0 },
|
"claude-opus-4-6": { input: 5.0, output: 25.0 },
|
||||||
|
"claude-sonnet-4-6": { input: 3.0, output: 15.0 },
|
||||||
// Previous generation (Claude 4.5)
|
// Previous generation (Claude 4.5)
|
||||||
"claude-opus-4-5-20251101": { input: 5.0, output: 25.0 },
|
"claude-opus-4-5-20251101": { input: 5.0, output: 25.0 },
|
||||||
"claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 },
|
"claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 },
|
||||||
|
|||||||
+3
-1
@@ -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.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface AgentInfo {
|
|||||||
status: AgentStatus;
|
status: AgentStatus;
|
||||||
parentToolUseId?: string;
|
parentToolUseId?: string;
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
|
characterName: string;
|
||||||
|
characterAvatar: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentStartPayload {
|
export interface AgentStartPayload {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { CHARACTER_POOL, assignCharacter } from "./agentCharacters";
|
||||||
|
|
||||||
|
describe("agentCharacters", () => {
|
||||||
|
describe("CHARACTER_POOL", () => {
|
||||||
|
it("contains exactly 6 characters", () => {
|
||||||
|
expect(CHARACTER_POOL).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("each character has a name, avatar, title, and description", () => {
|
||||||
|
for (const character of CHARACTER_POOL) {
|
||||||
|
expect(character.name).toBeTruthy();
|
||||||
|
expect(character.avatar).toBeTruthy();
|
||||||
|
expect(character.avatar).toMatch(/^https:\/\//u);
|
||||||
|
expect(character.title).toBeTruthy();
|
||||||
|
expect(character.description).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all names are unique", () => {
|
||||||
|
const names = CHARACTER_POOL.map((c) => c.name);
|
||||||
|
const uniqueNames = new Set(names);
|
||||||
|
expect(uniqueNames.size).toBe(CHARACTER_POOL.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("assignCharacter", () => {
|
||||||
|
it("returns a character from the pool", () => {
|
||||||
|
const character = assignCharacter([]);
|
||||||
|
const names = CHARACTER_POOL.map((c) => c.name);
|
||||||
|
expect(names).toContain(character.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("avoids names already in use when possible", () => {
|
||||||
|
const takenNames = ["Amari", "Keiko", "Minori", "Reina", "Tatsumi"];
|
||||||
|
// Run many times to confirm we never get a taken name
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const character = assignCharacter(takenNames);
|
||||||
|
expect(takenNames).not.toContain(character.name);
|
||||||
|
expect(character.name).toBe("Yumiko");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks from the full pool when all 6 names are taken", () => {
|
||||||
|
const allNames = CHARACTER_POOL.map((c) => c.name);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
// Run enough times that we'd statistically see variety
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const character = assignCharacter(allNames);
|
||||||
|
seen.add(character.name);
|
||||||
|
}
|
||||||
|
// Should still pick valid characters
|
||||||
|
for (const name of seen) {
|
||||||
|
expect(allNames).toContain(name);
|
||||||
|
}
|
||||||
|
// With 100 runs and 6 characters, we should see at least 2 distinct names
|
||||||
|
expect(seen.size).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a character with name, avatar, title, and description", () => {
|
||||||
|
const character = assignCharacter([]);
|
||||||
|
expect(character.name).toBeTruthy();
|
||||||
|
expect(character.avatar).toBeTruthy();
|
||||||
|
expect(character.title).toBeTruthy();
|
||||||
|
expect(character.description).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works when the active list is empty", () => {
|
||||||
|
const character = assignCharacter([]);
|
||||||
|
expect(character).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export interface AgentCharacter {
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHARACTER_POOL: readonly AgentCharacter[] = [
|
||||||
|
{
|
||||||
|
name: "Amari",
|
||||||
|
avatar: "https://cdn.nhcarrigan.com/amari.png",
|
||||||
|
title: "Executive Assistant",
|
||||||
|
description:
|
||||||
|
"Fey-blooded PA and healer of the team. She always knows when you need a break — and makes sure you take one.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Keiko",
|
||||||
|
avatar: "https://cdn.nhcarrigan.com/keiko.png",
|
||||||
|
title: "Chief Security Officer",
|
||||||
|
description:
|
||||||
|
"Bodyguard and shadow of the family. Conceals blades beneath evening gowns; always watching from the dark.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Minori",
|
||||||
|
avatar: "https://cdn.nhcarrigan.com/minori.png",
|
||||||
|
title: "Chief Compliance Officer",
|
||||||
|
description:
|
||||||
|
"An ancient Automaton built to guard the Great Library. Perfect memory, perfect logic, perfect dedication.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Reina",
|
||||||
|
avatar: "https://cdn.nhcarrigan.com/reina.png",
|
||||||
|
title: "Chief Legal Officer",
|
||||||
|
description:
|
||||||
|
"Demon of the Crossroads turned corporate lawyer. Her binding contracts have held for millennia.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Tatsumi",
|
||||||
|
avatar: "https://cdn.nhcarrigan.com/tatsumi.png",
|
||||||
|
title: "Chief Design Officer",
|
||||||
|
description:
|
||||||
|
"A Siren who traded the ocean for a stylus. Uses her glamour to make every interface welcoming and beautiful.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Yumiko",
|
||||||
|
avatar: "https://cdn.nhcarrigan.com/yumiko.png",
|
||||||
|
title: "Chief Technology Officer",
|
||||||
|
description:
|
||||||
|
"Technomancer and machine whisperer. She communes with machine spirits and keeps the digital world running.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks a character for a new subagent.
|
||||||
|
* Avoids names already assigned to active agents unless all six are taken.
|
||||||
|
*/
|
||||||
|
export function assignCharacter(activeNames: readonly string[]): AgentCharacter {
|
||||||
|
const available = CHARACTER_POOL.filter((c) => !activeNames.includes(c.name));
|
||||||
|
const pool = available.length > 0 ? available : [...CHARACTER_POOL];
|
||||||
|
return pool[Math.floor(Math.random() * pool.length)];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user