Compare commits

..

14 Commits

Author SHA1 Message Date
hikari 4c67380859 feat: enhance plugin and MCP management panels
Add comprehensive management capabilities to both panels:

MCP Management Panel:
- Add "Add New Server" form with transport selection (STDIO/HTTP/SSE)
- Add enhanced server details view showing full CLI output
- Dynamic placeholder text based on transport type
- Support for environment variables and headers (backend)

Plugin Management Panel:
- Add collapsible marketplace management section
- Add/remove marketplaces from GitHub repos
- List all configured marketplaces with sources
- Enhanced plugin installation with marketplace syntax support

Backend Commands:
- add_mcp_server: Add MCP servers with transport/env/headers
- get_mcp_server_details: Fetch full server details from CLI
- list_marketplaces: Parse and list plugin marketplaces
- add_marketplace: Add marketplace from GitHub source
- remove_marketplace: Remove marketplace by name

All operations use Claude CLI directly, avoiding cross-platform path
issues by letting the CLI handle file system operations internally.

 This enhancement was built by Hikari~ 🌸
2026-02-07 17:22:49 -08:00
hikari f5bc9a5016 fix: parse text output instead of JSON for plugin/MCP commands
- Remove --json flag from CLI commands (not supported)
- Parse plugin list text output format
- Parse MCP server list text output format
- Add status field to McpServerInfo for connection status
- Display connection status in MCP panel (✓/✗ badges)
- Simplify get_mcp_server to reuse list_mcp_servers
2026-02-07 17:02:14 -08:00
hikari cf20540a49 feat: add Plugin and MCP Server Management panels
Backend (Rust):
- Add plugin management commands: list, install, uninstall, enable, disable, update
- Add MCP server management commands: list, get details, remove
- Integrate with Claude CLI's 'plugin' and 'mcp' subcommands
- Export PluginInfo and McpServerInfo types

Frontend (Svelte):
- Create PluginManagementPanel component with full CRUD operations
- Create McpManagementPanel component with server listing and removal
- Add buttons to StatusBar for both panels
- Beautiful UI with lucide-svelte icons
- Theme-aware styling using CSS variables
- Real-time loading states and error handling

Closes #133
Closes #134
2026-02-07 15:11:06 -08:00
hikari 70af6a6b72 fix: make TodoPanel respect themes and clear on disconnect
- Replace all hardcoded colours with CSS theme variables
- TodoPanel now respects user's theme (Dark, Light, Trans Pride, etc.)
- Clear todos when user disconnects (stop)
- Preserve todos during reconnects (permission prompts, interrupts)
- Only clear on real disconnect (when skipNextGreeting is false)
2026-02-07 14:55:02 -08:00
hikari 3194a3cca5 feat: add visual todo list panel
- Add TodoPanel component to display TodoWrite tool calls
- Create todos Svelte store to track todo state
- Emit todo-update Tauri event when TodoWrite is called
- Add todo button to status bar (next to session history)
- Display todos with status icons, progress bar, and completion count
- Real-time updates as I work through tasks

Closes #132
2026-02-07 14:47:20 -08:00
hikari 7fecb20ba9 feat: add CLI version display in status bar
Displays the Claude Code CLI version in the status bar (to the left of the system clock) to help with debugging and understanding which features are available.

Changes:
- Add get_claude_version command to fetch CLI version from `claude --version`
- Create CliVersion.svelte component to display version
- Position component in InputBar status bar next to SystemClock
- Update on app start and CLI reconnection

Closes #131
2026-02-07 14:37:45 -08:00
hikari 9b3c242333 feat: add "Clear All Sessions" button to Session History Panel
Adds a "Clear All Sessions" button to the Session History Panel that
allows users to delete all saved sessions at once to manage disk usage.

Features:
- Red "Clear All" button with trash icon next to Import button
- Automatically disabled when there are no sessions to clear
- Confirmation dialog with warning styling shows exact session count
- Warning icon and "This action cannot be undone" message
- Keyboard support (Escape to cancel)
- Uses existing clearAllSessions() backend command

Benefits:
- Gives users control over disk usage
- Prevents unbounded growth of session files
- Safety confirmation prevents accidental deletions
- Improves performance for users with many sessions

Closes: #130
2026-02-07 14:24:50 -08:00
hikari 5d4fa75278 fix: remove frontend calls to deleted log_discord_rpc command
Removes all frontend invocations of the log_discord_rpc command that was
deleted in the Discord RPC refactor (edd8fa5). The frontend was still
calling this command during Discord RPC initialization, causing errors
that prevented the close confirmation modal event listener from being
registered.

The root cause:
1. initializeDiscordRpc() called invoke("log_discord_rpc")
2. Command doesn't exist anymore, promise rejects
3. await stops execution before close event listener setup
4. Close modal never works

Replaced all log_discord_rpc calls with console.log since the backend
already uses the tracing framework for Discord RPC logging.

Fixes: Close confirmation modal not appearing after Discord RPC refactor
2026-02-07 14:18:50 -08:00
hikari edd8fa5b55 refactor: remove Discord RPC file logging
Removes file-based logging from Discord RPC manager in favour of using
the tracing framework exclusively. All Discord RPC logs now appear in
the Debug Console with proper log levels.

Changes:
- Remove log_path field and file logging methods from DiscordRpcManager
- Replace all self.log() calls with tracing macros (debug/info/error)
- Remove log_discord_rpc command and its registration
- Remove set_app_handle() call from main setup

Benefits:
- Reduces disk usage (no unbounded log file growth)
- Eliminates maintenance burden of managing log files
- Better log levels and integration with existing tracing system

Closes: #129
2026-02-07 13:34:43 -08:00
hikari 7781b2caab feat: add extended thinking blocks display
Implements support for displaying Claude's extended thinking blocks in
the conversation UI with a collapsible, visually distinct component.

Changes:
- Backend: Update wsl_bridge.rs to emit thinking blocks with dedicated
  line_type instead of system messages
- Types: Add "thinking" to TerminalLine type union
- Config: Add show_thinking_blocks toggle (default: true)
- UI: Create ThinkingBlock.svelte component with collapsible interface
- Terminal: Update to conditionally render thinking blocks
- Settings: Add toggle in Appearance section of ConfigSidebar

The ThinkingBlock component features:
- Lightbulb icon to indicate extended thinking
- Collapsible/expandable with animated chevron
- Distinct styling: dimmed, italic, monospace
- Timestamp display
- Respects global show_thinking_blocks config setting

Note: Extended thinking support in Claude Code CLI appears to be in
development. This implementation is ready and will automatically display
thinking blocks once the CLI begins emitting them.

Issue: #120

 Implemented by Hikari~ 🌸
2026-02-07 12:42:34 -08:00
hikari e397435dbe fix: resolve linting issues in memory system
- Add key to #each block in MemoryBrowserPanel (ESLint)
- Replace .last() with .next_back() for better performance (Clippy)
- Format files with Prettier

Co-Authored-By: Hikari <hikari@nhcarrigan.com>
2026-02-07 12:11:14 -08:00
hikari 89e99b1524 feat: add memory system activity display
Implements issue #118 to display auto-memory system operations and
provide a browser for viewing memory files.

Backend changes:
- Detect memory file operations in format_tool_description()
- Add emoji icons (📝/💾) for memory Read/Write/Edit operations
- Add list_memory_files() Tauri command to find all memory files
- Add 4 new tests for memory detection logic
- Update Tauri capabilities to allow reading .claude directory

Frontend changes:
- Create MemoryBrowserPanel component with file list and viewer
- Add memory panel to main app layout
- Support Markdown rendering of memory file contents
- Add loading states and error handling

Testing:
- All 354 Rust tests passing
- TypeScript type-checks pass

Co-Authored-By: Hikari <hikari@nhcarrigan.com>
2026-02-07 12:01:31 -08:00
hikari e40ae989f7 fix: support Task(agent_type) tool name syntax from CLI v2.1.33+
Claude Code CLI v2.1.33 introduced support for restricting sub-agents
via Task(agent_type) syntax in agent tools frontmatter. This commit
updates our Task tool detection to handle both the legacy "Task" syntax
and the new "Task(agent_type)" syntax.

Changes:
- Updated agent-start event detection to match "Task" or "Task("
- Updated character state detection to recognize Task(agent_type)
- Added comprehensive test cases for new syntax variants:
  - Task(Explore)
  - Task(Plan)
  - Task(general-purpose)

The fix is backwards compatible and all 350+ tests pass.

Fixes: #114
2026-02-07 10:55:17 -08:00
hikari 32a74235c0 feat: add system clock display with comprehensive tests
Add a SystemClock component that displays the current date and time in
British format. The clock appears in the top control bar (same row as
Clipboard and Actions buttons) and updates every second.

Features:
- Date format: "7 February 2026" (British English)
- Time format: "14:35:42" (24-hour)
- Auto-updates every second via setInterval
- Proper cleanup on unmount using Svelte 5 $effect
- Hover effect with accent colour border
- Positioned with margin-left: auto to align right

Testing:
- Added comprehensive unit tests for date/time formatting logic
- 12 test cases covering edge cases, month boundaries, leap years
- All tests passing with proper local timezone handling
- Updated CLAUDE.md with testing requirements and guidelines

Resolves: #128
2026-02-07 10:42:59 -08:00
23 changed files with 220 additions and 1894 deletions
-101
View File
@@ -47,65 +47,6 @@ All new features, fixes, and significant changes should include tests whenever p
- Use descriptive test names that explain what behaviour is being tested
- Include edge cases and error conditions in test coverage
- Mock Tauri APIs using the patterns in `vitest.setup.ts`
- **Coverage Goal**: Maintain as close to 100% test coverage as possible across the entire codebase
### Mocking Strategies
#### Console Mocking
When testing code that intentionally logs errors (like error handling paths), mock console methods to prevent stderr output that makes tests appear flaky:
```typescript
it("handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Test error handling code
await expect(functionThatLogs()).rejects.toThrow();
// Verify error was logged
expect(consoleErrorSpy).toHaveBeenCalledWith("Expected error:", expect.any(Error));
// Restore console.error
consoleErrorSpy.mockRestore();
});
```
#### E2E Integration Testing for Cross-Platform Code
For code that calls platform-specific system APIs (like Windows PowerShell or Linux notify-send), use helper functions that build the command structure without execution. This allows CI to verify cross-platform compatibility on Linux-only containers:
```rust
/// Build notify-send command for testing (doesn't execute)
#[cfg(test)]
fn build_notify_send_command(title: &str, body: &str) -> (String, Vec<String>) {
(
"notify-send".to_string(),
vec![
title.to_string(),
body.to_string(),
"--urgency=normal".to_string(),
"--app-name=Hikari Desktop".to_string(),
],
)
}
#[test]
fn test_e2e_notify_send_command_structure() {
let (command, args) = build_notify_send_command("Test Title", "Test Body");
assert_eq!(command, "notify-send");
assert_eq!(args.len(), 4);
assert_eq!(args[0], "Test Title");
assert_eq!(args[1], "Test Body");
}
```
This approach:
- Verifies command structure, argument order, and escaping logic
- Tests cross-platform code paths without requiring the target platform
- Allows CI to catch regressions in Windows-specific code whilst running on Linux
- Keeps tests fast and deterministic (no actual system calls)
### Example Test Structure
@@ -130,48 +71,6 @@ 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
Hikari Desktop is a Tauri-based desktop application that wraps Claude Code with a visual anime character (Hikari) who appears on screen. This is a personal project where Hikari can sign her work and act as herself!
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "hikari-desktop",
"version": "1.5.1",
"version": "1.4.0",
"description": "",
"type": "module",
"scripts": {
@@ -60,7 +60,7 @@
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2",
"@tauri-apps/plugin-shell": "2.3.5",
"@tauri-apps/plugin-shell": "^2.3.4",
"@tauri-apps/plugin-store": "^2",
"codemirror": "^6.0.2",
"highlight.js": "^11.11.1",
+6 -11
View File
@@ -108,8 +108,8 @@ importers:
specifier: ^2
version: 2.3.2
'@tauri-apps/plugin-shell':
specifier: 2.3.5
version: 2.3.5
specifier: ^2.3.4
version: 2.3.4
'@tauri-apps/plugin-store':
specifier: ^2
version: 2.4.2
@@ -909,9 +909,6 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@tauri-apps/api@2.10.1':
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
'@tauri-apps/api@2.9.1':
resolution: {integrity: sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==}
@@ -1004,8 +1001,8 @@ packages:
'@tauri-apps/plugin-os@2.3.2':
resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==}
'@tauri-apps/plugin-shell@2.3.5':
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
'@tauri-apps/plugin-shell@2.3.4':
resolution: {integrity: sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA==}
'@tauri-apps/plugin-store@2.4.2':
resolution: {integrity: sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==}
@@ -2872,8 +2869,6 @@ snapshots:
tailwindcss: 4.1.18
vite: 6.4.1(jiti@2.6.1)(lightningcss@1.30.2)
'@tauri-apps/api@2.10.1': {}
'@tauri-apps/api@2.9.1': {}
'@tauri-apps/cli-darwin-arm64@2.9.6':
@@ -2947,9 +2942,9 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/plugin-shell@2.3.5':
'@tauri-apps/plugin-shell@2.3.4':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/api': 2.9.1
'@tauri-apps/plugin-store@2.4.2':
dependencies:
+1 -1
View File
@@ -1636,7 +1636,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hikari-desktop"
version = "1.5.1"
version = "1.4.0"
dependencies = [
"chrono",
"dirs 5.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hikari-desktop"
version = "1.5.1"
version = "1.4.0"
description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"]
edition = "2021"
-124
View File
@@ -173,127 +173,3 @@ pub type SharedBridgeManager = Arc<Mutex<BridgeManager>>;
pub fn create_shared_bridge_manager() -> SharedBridgeManager {
Arc::new(Mutex::new(BridgeManager::new()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bridge_manager_new() {
let manager = BridgeManager::new();
assert!(manager.app_handle.is_none());
assert!(manager.bridges.is_empty());
}
#[test]
fn test_bridge_manager_default() {
let manager = BridgeManager::default();
assert!(manager.app_handle.is_none());
assert!(manager.bridges.is_empty());
}
#[test]
fn test_is_claude_running_no_bridge() {
let manager = BridgeManager::new();
assert!(!manager.is_claude_running("nonexistent"));
}
#[test]
fn test_get_working_directory_no_bridge() {
let manager = BridgeManager::new();
let result = manager.get_working_directory("nonexistent");
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"No Claude instance found for this conversation"
);
}
#[test]
fn test_get_usage_stats_no_bridge() {
let manager = BridgeManager::new();
let result = manager.get_usage_stats("nonexistent");
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"No Claude instance found for this conversation"
);
}
#[test]
fn test_stop_claude_no_bridge() {
let mut manager = BridgeManager::new();
let result = manager.stop_claude("nonexistent");
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"No Claude instance found for this conversation"
);
}
#[test]
fn test_interrupt_claude_no_bridge() {
let mut manager = BridgeManager::new();
let result = manager.interrupt_claude("nonexistent");
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"No Claude instance found for this conversation"
);
}
#[test]
fn test_send_prompt_no_bridge() {
let mut manager = BridgeManager::new();
let result = manager.send_prompt("nonexistent", "Hello".to_string());
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"No Claude instance found for this conversation"
);
}
#[test]
fn test_send_tool_result_no_bridge() {
let mut manager = BridgeManager::new();
let result = manager.send_tool_result(
"nonexistent",
"tool_id",
serde_json::json!({"result": "success"}),
);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"No Claude instance found for this conversation"
);
}
#[test]
fn test_create_shared_bridge_manager() {
let shared = create_shared_bridge_manager();
let manager = shared.lock();
assert!(manager.bridges.is_empty());
assert!(manager.app_handle.is_none());
}
#[test]
fn test_cleanup_stopped_bridges_empty() {
let mut manager = BridgeManager::new();
manager.cleanup_stopped_bridges();
assert!(manager.bridges.is_empty());
}
#[test]
fn test_get_active_conversations_empty() {
let manager = BridgeManager::new();
let active = manager.get_active_conversations();
assert!(active.is_empty());
}
#[test]
fn test_stop_all_without_app_handle() {
let mut manager = BridgeManager::new();
manager.stop_all(); // Should not panic
assert!(manager.bridges.is_empty());
}
}
+153 -663
View File
@@ -49,59 +49,6 @@ 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]
pub async fn start_claude(
bridge_manager: State<'_, SharedBridgeManager>,
@@ -1219,55 +1166,6 @@ pub struct MemoryFilesResponse {
#[tauri::command]
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;
// Get the .claude directory in the user's home
@@ -1335,7 +1233,7 @@ async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
pub async fn get_claude_version() -> Result<String, String> {
tracing::debug!("Getting Claude CLI version");
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("--version")
.output();
@@ -1370,62 +1268,11 @@ pub struct PluginInfo {
pub enabled: bool,
}
/// Parse plugin list output from Claude CLI
fn parse_plugin_list(stdout: &str) -> Vec<PluginInfo> {
let mut plugins = Vec::new();
// Parse text output format:
// macrodata@macrodata
// Version: 0.1.3
// Scope: user
// Status: ✔ enabled
let lines: Vec<&str> = stdout.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim();
// Look for plugin name line (starts with )
if line.starts_with("") {
let name = line.trim_start_matches("").trim().to_string();
let mut version = String::new();
let mut enabled = false;
// Parse following lines for metadata
i += 1;
while i < lines.len() {
let meta_line = lines[i].trim();
if meta_line.is_empty() || meta_line.starts_with("") {
break;
}
if meta_line.starts_with("Version:") {
version = meta_line.trim_start_matches("Version:").trim().to_string();
} else if meta_line.starts_with("Status:") {
enabled = meta_line.contains("enabled");
}
i += 1;
}
plugins.push(PluginInfo {
name,
version,
description: None,
enabled,
});
continue;
}
i += 1;
}
plugins
}
#[tauri::command]
pub async fn list_plugins() -> Result<Vec<PluginInfo>, String> {
tracing::debug!("Listing Claude Code plugins");
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("list")
.output();
@@ -1434,7 +1281,52 @@ pub async fn list_plugins() -> Result<Vec<PluginInfo>, String> {
Ok(output) => {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let plugins = parse_plugin_list(&stdout);
let mut plugins = Vec::new();
// Parse text output format:
// macrodata@macrodata
// Version: 0.1.3
// Scope: user
// Status: ✔ enabled
let lines: Vec<&str> = stdout.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim();
// Look for plugin name line (starts with )
if line.starts_with("") {
let name = line.trim_start_matches("").trim().to_string();
let mut version = String::new();
let mut enabled = false;
// Parse following lines for metadata
i += 1;
while i < lines.len() {
let meta_line = lines[i].trim();
if meta_line.is_empty() || meta_line.starts_with("") {
break;
}
if meta_line.starts_with("Version:") {
version = meta_line.trim_start_matches("Version:").trim().to_string();
} else if meta_line.starts_with("Status:") {
enabled = meta_line.contains("enabled");
}
i += 1;
}
plugins.push(PluginInfo {
name,
version,
description: None,
enabled,
});
continue;
}
i += 1;
}
tracing::info!("Listed {} plugins", plugins.len());
Ok(plugins)
} else {
@@ -1454,7 +1346,7 @@ pub async fn list_plugins() -> Result<Vec<PluginInfo>, String> {
pub async fn install_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Installing plugin: {}", plugin_name);
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("install")
.arg(&plugin_name)
@@ -1483,7 +1375,7 @@ pub async fn install_plugin(plugin_name: String) -> Result<String, String> {
pub async fn uninstall_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Uninstalling plugin: {}", plugin_name);
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("uninstall")
.arg(&plugin_name)
@@ -1512,7 +1404,7 @@ pub async fn uninstall_plugin(plugin_name: String) -> Result<String, String> {
pub async fn enable_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Enabling plugin: {}", plugin_name);
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("enable")
.arg(&plugin_name)
@@ -1541,7 +1433,7 @@ pub async fn enable_plugin(plugin_name: String) -> Result<String, String> {
pub async fn disable_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Disabling plugin: {}", plugin_name);
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("disable")
.arg(&plugin_name)
@@ -1570,7 +1462,7 @@ pub async fn disable_plugin(plugin_name: String) -> Result<String, String> {
pub async fn update_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Updating plugin: {}", plugin_name);
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("update")
.arg(&plugin_name)
@@ -1603,46 +1495,11 @@ pub struct MarketplaceInfo {
pub source: String,
}
/// Parse marketplace list output from Claude CLI
fn parse_marketplace_list(stdout: &str) -> Vec<MarketplaceInfo> {
let mut marketplaces = Vec::new();
// Parse format:
// Configured marketplaces:
//
// claude-plugins-official
// Source: GitHub (anthropics/claude-plugins-official)
//
// macrodata
// Source: GitHub (ascorbic/macrodata)
let mut current_name: Option<String> = None;
for line in stdout.lines() {
let trimmed = line.trim();
// Look for marketplace names starting with
if trimmed.starts_with("") {
current_name = Some(trimmed.trim_start_matches("").trim().to_string());
}
// Look for Source line
else if trimmed.starts_with("Source:") && current_name.is_some() {
let source = trimmed.trim_start_matches("Source:").trim().to_string();
marketplaces.push(MarketplaceInfo {
name: current_name.take().unwrap(),
source,
});
}
}
marketplaces
}
#[tauri::command]
pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> {
tracing::debug!("Listing plugin marketplaces");
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("marketplace")
.arg("list")
@@ -1657,7 +1514,36 @@ pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> {
}
let stdout = String::from_utf8_lossy(&output.stdout);
let marketplaces = parse_marketplace_list(&stdout);
let mut marketplaces = Vec::new();
// Parse format:
// Configured marketplaces:
//
// claude-plugins-official
// Source: GitHub (anthropics/claude-plugins-official)
//
// macrodata
// Source: GitHub (ascorbic/macrodata)
let mut current_name: Option<String> = None;
for line in stdout.lines() {
let trimmed = line.trim();
// Look for marketplace names starting with
if trimmed.starts_with(" ") {
current_name = Some(trimmed[2..].trim().to_string());
}
// Look for Source line
else if trimmed.starts_with("Source: ") && current_name.is_some() {
let source = trimmed[8..].trim().to_string();
marketplaces.push(MarketplaceInfo {
name: current_name.take().unwrap(),
source,
});
}
}
tracing::info!("Found {} marketplaces", marketplaces.len());
Ok(marketplaces)
}
@@ -1675,7 +1561,7 @@ pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> {
pub async fn add_marketplace(source: String) -> Result<String, String> {
tracing::debug!("Adding marketplace: {}", source);
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("marketplace")
.arg("add")
@@ -1708,7 +1594,7 @@ pub async fn add_marketplace(source: String) -> Result<String, String> {
pub async fn remove_marketplace(name: String) -> Result<String, String> {
tracing::debug!("Removing marketplace: {}", name);
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("marketplace")
.arg("remove")
@@ -1749,106 +1635,11 @@ pub struct McpServerInfo {
pub status: Option<String>, // "Connected" or "Failed to connect"
}
/// Parse MCP server list output from Claude CLI
fn parse_mcp_server_list(stdout: &str) -> Vec<McpServerInfo> {
let mut servers = Vec::new();
// Parse text output format:
// asana: https://mcp.asana.com/sse (SSE) - ✓ Connected
// gitea: gitea-mcp -t stdio --host https://git.nhcarrigan.com - ✓ Connected
// plugin:macrodata:macrodata: ... - ✓ Connected
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with("Checking") {
continue;
}
// Find the last occurrence of " - ✓" or " - ✗" to split status from the rest
let (content, status) = if let Some(pos) = line.rfind(" - ✓").or_else(|| line.rfind(" - ✗")) {
let status_str = line[pos + 3..].trim().trim_start_matches("").trim_start_matches("").trim();
(line[..pos].trim(), Some(status_str.to_string()))
} else {
(line, None)
};
// Now find the name by looking for the first colon followed by either http or a command
// The format is: "name: command/url"
// But name can contain colons (e.g., "plugin:macrodata:macrodata")
// Strategy: Find the colon that separates name from content
// - If content after colon starts with "http", it's a URL (name is before first colon)
// - If content is a command, name might have colons, so find the last colon before a non-URL space-separated part
let (name, rest) = if let Some(first_colon) = content.find(':') {
let after_first_colon = content[first_colon + 1..].trim_start();
// Check if it's a URL (starts with http)
if after_first_colon.starts_with("http") {
// Name is everything before the first colon
(content[..first_colon].to_string(), after_first_colon.to_string())
} else {
// It's a command - name might contain colons (like plugin:foo:bar)
// Strategy: Commands start with a letter/word, not with a colon
// Find the rightmost colon that has whitespace after it (indicating start of command)
let mut split_pos = first_colon;
for (idx, _) in content.match_indices(':') {
let after = content[idx + 1..].trim_start();
// If what comes after this colon is NOT another colon-prefixed part,
// and doesn't start with "//" (part of URL), this is our split point
if !after.is_empty() && !after.starts_with(':') && !after.starts_with("//") {
// Check if this looks like a command (starts with letter/number)
if after.chars().next().map(|c| c.is_alphanumeric()).unwrap_or(false) {
split_pos = idx;
}
}
}
(content[..split_pos].to_string(), content[split_pos + 1..].trim_start().to_string())
}
} else {
continue; // Skip lines without colons
};
let name = name.trim().to_string();
let rest = rest.trim();
// Determine if it's a URL or command
let (url, command, transport) = if rest.starts_with("http") {
// HTTP/SSE server: "https://mcp.asana.com/sse (SSE)"
// Extract URL and transport type
let (url, transport) = if let Some((url_part, transport_part)) = rest.rsplit_once('(') {
let url = url_part.trim().to_string();
let transport = transport_part.trim_end_matches(')').trim().to_lowercase();
(Some(url), transport)
} else {
(Some(rest.to_string()), "http".to_string())
};
(url, None, transport)
} else {
// stdio server: "gitea-mcp -t stdio --host https://git.nhcarrigan.com"
// Command is everything in rest
(None, Some(rest.to_string()), "stdio".to_string())
};
servers.push(McpServerInfo {
name,
command,
url,
transport,
env: None,
status,
});
}
servers
}
#[tauri::command]
pub async fn list_mcp_servers() -> Result<Vec<McpServerInfo>, String> {
tracing::debug!("Listing MCP servers");
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("mcp")
.arg("list")
.output();
@@ -1857,7 +1648,69 @@ pub async fn list_mcp_servers() -> Result<Vec<McpServerInfo>, String> {
Ok(output) => {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let servers = parse_mcp_server_list(&stdout);
let mut servers = Vec::new();
// Parse text output format:
// asana: https://mcp.asana.com/sse (SSE) - ✓ Connected
// gitea: gitea-mcp -t stdio --host https://git.nhcarrigan.com - ✓ Connected
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with("Checking") {
continue;
}
// Split by colon to get name and rest
if let Some((name, rest)) = line.split_once(':') {
let name = name.trim().to_string();
let rest = rest.trim();
// Determine if it's a URL or command
let (url, command, transport, status) = if rest.starts_with("http") {
// HTTP/SSE server: "https://mcp.asana.com/sse (SSE) - ✓ Connected"
let parts: Vec<&str> = rest.split('-').collect();
let url_and_transport = parts[0].trim();
let status = if parts.len() > 1 {
Some(parts[1].trim().trim_start_matches("").trim_start_matches("").trim().to_string())
} else {
None
};
// Extract URL and transport type
let (url, transport) = if let Some((url_part, transport_part)) = url_and_transport.rsplit_once('(') {
let url = url_part.trim().to_string();
let transport = transport_part.trim_end_matches(')').trim().to_lowercase();
(Some(url), transport)
} else {
(Some(url_and_transport.to_string()), "http".to_string())
};
(url, None, transport, status)
} else {
// stdio server: "gitea-mcp -t stdio --host https://git.nhcarrigan.com - ✓ Connected"
let parts: Vec<&str> = rest.split('-').collect();
let command = parts[0].trim().to_string();
let status = if parts.len() > 1 {
let status_part = parts[parts.len() - 1];
Some(status_part.trim().trim_start_matches("").trim_start_matches("").trim().to_string())
} else {
None
};
(None, Some(command), "stdio".to_string(), status)
};
servers.push(McpServerInfo {
name,
command,
url,
transport,
env: None,
status,
});
}
}
tracing::info!("Listed {} MCP servers", servers.len());
Ok(servers)
} else {
@@ -1890,7 +1743,7 @@ pub async fn get_mcp_server(name: String) -> Result<McpServerInfo, String> {
pub async fn remove_mcp_server(name: String) -> Result<String, String> {
tracing::debug!("Removing MCP server: {}", name);
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("mcp")
.arg("remove")
.arg(&name)
@@ -1925,7 +1778,7 @@ pub async fn add_mcp_server(
) -> Result<String, String> {
tracing::debug!("Adding MCP server: {} with transport {}", name, transport);
let mut cmd = create_claude_command();
let mut cmd = std::process::Command::new("claude");
cmd.arg("mcp").arg("add");
// Add transport flag
@@ -1973,7 +1826,7 @@ pub async fn add_mcp_server(
pub async fn get_mcp_server_details(name: String) -> Result<String, String> {
tracing::debug!("Getting detailed info for MCP server: {}", name);
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("mcp")
.arg("get")
.arg(&name)
@@ -2010,49 +1863,6 @@ mod tests {
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 ====================
#[test]
@@ -2302,324 +2112,4 @@ mod tests {
assert!(json.contains("/tmp/test.txt"));
assert!(json.contains("test.txt"));
}
// ==================== CLI Parser Tests ====================
#[test]
fn test_parse_plugin_list_single_enabled() {
let output = r#" macrodata@macrodata
Version: 0.1.3
Scope: user
Status: ✔ enabled"#;
let plugins = parse_plugin_list(output);
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "macrodata@macrodata");
assert_eq!(plugins[0].version, "0.1.3");
assert!(plugins[0].enabled);
assert_eq!(plugins[0].description, None);
}
#[test]
fn test_parse_plugin_list_single_disabled() {
let output = r#" test-plugin@official
Version: 2.0.0
Status: ✘ disabled"#;
let plugins = parse_plugin_list(output);
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "test-plugin@official");
assert_eq!(plugins[0].version, "2.0.0");
assert!(!plugins[0].enabled);
}
#[test]
fn test_parse_plugin_list_multiple() {
let output = r#" macrodata@macrodata
Version: 0.1.3
Status: ✔ enabled
another-plugin@official
Version: 1.5.0
Status: ✘ disabled
third-plugin@test
Version: 3.0.0-beta
Status: ✔ enabled"#;
let plugins = parse_plugin_list(output);
assert_eq!(plugins.len(), 3);
assert_eq!(plugins[0].name, "macrodata@macrodata");
assert_eq!(plugins[0].version, "0.1.3");
assert!(plugins[0].enabled);
assert_eq!(plugins[1].name, "another-plugin@official");
assert_eq!(plugins[1].version, "1.5.0");
assert!(!plugins[1].enabled);
assert_eq!(plugins[2].name, "third-plugin@test");
assert_eq!(plugins[2].version, "3.0.0-beta");
assert!(plugins[2].enabled);
}
#[test]
fn test_parse_plugin_list_empty() {
let output = "";
let plugins = parse_plugin_list(output);
assert_eq!(plugins.len(), 0);
}
#[test]
fn test_parse_marketplace_list_single() {
let output = r#"Configured marketplaces:
claude-plugins-official
Source: GitHub (anthropics/claude-plugins-official)"#;
let marketplaces = parse_marketplace_list(output);
assert_eq!(marketplaces.len(), 1);
assert_eq!(marketplaces[0].name, "claude-plugins-official");
assert_eq!(marketplaces[0].source, "GitHub (anthropics/claude-plugins-official)");
}
#[test]
fn test_parse_marketplace_list_multiple() {
let output = r#"Configured marketplaces:
claude-plugins-official
Source: GitHub (anthropics/claude-plugins-official)
macrodata
Source: GitHub (ascorbic/macrodata)
custom-marketplace
Source: GitHub (user/custom-marketplace)"#;
let marketplaces = parse_marketplace_list(output);
assert_eq!(marketplaces.len(), 3);
assert_eq!(marketplaces[0].name, "claude-plugins-official");
assert_eq!(marketplaces[1].name, "macrodata");
assert_eq!(marketplaces[2].name, "custom-marketplace");
}
#[test]
fn test_parse_marketplace_list_empty() {
let output = "Configured marketplaces:\n\n";
let marketplaces = parse_marketplace_list(output);
assert_eq!(marketplaces.len(), 0);
}
#[test]
fn test_parse_mcp_server_list_sse_connected() {
let output = "asana: https://mcp.asana.com/sse (SSE) - ✓ Connected";
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "asana");
assert_eq!(servers[0].url, Some("https://mcp.asana.com/sse".to_string()));
assert_eq!(servers[0].command, None);
assert_eq!(servers[0].transport, "sse");
assert_eq!(servers[0].status, Some("Connected".to_string()));
}
#[test]
fn test_parse_mcp_server_list_http_connected() {
let output = "test-server: https://api.example.com/mcp (HTTP) - ✓ Connected";
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "test-server");
assert_eq!(servers[0].url, Some("https://api.example.com/mcp".to_string()));
assert_eq!(servers[0].transport, "http");
assert_eq!(servers[0].status, Some("Connected".to_string()));
}
#[test]
fn test_parse_mcp_server_list_stdio_connected() {
let output = "gitea: gitea-mcp -t stdio --host https://git.nhcarrigan.com - ✓ Connected";
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "gitea");
assert_eq!(servers[0].url, None);
assert_eq!(servers[0].command, Some("gitea-mcp -t stdio --host https://git.nhcarrigan.com".to_string()));
assert_eq!(servers[0].transport, "stdio");
assert_eq!(servers[0].status, Some("Connected".to_string()));
}
#[test]
fn test_parse_mcp_server_list_failed_connection() {
let output = "broken-server: https://invalid.com (SSE) - ✗ Failed to connect";
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "broken-server");
assert_eq!(servers[0].status, Some("Failed to connect".to_string()));
}
#[test]
fn test_parse_mcp_server_list_multiple() {
let output = r#"asana: https://mcp.asana.com/sse (SSE) - ✓ Connected
gitea: gitea-mcp -t stdio (STDIO) - ✓ Connected
notion: https://mcp.notion.so (HTTP) - ✓ Connected"#;
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 3);
assert_eq!(servers[0].name, "asana");
assert_eq!(servers[0].transport, "sse");
assert_eq!(servers[1].name, "gitea");
assert_eq!(servers[1].transport, "stdio");
assert_eq!(servers[2].name, "notion");
assert_eq!(servers[2].transport, "http");
}
#[test]
fn test_parse_mcp_server_list_with_checking_line() {
let output = r#"Checking MCP servers...
asana: https://mcp.asana.com/sse (SSE) - ✓ Connected"#;
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "asana");
}
#[test]
fn test_parse_mcp_server_list_empty() {
let output = "";
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 0);
}
#[test]
fn test_parse_mcp_server_list_plugin_provided() {
let output = "plugin:macrodata:macrodata: plugin macrodata - ✗ Failed to connect";
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "plugin:macrodata:macrodata");
assert_eq!(servers[0].command, Some("plugin macrodata".to_string()));
assert_eq!(servers[0].transport, "stdio");
}
// ==================== Edge Case Tests ====================
#[test]
fn test_parse_plugin_list_with_unicode_names() {
let output = r#" 日本語-plugin@marketplace
Version: 1.0.0
Status: ✔ enabled
émoji-🎉-plugin@marketplace
Version: 2.0.0
Status: ✗ disabled"#;
let plugins = parse_plugin_list(output);
assert_eq!(plugins.len(), 2);
assert_eq!(plugins[0].name, "日本語-plugin@marketplace");
assert!(plugins[0].enabled);
assert_eq!(plugins[1].name, "émoji-🎉-plugin@marketplace");
assert!(!plugins[1].enabled);
}
#[test]
fn test_parse_plugin_list_missing_version() {
let output = r#" broken-plugin@marketplace
Status: ✔ enabled"#;
let plugins = parse_plugin_list(output);
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "broken-plugin@marketplace");
assert_eq!(plugins[0].version, ""); // Empty version
assert!(plugins[0].enabled);
}
#[test]
fn test_parse_plugin_list_missing_status() {
let output = r#" incomplete-plugin@marketplace
Version: 1.0.0"#;
let plugins = parse_plugin_list(output);
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "incomplete-plugin@marketplace");
assert_eq!(plugins[0].version, "1.0.0");
assert!(!plugins[0].enabled); // Defaults to false when status missing
}
#[test]
fn test_parse_marketplace_list_with_unicode() {
let output = r#" 日本語-marketplace
Source: github/日本語/repo
emoji-🚀-marketplace
Source: github/emoji/🚀-repo"#;
let marketplaces = parse_marketplace_list(output);
assert_eq!(marketplaces.len(), 2);
assert_eq!(marketplaces[0].name, "日本語-marketplace");
assert_eq!(marketplaces[0].source, "github/日本語/repo");
assert_eq!(marketplaces[1].name, "emoji-🚀-marketplace");
}
#[test]
fn test_parse_mcp_server_list_with_unicode_names() {
let output = "日本語-server: https://example.com/日本語 (SSE) - ✓ Connected";
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "日本語-server");
assert_eq!(servers[0].url, Some("https://example.com/日本語".to_string()));
}
#[test]
fn test_parse_mcp_server_list_very_long_command() {
let output = "long-cmd: some-binary --flag1 value1 --flag2 value2 --flag3 value3 --flag4 value4 --flag5 value5 --very-long-option with-a-very-long-value - ✓ Connected";
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "long-cmd");
assert_eq!(
servers[0].command,
Some("some-binary --flag1 value1 --flag2 value2 --flag3 value3 --flag4 value4 --flag5 value5 --very-long-option with-a-very-long-value".to_string())
);
}
#[test]
fn test_parse_mcp_server_list_no_status() {
let output = "pending-server: https://example.com (HTTP)";
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "pending-server");
assert_eq!(servers[0].status, None);
}
#[test]
fn test_parse_plugin_list_with_extra_whitespace() {
let output = r#" whitespace-plugin@marketplace
Version: 1.0.0
Status: ✔ enabled "#;
let plugins = parse_plugin_list(output);
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "whitespace-plugin@marketplace");
assert_eq!(plugins[0].version, "1.0.0");
assert!(plugins[0].enabled);
}
#[test]
fn test_parse_mcp_server_list_multiple_with_checking() {
let output = r#"Checking connections...
asana: https://mcp.asana.com/sse (SSE) - ✓ Connected
gitea: gitea-mcp -t stdio (STDIO) - ✓ Connected"#;
let servers = parse_mcp_server_list(output);
assert_eq!(servers.len(), 2); // Should ignore "Checking" line
assert_eq!(servers[0].name, "asana");
assert_eq!(servers[1].name, "gitea");
}
}
-79
View File
@@ -76,82 +76,3 @@ where
let _ = self.app.emit("debug:log", log_event);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_debug_log_event_creation() {
let event = DebugLogEvent {
level: "info".to_string(),
message: "Test message".to_string(),
};
assert_eq!(event.level, "info");
assert_eq!(event.message, "Test message");
}
#[test]
fn test_debug_log_event_serialization() {
let event = DebugLogEvent {
level: "error".to_string(),
message: "Error occurred".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"level\":\"error\""));
assert!(json.contains("\"message\":\"Error occurred\""));
}
#[test]
fn test_debug_log_event_deserialization() {
let json = r#"{"level":"warn","message":"Warning message"}"#;
let event: DebugLogEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.level, "warn");
assert_eq!(event.message, "Warning message");
}
#[test]
fn test_debug_log_event_with_special_characters() {
let event = DebugLogEvent {
level: "info".to_string(),
message: "Message with \"quotes\" and \n newlines".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
let decoded: DebugLogEvent = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.level, event.level);
assert_eq!(decoded.message, event.message);
}
#[test]
fn test_debug_log_event_with_unicode() {
let event = DebugLogEvent {
level: "debug".to_string(),
message: "Unicode: 日本語 🎉".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
let decoded: DebugLogEvent = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.message, "Unicode: 日本語 🎉");
}
#[test]
fn test_debug_log_event_all_levels() {
let levels = vec!["error", "warn", "info", "debug", "trace"];
for level in levels {
let event = DebugLogEvent {
level: level.to_string(),
message: format!("{} level message", level),
};
assert_eq!(event.level, level);
assert!(event.message.contains(level));
}
}
}
+29 -318
View File
@@ -1,83 +1,6 @@
use std::process::Command;
use tauri::command;
/// Generate PowerShell script for Windows Toast Notification
fn generate_powershell_toast_script(title: &str, body: &str) -> String {
format!(
r#"
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
$APP_ID = 'Hikari Desktop'
$template = @"
<toast>
<visual>
<binding template="ToastText02">
<text id="1">{}</text>
<text id="2">{}</text>
</binding>
</visual>
<audio src="ms-winsoundevent:Notification.Default" />
</toast>
"@
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
$xml.LoadXml($template)
$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($APP_ID).Show($toast)
"#,
title.replace("\"", "`\""),
body.replace("\"", "`\"")
)
}
/// Format simple notification message
fn format_simple_notification(title: &str, body: &str) -> String {
format!("{}\n\n{}", title, body)
}
/// Build notify-send command for testing (doesn't execute)
#[cfg(test)]
fn build_notify_send_command(title: &str, body: &str) -> (String, Vec<String>) {
(
"notify-send".to_string(),
vec![
title.to_string(),
body.to_string(),
"--urgency=normal".to_string(),
"--app-name=Hikari Desktop".to_string(),
],
)
}
/// Build Windows PowerShell command for testing (doesn't execute)
#[cfg(test)]
fn build_windows_powershell_command(title: &str, body: &str) -> (String, Vec<String>) {
let script = generate_powershell_toast_script(title, body);
(
"pwsh.exe".to_string(),
vec![
"-NoProfile".to_string(),
"-WindowStyle".to_string(),
"Hidden".to_string(),
"-Command".to_string(),
script,
],
)
}
/// Build simple notification command for testing (doesn't execute)
#[cfg(test)]
fn build_simple_notification_command(title: &str, body: &str) -> (String, Vec<String>) {
let message = format_simple_notification(title, body);
(
"cmd.exe".to_string(),
vec!["/c".to_string(), "msg".to_string(), "*".to_string(), message],
)
}
#[command]
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
// Use notify-send for Linux/WSL
@@ -105,7 +28,34 @@ pub async fn send_notify_send(title: String, body: String) -> Result<(), String>
#[command]
pub async fn send_windows_notification(title: String, body: String) -> Result<(), String> {
// Create PowerShell script for Windows Toast Notification
let ps_script = generate_powershell_toast_script(&title, &body);
let ps_script = format!(
r#"
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
$APP_ID = 'Hikari Desktop'
$template = @"
<toast>
<visual>
<binding template="ToastText02">
<text id="1">{}</text>
<text id="2">{}</text>
</binding>
</visual>
<audio src="ms-winsoundevent:Notification.Default" />
</toast>
"@
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
$xml.LoadXml($template)
$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($APP_ID).Show($toast)
"#,
title.replace("\"", "`\""),
body.replace("\"", "`\"")
);
// Try PowerShell Core first (pwsh), then fall back to Windows PowerShell
let output = Command::new("pwsh.exe")
@@ -137,7 +87,7 @@ pub async fn send_windows_notification(title: String, body: String) -> Result<()
// Alternative: Use Windows built-in MSG command for simple notifications
#[command]
pub async fn send_simple_notification(title: String, body: String) -> Result<(), String> {
let message = format_simple_notification(&title, &body);
let message = format!("{}\n\n{}", title, body);
Command::new("cmd.exe")
.arg("/c")
@@ -149,242 +99,3 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(),
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_powershell_toast_script_basic() {
let script = generate_powershell_toast_script("Title", "Body");
assert!(script.contains("Hikari Desktop"));
assert!(script.contains("Title"));
assert!(script.contains("Body"));
assert!(script.contains("ToastNotification"));
}
#[test]
fn test_generate_powershell_toast_script_escapes_quotes() {
let script = generate_powershell_toast_script("Title with \"quotes\"", "Body with \"quotes\"");
// Quotes should be escaped as `" in PowerShell
assert!(script.contains("Title with `\"quotes`\""));
assert!(script.contains("Body with `\"quotes`\""));
}
#[test]
fn test_generate_powershell_toast_script_with_special_chars() {
let script = generate_powershell_toast_script("Title: Test", "Body\nwith\nnewlines");
assert!(script.contains("Title: Test"));
assert!(script.contains("Body\nwith\nnewlines"));
}
#[test]
fn test_generate_powershell_toast_script_unicode() {
let script = generate_powershell_toast_script("日本語 Title", "Unicode: 🎉");
assert!(script.contains("日本語 Title"));
assert!(script.contains("Unicode: 🎉"));
}
#[test]
fn test_generate_powershell_toast_script_empty() {
let script = generate_powershell_toast_script("", "");
// Should still contain the structure
assert!(script.contains("Hikari Desktop"));
assert!(script.contains("ToastNotification"));
}
#[test]
fn test_format_simple_notification_basic() {
let message = format_simple_notification("Title", "Body");
assert_eq!(message, "Title\n\nBody");
}
#[test]
fn test_format_simple_notification_with_newlines() {
let message = format_simple_notification("Multi\nLine\nTitle", "Multi\nLine\nBody");
assert!(message.contains("Multi\nLine\nTitle"));
assert!(message.contains("\n\n"));
assert!(message.contains("Multi\nLine\nBody"));
}
#[test]
fn test_format_simple_notification_unicode() {
let message = format_simple_notification("日本語", "🎉 Unicode");
assert_eq!(message, "日本語\n\n🎉 Unicode");
}
#[test]
fn test_format_simple_notification_empty() {
let message = format_simple_notification("", "");
assert_eq!(message, "\n\n");
}
#[test]
fn test_format_simple_notification_long_text() {
let long_title = "A".repeat(1000);
let long_body = "B".repeat(1000);
let message = format_simple_notification(&long_title, &long_body);
assert!(message.starts_with(&long_title));
assert!(message.ends_with(&long_body));
assert!(message.contains("\n\n"));
}
#[test]
fn test_generate_powershell_toast_script_multiple_quotes() {
let script = generate_powershell_toast_script(
"\"Quoted\" \"Multiple\" \"Times\"",
"\"More\" \"Quotes\" \"Here\""
);
// Each quote should be escaped
assert!(script.contains("`\"Quoted`\" `\"Multiple`\" `\"Times`\""));
assert!(script.contains("`\"More`\" `\"Quotes`\" `\"Here`\""));
}
// E2E Integration Tests - Command Structure Verification
#[test]
fn test_e2e_notify_send_command_structure() {
let (command, args) = build_notify_send_command("Test Title", "Test Body");
assert_eq!(command, "notify-send");
assert_eq!(args.len(), 4);
assert_eq!(args[0], "Test Title");
assert_eq!(args[1], "Test Body");
assert_eq!(args[2], "--urgency=normal");
assert_eq!(args[3], "--app-name=Hikari Desktop");
}
#[test]
fn test_e2e_notify_send_with_special_chars() {
let (command, args) =
build_notify_send_command("Title with \"quotes\"", "Body\nwith\nnewlines");
assert_eq!(command, "notify-send");
assert_eq!(args[0], "Title with \"quotes\"");
assert_eq!(args[1], "Body\nwith\nnewlines");
// notify-send handles these directly
}
#[test]
fn test_e2e_windows_powershell_command_structure() {
let (command, args) = build_windows_powershell_command("Test Title", "Test Body");
assert_eq!(command, "pwsh.exe");
assert_eq!(args.len(), 5);
assert_eq!(args[0], "-NoProfile");
assert_eq!(args[1], "-WindowStyle");
assert_eq!(args[2], "Hidden");
assert_eq!(args[3], "-Command");
// Verify the script in args[4] contains expected elements
let script = &args[4];
assert!(script.contains("Test Title"));
assert!(script.contains("Test Body"));
assert!(script.contains("Hikari Desktop"));
assert!(script.contains("ToastNotification"));
}
#[test]
fn test_e2e_windows_powershell_quote_escaping() {
let (_, args) =
build_windows_powershell_command("Title with \"quotes\"", "Body with \"quotes\"");
let script = &args[4];
// Verify quotes are properly escaped in the PowerShell script
assert!(script.contains("Title with `\"quotes`\""));
assert!(script.contains("Body with `\"quotes`\""));
}
#[test]
fn test_e2e_simple_notification_command_structure() {
let (command, args) = build_simple_notification_command("Test Title", "Test Body");
assert_eq!(command, "cmd.exe");
assert_eq!(args.len(), 4);
assert_eq!(args[0], "/c");
assert_eq!(args[1], "msg");
assert_eq!(args[2], "*");
assert_eq!(args[3], "Test Title\n\nTest Body");
}
#[test]
fn test_e2e_simple_notification_multiline() {
let (_, args) =
build_simple_notification_command("Multi\nLine\nTitle", "Multi\nLine\nBody");
let message = &args[3];
assert!(message.contains("Multi\nLine\nTitle"));
assert!(message.contains("\n\n"));
assert!(message.contains("Multi\nLine\nBody"));
}
#[test]
fn test_e2e_command_consistency_across_platforms() {
// Test that different platforms use consistent parameters
let title = "Consistency Test";
let body = "Testing cross-platform consistency";
// Linux command
let (notify_cmd, notify_args) = build_notify_send_command(title, body);
assert!(notify_cmd.contains("notify"));
assert!(notify_args.iter().any(|arg| arg.contains("Hikari Desktop")));
// Windows PowerShell command
let (ps_cmd, ps_args) = build_windows_powershell_command(title, body);
assert!(ps_cmd.contains("pwsh") || ps_cmd.contains("powershell"));
let ps_script = &ps_args[4];
assert!(ps_script.contains("Hikari Desktop"));
// Windows simple command
let (msg_cmd, msg_args) = build_simple_notification_command(title, body);
assert!(msg_cmd.contains("cmd"));
assert!(msg_args[3].contains(title));
assert!(msg_args[3].contains(body));
}
#[test]
fn test_e2e_unicode_support_across_platforms() {
let title = "日本語 Title";
let body = "Unicode: 🎉";
// Verify all platforms preserve unicode
let (_, notify_args) = build_notify_send_command(title, body);
assert_eq!(notify_args[0], title);
assert_eq!(notify_args[1], body);
let (_, ps_args) = build_windows_powershell_command(title, body);
let ps_script = &ps_args[4];
assert!(ps_script.contains(title));
assert!(ps_script.contains(body));
let (_, msg_args) = build_simple_notification_command(title, body);
assert!(msg_args[3].contains(title));
assert!(msg_args[3].contains(body));
}
#[test]
fn test_e2e_empty_input_handling() {
// Test that empty inputs are handled gracefully
let (_, notify_args) = build_notify_send_command("", "");
assert_eq!(notify_args[0], "");
assert_eq!(notify_args[1], "");
let (_, ps_args) = build_windows_powershell_command("", "");
let ps_script = &ps_args[4];
assert!(ps_script.contains("Hikari Desktop")); // Still has app name
let (_, msg_args) = build_simple_notification_command("", "");
assert_eq!(msg_args[3], "\n\n");
}
}
-167
View File
@@ -129,11 +129,6 @@ impl WslBridge {
return Err("Process already running".to_string());
}
// Check if Claude binary is installed before attempting to start
if Command::new("which").arg("claude").output().ok().is_none_or(|output| !output.status.success()) {
return Err("Claude Code is not installed. Please install it using:\n\ncurl -fsSL https://claude.ai/install.sh | bash".to_string());
}
// Load saved achievements and stats when starting a new session
let app_clone = app.clone();
let stats = self.stats.clone();
@@ -683,34 +678,6 @@ fn handle_stderr(
}
}
// Check if this is a SubagentStop hook message
if line.contains("[SubagentStop Hook]") {
if let Some(stop_data) = parse_subagent_stop_hook(&line) {
tracing::debug!("Parsed SubagentStop hook: tool_use_id={:?}",
stop_data.parent_tool_use_id);
// Emit agent-end event if we have a tool_use_id
if let Some(tool_use_id) = stop_data.parent_tool_use_id {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let _ = app.emit(
"claude:agent-end",
AgentEndEvent {
tool_use_id,
ended_at: now,
is_error: false,
conversation_id: conversation_id.clone(),
duration_ms: None,
num_turns: None,
},
);
}
}
}
// Still emit the stderr line as output
let _ = app.emit(
"claude:output",
@@ -765,30 +732,6 @@ fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
})
}
#[derive(Debug)]
struct SubagentStopData {
parent_tool_use_id: Option<String>,
}
fn parse_subagent_stop_hook(line: &str) -> Option<SubagentStopData> {
// Parse: [SubagentStop Hook] ... parent_tool_use_id=Some("toolu_xxx"), ...
// Extract parent_tool_use_id if present
let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") {
line.split("parent_tool_use_id=Some(\"")
.nth(1)?
.split('"')
.next()
.map(|s| s.to_string())
} else {
None
};
Some(SubagentStopData {
parent_tool_use_id,
})
}
fn process_json_line(
line: &str,
app: &AppHandle,
@@ -1873,22 +1816,6 @@ mod tests {
assert!(!bridge.is_running());
}
#[test]
fn test_claude_binary_check_command_structure() {
// Test that we're using the correct command to check for Claude binary
let output = Command::new("which").arg("claude").output();
// The command should execute successfully (even if claude is not found)
// We're just verifying the command structure is valid
assert!(output.is_ok(), "which command should execute without error");
// Verify the check logic returns a boolean
// This is the same logic used in start() to check if claude is installed
let _result = output.ok().is_none_or(|o| !o.status.success());
// If claude is not installed, _result will be true (show error)
// If claude is installed, _result will be false (proceed with connection)
}
#[test]
fn test_create_shared_bridge_manager() {
use crate::bridge_manager::create_shared_bridge_manager;
@@ -1896,98 +1823,4 @@ mod tests {
let manager = shared.lock();
assert!(manager.get_active_conversations().is_empty());
}
// SubagentStart hook parsing tests
#[test]
fn test_parse_subagent_start_hook_with_parent() {
let line = r#"[SubagentStart Hook] agent_id=agent-abc123, parent_tool_use_id=Some("toolu_01XYZ789"), session_id=123"#;
let result = parse_subagent_start_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.agent_id, "agent-abc123");
assert_eq!(data.parent_tool_use_id, Some("toolu_01XYZ789".to_string()));
}
#[test]
fn test_parse_subagent_start_hook_without_parent() {
let line = r#"[SubagentStart Hook] agent_id=agent-xyz789, parent_tool_use_id=None, session_id=456"#;
let result = parse_subagent_start_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.agent_id, "agent-xyz789");
assert_eq!(data.parent_tool_use_id, None);
}
#[test]
fn test_parse_subagent_start_hook_invalid() {
let line = "[SubagentStart Hook] invalid data";
let result = parse_subagent_start_hook(line);
assert!(result.is_none());
}
#[test]
fn test_parse_subagent_start_hook_with_extra_fields() {
let line = r#"[SubagentStart Hook] agent_id=agent-test, parent_tool_use_id=Some("toolu_test"), session_id=789, cwd=/home/user"#;
let result = parse_subagent_start_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.agent_id, "agent-test");
assert_eq!(data.parent_tool_use_id, Some("toolu_test".to_string()));
}
// SubagentStop hook parsing tests
#[test]
fn test_parse_subagent_stop_hook_with_parent() {
let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), session_id=123"#;
let result = parse_subagent_stop_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.parent_tool_use_id, Some("toolu_01ABC123".to_string()));
}
#[test]
fn test_parse_subagent_stop_hook_without_parent() {
let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=None, session_id=456"#;
let result = parse_subagent_stop_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.parent_tool_use_id, None);
}
#[test]
fn test_parse_subagent_stop_hook_minimal() {
let line = r#"[SubagentStop Hook] parent_tool_use_id=Some("toolu_minimal")"#;
let result = parse_subagent_stop_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.parent_tool_use_id, Some("toolu_minimal".to_string()));
}
#[test]
fn test_parse_subagent_stop_hook_with_extra_fields() {
let line = r#"[SubagentStop Hook] stop_hook_active=false, parent_tool_use_id=Some("toolu_extra"), session_id=789, transcript_path=/path/to/transcript"#;
let result = parse_subagent_stop_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.parent_tool_use_id, Some("toolu_extra".to_string()));
}
#[test]
fn test_parse_subagent_stop_hook_empty() {
let line = "[SubagentStop Hook]";
let result = parse_subagent_stop_hook(line);
// Should still return Some with None parent_tool_use_id
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.parent_tool_use_id, None);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop",
"version": "1.5.1",
"version": "1.4.0",
"identifier": "com.naomi.hikari-desktop",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -118,8 +118,6 @@
try {
await invoke("interrupt_claude", { conversationId: currentConversationId });
// Mark all running agents as errored after killing the process
agentStore.markAllErrored(currentConversationId);
} catch (error) {
console.error("Failed to kill Claude process:", error);
}
-13
View File
@@ -1071,7 +1071,6 @@ User: ${formattedMessage}`;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.control-button {
@@ -1088,18 +1087,6 @@ User: ${formattedMessage}`;
transition: all 0.2s;
font-size: 14px;
white-space: nowrap;
flex-shrink: 0;
}
/* Hide button text on smaller screens, show icons only */
@media (max-width: 640px) {
.control-button span {
display: none;
}
.control-button {
padding: 10px;
min-width: 40px;
}
}
.control-button:hover {
+6 -14
View File
@@ -12,7 +12,7 @@
command: string | null;
url: string | null;
transport: string; // "stdio", "http", or "sse"
env: Record<string, string> | null;
env: any | null;
status: string | null; // "Connected" or "Failed to connect"
}
@@ -184,9 +184,7 @@
<!-- Add Server Form -->
{#if showAddForm}
<div
class="mx-4 mt-4 p-4 bg-[var(--bg-secondary)]/50 border border-[var(--border-color)] rounded-lg"
>
<div class="mx-4 mt-4 p-4 bg-[var(--bg-secondary)]/50 border border-[var(--border-color)] rounded-lg">
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-3">Add MCP Server</h3>
<div class="space-y-3">
<div>
@@ -313,9 +311,7 @@
<!-- Server Details Panel -->
{#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>
{#if isLoadingDetails}
@@ -380,11 +376,7 @@
>Environment</label
>
<pre
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto">{JSON.stringify(
selectedServer.env,
null,
2
)}</pre>
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto">{JSON.stringify(selectedServer.env, null, 2)}</pre>
</div>
{/if}
@@ -402,8 +394,8 @@
<!-- Actions -->
<div class="pt-4 border-t border-[var(--border-color)]">
<button
onclick={() => selectedServer && removeServer(selectedServer.name)}
disabled={actionInProgress === selectedServer?.name}
onclick={() => removeServer(selectedServer.name)}
disabled={actionInProgress === selectedServer.name}
class="w-full px-4 py-2 bg-red-500/20 border border-red-500/30 rounded-lg text-sm text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<Trash2 class="w-4 h-4" />
+2 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { readTextFile } from "@tauri-apps/plugin-fs";
import Markdown from "./Markdown.svelte";
let memoryFiles: string[] = $state([]);
@@ -32,8 +33,7 @@
isLoading = true;
error = null;
try {
// Use our backend command instead of Tauri plugin to handle WSL paths
const content = await invoke<string>("read_file_content", { path: filePath });
const content = await readTextFile(filePath);
fileContent = content;
selectedFile = filePath;
} catch (e) {
+7 -18
View File
@@ -17,21 +17,8 @@
let isImporting = $state(false);
let showClearAllConfirm = $state(false);
let sessions = $state<SessionListItem[]>([]);
let isLoading = $state(false);
$effect(() => {
const unsubSessions = sessionsStore.sessions.subscribe((value) => {
sessions = value;
});
const unsubLoading = sessionsStore.isLoading.subscribe((value) => {
isLoading = value;
});
return () => {
unsubSessions();
unsubLoading();
};
});
const sessions = $derived(sessionsStore.sessions);
const isLoading = $derived(sessionsStore.isLoading);
onMount(() => {
sessionsStore.loadSessions();
@@ -316,11 +303,11 @@
</div>
<div class="overflow-y-auto flex-1">
{#if isLoading}
{#if $isLoading}
<div class="flex items-center justify-center p-8">
<div class="text-[var(--text-tertiary)]">Loading sessions...</div>
</div>
{:else if sessions.length === 0}
{:else if $sessions.length === 0}
<div class="flex flex-col items-center justify-center p-8 text-center">
<svg
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
@@ -342,7 +329,7 @@
</div>
{:else}
<div class="divide-y divide-[var(--border-color)]">
{#each sessions as session (session.id)}
{#each $sessions as session (session.id)}
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
<div class="flex items-start justify-between gap-4">
<button class="flex-1 text-left" onclick={() => handleViewSession(session)}>
@@ -464,6 +451,7 @@
</div>
{#if showClearAllConfirm}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
onclick={() => (showClearAllConfirm = false)}
@@ -471,6 +459,7 @@
tabindex="0"
onkeydown={(e) => e.key === "Escape" && (showClearAllConfirm = false)}
>
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
class="bg-[var(--bg-primary)] border border-red-500/30 rounded-lg shadow-xl max-w-md w-full p-6"
onclick={(e) => e.stopPropagation()}
+4 -33
View File
@@ -381,16 +381,16 @@
{/if}
</div>
<div class="flex items-center gap-2 flex-wrap min-w-0">
<div class="flex items-center gap-3">
{#if streamerModeActive}
<div
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse shrink-0"
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse"
title="Streamer mode active (Ctrl+Shift+S to toggle)"
></div>
{/if}
<button
onclick={() => (showProfile = true)}
class="p-1 text-gray-500 icon-trans-hover shrink-0"
class="p-1 text-gray-500 icon-trans-hover"
title="Profile"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -696,7 +696,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div>
<div class="fixed top-14 right-4 z-50 max-h-[calc(100vh-4rem)] overflow-y-auto">
<div class="fixed top-14 right-4 z-50">
<StatsDisplay
onRequestSummary={handleCompactConversation}
onStartFreshWithContext={handleStartFreshWithContext}
@@ -744,32 +744,3 @@
{#if showMcpPanel}
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
{/if}
<style>
/* Responsive status bar styling */
.status-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
/* Make all icon buttons shrink but not grow */
.status-bar button {
flex-shrink: 0;
}
/* Hide version text on very small screens */
@media (max-width: 640px) {
.status-bar button span:last-of-type {
display: none;
}
}
/* Stack left and right sections on very small screens */
@media (max-width: 768px) {
.status-bar {
flex-direction: column;
gap: 0.75rem;
}
}
</style>
+6 -1
View File
@@ -47,7 +47,12 @@
width="14"
height="14"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { todos } from "$lib/stores/todos";
import { todos, type TodoItem } from "$lib/stores/todos";
import { CheckCircle, Circle, Loader } from "lucide-svelte";
interface Props {
-325
View File
@@ -1,325 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import { agentStore, getAgentsForConversation, runningAgentCount } from "./agents";
import { get } from "svelte/store";
import type { AgentInfo } from "$lib/types/agents";
describe("agents store", () => {
const conversationId = "test-conversation-1";
const otherConversationId = "test-conversation-2";
const createMockAgent = (overrides?: Partial<AgentInfo>): AgentInfo => ({
toolUseId: "toolu_test123",
description: "Test agent",
subagentType: "Explore",
startedAt: Date.now(),
status: "running",
...overrides,
});
beforeEach(() => {
// Clear all conversations by subscribing and getting state
let state: Record<string, AgentInfo[]> = {};
const unsub = agentStore.subscribe((s) => {
state = s;
});
unsub();
// Clear each conversation
for (const convId of Object.keys(state)) {
agentStore.clearConversation(convId);
}
});
describe("addAgent", () => {
it("adds an agent to a conversation", () => {
const agent = createMockAgent();
agentStore.addAgent(conversationId, agent);
const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(1);
expect(agents[0]).toEqual(agent);
});
it("adds multiple agents to the same conversation", () => {
const agent1 = createMockAgent({ toolUseId: "tool1" });
const agent2 = createMockAgent({ toolUseId: "tool2" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(conversationId, agent2);
const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(2);
expect(agents[0]).toEqual(agent1);
expect(agents[1]).toEqual(agent2);
});
it("keeps agents in different conversations separate", () => {
const agent1 = createMockAgent({ toolUseId: "tool1" });
const agent2 = createMockAgent({ toolUseId: "tool2" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(otherConversationId, agent2);
const agents1 = get(getAgentsForConversation(conversationId));
const agents2 = get(getAgentsForConversation(otherConversationId));
expect(agents1).toHaveLength(1);
expect(agents2).toHaveLength(1);
expect(agents1[0]).toEqual(agent1);
expect(agents2[0]).toEqual(agent2);
});
});
describe("updateAgentId", () => {
it("updates the agent_id for a specific agent", () => {
const agent = createMockAgent({ agentId: undefined });
agentStore.addAgent(conversationId, agent);
agentStore.updateAgentId(conversationId, agent.toolUseId, "agent-abc123");
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].agentId).toBe("agent-abc123");
});
it("does nothing if conversation doesn't exist", () => {
agentStore.updateAgentId("nonexistent", "tool1", "agent1");
// Should not throw
expect(true).toBe(true);
});
it("does nothing if tool_use_id doesn't exist", () => {
const agent = createMockAgent();
agentStore.addAgent(conversationId, agent);
agentStore.updateAgentId(conversationId, "nonexistent-tool", "agent1");
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].agentId).toBeUndefined();
});
});
describe("endAgent", () => {
it("marks an agent as completed", () => {
const agent = createMockAgent({ status: "running" });
agentStore.addAgent(conversationId, agent);
const endTime = Date.now();
agentStore.endAgent(conversationId, agent.toolUseId, endTime, false);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].status).toBe("completed");
expect(agents[0].endedAt).toBe(endTime);
expect(agents[0].durationMs).toBeGreaterThanOrEqual(0); // Duration can be 0 if timestamps are the same
});
it("marks an agent as errored", () => {
const agent = createMockAgent({ status: "running" });
agentStore.addAgent(conversationId, agent);
const endTime = Date.now();
agentStore.endAgent(conversationId, agent.toolUseId, endTime, true);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].status).toBe("errored");
expect(agents[0].endedAt).toBe(endTime);
});
it("calculates duration correctly", () => {
const startTime = Date.now() - 5000; // 5 seconds ago
const agent = createMockAgent({ startedAt: startTime, status: "running" });
agentStore.addAgent(conversationId, agent);
const endTime = Date.now();
agentStore.endAgent(conversationId, agent.toolUseId, endTime, false);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].durationMs).toBeGreaterThanOrEqual(5000);
expect(agents[0].durationMs).toBeLessThanOrEqual(6000); // Allow some buffer
});
it("does nothing if conversation doesn't exist", () => {
agentStore.endAgent("nonexistent", "tool1", Date.now(), false);
// Should not throw
expect(true).toBe(true);
});
it("does nothing if agent doesn't exist", () => {
const agent = createMockAgent();
agentStore.addAgent(conversationId, agent);
agentStore.endAgent(conversationId, "nonexistent-tool", Date.now(), false);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].status).toBe("running"); // Status unchanged
});
});
describe("markAllErrored", () => {
it("marks all running agents as errored", () => {
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
const agent3 = createMockAgent({ toolUseId: "tool3", status: "completed" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(conversationId, agent2);
agentStore.addAgent(conversationId, agent3);
agentStore.markAllErrored(conversationId);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].status).toBe("errored");
expect(agents[0].endedAt).toBeGreaterThan(0);
expect(agents[1].status).toBe("errored");
expect(agents[1].endedAt).toBeGreaterThan(0);
expect(agents[2].status).toBe("completed"); // Already completed, unchanged
});
it("does nothing if conversation doesn't exist", () => {
agentStore.markAllErrored("nonexistent");
// Should not throw
expect(true).toBe(true);
});
it("does nothing if conversation has no running agents", () => {
const agent = createMockAgent({ status: "completed" });
agentStore.addAgent(conversationId, agent);
agentStore.markAllErrored(conversationId);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].status).toBe("completed"); // Unchanged
});
});
describe("clearCompleted", () => {
it("removes completed and errored agents", () => {
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
const agent2 = createMockAgent({ toolUseId: "tool2", status: "completed" });
const agent3 = createMockAgent({ toolUseId: "tool3", status: "errored" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(conversationId, agent2);
agentStore.addAgent(conversationId, agent3);
agentStore.clearCompleted(conversationId);
const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(1);
expect(agents[0].toolUseId).toBe("tool1"); // Only running agent remains
});
it("does nothing if conversation doesn't exist", () => {
agentStore.clearCompleted("nonexistent");
// Should not throw
expect(true).toBe(true);
});
it("clears all agents if all are completed", () => {
const agent1 = createMockAgent({ toolUseId: "tool1", status: "completed" });
const agent2 = createMockAgent({ toolUseId: "tool2", status: "errored" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(conversationId, agent2);
agentStore.clearCompleted(conversationId);
const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(0);
});
});
describe("clearConversation", () => {
it("removes all agents from a conversation", () => {
const agent1 = createMockAgent({ toolUseId: "tool1" });
const agent2 = createMockAgent({ toolUseId: "tool2" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(conversationId, agent2);
agentStore.clearConversation(conversationId);
const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(0);
});
it("only removes agents from the specified conversation", () => {
const agent1 = createMockAgent({ toolUseId: "tool1" });
const agent2 = createMockAgent({ toolUseId: "tool2" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(otherConversationId, agent2);
agentStore.clearConversation(conversationId);
const agents1 = get(getAgentsForConversation(conversationId));
const agents2 = get(getAgentsForConversation(otherConversationId));
expect(agents1).toHaveLength(0);
expect(agents2).toHaveLength(1);
expect(agents2[0]).toEqual(agent2);
});
it("does nothing if conversation doesn't exist", () => {
agentStore.clearConversation("nonexistent");
// Should not throw
expect(true).toBe(true);
});
});
describe("runningAgentCount", () => {
it("counts running agents across all conversations", () => {
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
const agent3 = createMockAgent({ toolUseId: "tool3", status: "completed" });
const agent4 = createMockAgent({ toolUseId: "tool4", status: "running" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(conversationId, agent2);
agentStore.addAgent(conversationId, agent3);
agentStore.addAgent(otherConversationId, agent4);
const count = get(runningAgentCount);
expect(count).toBe(3); // 2 from first conversation + 1 from second
});
it("returns 0 when no agents are running", () => {
const agent1 = createMockAgent({ status: "completed" });
const agent2 = createMockAgent({ status: "errored" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(otherConversationId, agent2);
const count = get(runningAgentCount);
expect(count).toBe(0);
});
it("updates when agents complete", () => {
const agent = createMockAgent({ status: "running" });
agentStore.addAgent(conversationId, agent);
let count = get(runningAgentCount);
expect(count).toBe(1);
agentStore.endAgent(conversationId, agent.toolUseId, Date.now(), false);
count = get(runningAgentCount);
expect(count).toBe(0);
});
it("updates when conversation is cleared", () => {
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(conversationId, agent2);
let count = get(runningAgentCount);
expect(count).toBe(2);
agentStore.clearConversation(conversationId);
count = get(runningAgentCount);
expect(count).toBe(0);
});
});
});
-9
View File
@@ -722,9 +722,6 @@ describe("config store", () => {
it("handles save errors gracefully without losing data", async () => {
const mockInvokeImpl = vi.mocked(invoke);
// Mock console.error to suppress expected error output
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Set initial config
await configStore.updateConfig({ font_size: 14 });
@@ -736,12 +733,6 @@ describe("config store", () => {
// Original config should still be accessible
expect(configStore.getConfig().font_size).toBe(14);
// Verify error was logged
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to save config:", expect.any(Error));
// Restore console.error
consoleErrorSpy.mockRestore();
});
});
-5
View File
@@ -10,7 +10,6 @@ import type { CharacterState } from "$lib/types/states";
import { cleanupConversationTracking } from "$lib/tauri";
import { characterState } from "$lib/stores/character";
import { sessionsStore } from "$lib/stores/sessions";
import { agentStore } from "$lib/stores/agents";
export interface ConversationSummary {
generatedAt: Date;
@@ -334,10 +333,6 @@ function createConversationsStore() {
// Clean up tracking for this conversation (including temp files)
await cleanupConversationTracking(id);
// Clean up agent tracking for this conversation
// This prevents the badge from persisting after tab close
agentStore.clearConversation(id);
conversations.update((c) => {
c.delete(id);
return c;
+1 -3
View File
@@ -183,9 +183,6 @@ export async function initializeTauriListeners() {
// (permission prompts trigger reconnects and agents may complete before reconnect)
if (!skipNextGreeting && targetConversationId) {
agentStore.markAllErrored(targetConversationId);
// Clear the conversation's agents from the store on real disconnect
// This prevents agents from persisting across sessions
agentStore.clearConversation(targetConversationId);
}
// Only remove from connected set if we're not about to reconnect
@@ -469,6 +466,7 @@ export async function initializeDiscordRpc() {
console.log("Discord RPC initialized successfully with initial presence");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("Failed to initialize Discord RPC:", error);
console.warn("Discord RPC will be unavailable. Make sure Discord is running.");
}