generated from nhcarrigan/template
Compare commits
14 Commits
v1.13.0
...
4c67380859
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c67380859 | |||
| f5bc9a5016 | |||
| cf20540a49 | |||
| 70af6a6b72 | |||
| 3194a3cca5 | |||
| 7fecb20ba9 | |||
| 9b3c242333 | |||
| 5d4fa75278 | |||
| edd8fa5b55 | |||
| 7781b2caab | |||
| e397435dbe | |||
| 89e99b1524 | |||
| e40ae989f7 | |||
| 32a74235c0 |
@@ -30,6 +30,47 @@ Example commit command:
|
||||
git commit --author="Hikari <hikari@nhcarrigan.com>" --no-gpg-sign -m "your commit message"
|
||||
```
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
All new features, fixes, and significant changes should include tests whenever possible:
|
||||
|
||||
- **Frontend tests**: Use Vitest with `@testing-library/svelte` for component tests
|
||||
- **Test files**: Place test files next to the code they test with `.test.ts` or `.spec.ts` extension
|
||||
- **Run tests**: Use `pnpm test` to run all tests, or `pnpm test:watch` for watch mode
|
||||
- **Coverage**: Run `pnpm test:coverage` to generate coverage reports
|
||||
- **Rust tests**: Use `pnpm test:backend` for Rust/Tauri backend tests
|
||||
|
||||
### Testing Guidelines
|
||||
|
||||
- Write tests for utility functions, stores, and business logic
|
||||
- For Svelte 5 components, focus on testing the underlying logic functions
|
||||
- Use descriptive test names that explain what behaviour is being tested
|
||||
- Include edge cases and error conditions in test coverage
|
||||
- Mock Tauri APIs using the patterns in `vitest.setup.ts`
|
||||
|
||||
### Example Test Structure
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("FeatureName", () => {
|
||||
it("handles the normal case correctly", () => {
|
||||
// Arrange
|
||||
const input = "test data";
|
||||
|
||||
// Act
|
||||
const result = functionUnderTest(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("expected output");
|
||||
});
|
||||
|
||||
it("handles edge cases gracefully", () => {
|
||||
// Test edge cases...
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 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!
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"codemirror": "^6.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-svelte": "^0.563.0",
|
||||
"marked": "^17.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+12
@@ -119,6 +119,9 @@ importers:
|
||||
highlight.js:
|
||||
specifier: ^11.11.1
|
||||
version: 11.11.1
|
||||
lucide-svelte:
|
||||
specifier: ^0.563.0
|
||||
version: 0.563.0(svelte@5.46.3)
|
||||
marked:
|
||||
specifier: ^17.0.1
|
||||
version: 17.0.1
|
||||
@@ -1668,6 +1671,11 @@ packages:
|
||||
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lucide-svelte@0.563.0:
|
||||
resolution: {integrity: sha512-pjZKw7TpQcamfQrx7YdbOHgmrcNeKiGGMD0tKZQaVktwSsbqw28CsKc2Q97ttwjytiCWkJyOa8ij2Q+Og0nPfQ==}
|
||||
peerDependencies:
|
||||
svelte: ^3 || ^4 || ^5.0.0-next.42
|
||||
|
||||
lz-string@1.5.0:
|
||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||
hasBin: true
|
||||
@@ -3650,6 +3658,10 @@ snapshots:
|
||||
|
||||
lru-cache@11.2.4: {}
|
||||
|
||||
lucide-svelte@0.563.0(svelte@5.46.3):
|
||||
dependencies:
|
||||
svelte: 5.46.3
|
||||
|
||||
lz-string@1.5.0: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
|
||||
Generated
+1
-1
@@ -1636,7 +1636,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hikari-desktop"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
|
||||
@@ -28,6 +28,14 @@
|
||||
"identifier": "fs:allow-write-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": [{ "path": "$HOME/.claude/**" }]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-read-text-file",
|
||||
"allow": [{ "path": "$HOME/.claude/**" }]
|
||||
},
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-set-always-on-top",
|
||||
"core:window:allow-inner-size",
|
||||
|
||||
+693
-9
@@ -1,4 +1,5 @@
|
||||
use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tauri_plugin_http::reqwest;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
@@ -1145,15 +1146,6 @@ pub async fn stop_discord_rpc(
|
||||
discord_rpc.stop()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn log_discord_rpc(
|
||||
discord_rpc: State<'_, std::sync::Arc<crate::discord_rpc::DiscordRpcManager>>,
|
||||
message: String,
|
||||
) -> Result<(), String> {
|
||||
discord_rpc.log(&message);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn close_application(app_handle: AppHandle) -> Result<(), String> {
|
||||
// Get the main window
|
||||
@@ -1167,6 +1159,698 @@ pub async fn close_application(app_handle: AppHandle) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct MemoryFilesResponse {
|
||||
pub files: Vec<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_memory_files() -> Result<MemoryFilesResponse, String> {
|
||||
use std::fs;
|
||||
|
||||
// Get the .claude directory in the user's home
|
||||
let home_dir = match dirs::home_dir() {
|
||||
Some(dir) => dir,
|
||||
None => return Err("Could not find home directory".to_string()),
|
||||
};
|
||||
|
||||
let claude_dir = home_dir.join(".claude");
|
||||
let projects_dir = claude_dir.join("projects");
|
||||
|
||||
if !projects_dir.exists() {
|
||||
return Ok(MemoryFilesResponse { files: Vec::new() });
|
||||
}
|
||||
|
||||
let mut memory_files = Vec::new();
|
||||
|
||||
// Recursively find all memory directories
|
||||
fn find_memory_files(dir: &std::path::Path, files: &mut Vec<String>) -> std::io::Result<()> {
|
||||
if !dir.is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
// Check if this is a "memory" directory
|
||||
if path.file_name().and_then(|n| n.to_str()) == Some("memory") {
|
||||
// List all files in the memory directory
|
||||
for mem_entry in fs::read_dir(&path)? {
|
||||
let mem_entry = mem_entry?;
|
||||
let mem_path = mem_entry.path();
|
||||
|
||||
if mem_path.is_file() {
|
||||
if let Some(path_str) = mem_path.to_str() {
|
||||
files.push(path_str.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Recurse into subdirectories
|
||||
find_memory_files(&path, files)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
if let Err(e) = find_memory_files(&projects_dir, &mut memory_files) {
|
||||
return Err(format!("Failed to list memory files: {}", e));
|
||||
}
|
||||
|
||||
// Sort files alphabetically
|
||||
memory_files.sort();
|
||||
|
||||
Ok(MemoryFilesResponse {
|
||||
files: memory_files,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_claude_version() -> Result<String, String> {
|
||||
tracing::debug!("Getting Claude CLI version");
|
||||
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("--version")
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let version = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.to_string();
|
||||
tracing::info!("Claude CLI version: {}", version);
|
||||
Ok(version)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to get Claude version: {}", error);
|
||||
Err(format!("Failed to get Claude version: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude --version: {}", e);
|
||||
Err(format!("Failed to execute claude --version: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Plugin Management Commands ====================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginInfo {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_plugins() -> Result<Vec<PluginInfo>, String> {
|
||||
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();
|
||||
|
||||
// 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 {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to list plugins: {}", error);
|
||||
Err(format!("Failed to list plugins: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude plugin list: {}", e);
|
||||
Err(format!("Failed to execute claude plugin list: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_plugin(plugin_name: String) -> Result<String, String> {
|
||||
tracing::debug!("Installing plugin: {}", plugin_name);
|
||||
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("install")
|
||||
.arg(&plugin_name)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
tracing::info!("Successfully installed plugin: {}", plugin_name);
|
||||
Ok(message)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to install plugin {}: {}", plugin_name, error);
|
||||
Err(format!("Failed to install plugin: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude plugin install: {}", e);
|
||||
Err(format!("Failed to execute claude plugin install: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn uninstall_plugin(plugin_name: String) -> Result<String, String> {
|
||||
tracing::debug!("Uninstalling plugin: {}", plugin_name);
|
||||
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("uninstall")
|
||||
.arg(&plugin_name)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
tracing::info!("Successfully uninstalled plugin: {}", plugin_name);
|
||||
Ok(message)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to uninstall plugin {}: {}", plugin_name, error);
|
||||
Err(format!("Failed to uninstall plugin: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude plugin uninstall: {}", e);
|
||||
Err(format!("Failed to execute claude plugin uninstall: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn enable_plugin(plugin_name: String) -> Result<String, String> {
|
||||
tracing::debug!("Enabling plugin: {}", plugin_name);
|
||||
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("enable")
|
||||
.arg(&plugin_name)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
tracing::info!("Successfully enabled plugin: {}", plugin_name);
|
||||
Ok(message)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to enable plugin {}: {}", plugin_name, error);
|
||||
Err(format!("Failed to enable plugin: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude plugin enable: {}", e);
|
||||
Err(format!("Failed to execute claude plugin enable: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disable_plugin(plugin_name: String) -> Result<String, String> {
|
||||
tracing::debug!("Disabling plugin: {}", plugin_name);
|
||||
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("disable")
|
||||
.arg(&plugin_name)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
tracing::info!("Successfully disabled plugin: {}", plugin_name);
|
||||
Ok(message)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to disable plugin {}: {}", plugin_name, error);
|
||||
Err(format!("Failed to disable plugin: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude plugin disable: {}", e);
|
||||
Err(format!("Failed to execute claude plugin disable: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_plugin(plugin_name: String) -> Result<String, String> {
|
||||
tracing::debug!("Updating plugin: {}", plugin_name);
|
||||
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("update")
|
||||
.arg(&plugin_name)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
tracing::info!("Successfully updated plugin: {}", plugin_name);
|
||||
Ok(message)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to update plugin {}: {}", plugin_name, error);
|
||||
Err(format!("Failed to update plugin: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude plugin update: {}", e);
|
||||
Err(format!("Failed to execute claude plugin update: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Plugin Marketplace Commands ====================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MarketplaceInfo {
|
||||
pub name: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> {
|
||||
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();
|
||||
|
||||
// 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)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude plugin marketplace list: {}", e);
|
||||
Err(format!(
|
||||
"Failed to execute claude plugin marketplace list: {}",
|
||||
e
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_marketplace(source: String) -> Result<String, String> {
|
||||
tracing::debug!("Adding marketplace: {}", source);
|
||||
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("marketplace")
|
||||
.arg("add")
|
||||
.arg(&source)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
tracing::info!("Successfully added marketplace: {}", source);
|
||||
Ok(message)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to add marketplace {}: {}", source, error);
|
||||
Err(format!("Failed to add marketplace: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude plugin marketplace add: {}", e);
|
||||
Err(format!(
|
||||
"Failed to execute claude plugin marketplace add: {}",
|
||||
e
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_marketplace(name: String) -> Result<String, String> {
|
||||
tracing::debug!("Removing marketplace: {}", name);
|
||||
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("marketplace")
|
||||
.arg("remove")
|
||||
.arg(&name)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
tracing::info!("Successfully removed marketplace: {}", name);
|
||||
Ok(message)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to remove marketplace {}: {}", name, error);
|
||||
Err(format!("Failed to remove marketplace: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude plugin marketplace remove: {}", e);
|
||||
Err(format!(
|
||||
"Failed to execute claude plugin marketplace remove: {}",
|
||||
e
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== MCP Management Commands ====================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerInfo {
|
||||
pub name: String,
|
||||
pub command: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub transport: String, // "stdio", "http", or "sse"
|
||||
pub env: Option<serde_json::Value>,
|
||||
pub status: Option<String>, // "Connected" or "Failed to connect"
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_mcp_servers() -> Result<Vec<McpServerInfo>, String> {
|
||||
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();
|
||||
|
||||
// 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 {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to list MCP servers: {}", error);
|
||||
Err(format!("Failed to list MCP servers: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude mcp list: {}", e);
|
||||
Err(format!("Failed to execute claude mcp list: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_mcp_server(name: String) -> Result<McpServerInfo, String> {
|
||||
tracing::debug!("Getting MCP server details: {}", name);
|
||||
|
||||
// Get all servers and find the matching one
|
||||
let servers = list_mcp_servers().await?;
|
||||
|
||||
servers
|
||||
.into_iter()
|
||||
.find(|s| s.name == name)
|
||||
.ok_or_else(|| format!("MCP server '{}' not found", name))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_mcp_server(name: String) -> Result<String, String> {
|
||||
tracing::debug!("Removing MCP server: {}", name);
|
||||
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("mcp")
|
||||
.arg("remove")
|
||||
.arg(&name)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
tracing::info!("Successfully removed MCP server: {}", name);
|
||||
Ok(message)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to remove MCP server {}: {}", name, error);
|
||||
Err(format!("Failed to remove MCP server: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude mcp remove: {}", e);
|
||||
Err(format!("Failed to execute claude mcp remove: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_mcp_server(
|
||||
name: String,
|
||||
command_or_url: String,
|
||||
transport: String,
|
||||
env_vars: Option<Vec<String>>,
|
||||
headers: Option<Vec<String>>,
|
||||
) -> Result<String, String> {
|
||||
tracing::debug!("Adding MCP server: {} with transport {}", name, transport);
|
||||
|
||||
let mut cmd = std::process::Command::new("claude");
|
||||
cmd.arg("mcp").arg("add");
|
||||
|
||||
// Add transport flag
|
||||
cmd.arg("--transport").arg(&transport);
|
||||
|
||||
// Add environment variables if provided
|
||||
if let Some(env_vars) = env_vars {
|
||||
for env_var in env_vars {
|
||||
cmd.arg("-e").arg(env_var);
|
||||
}
|
||||
}
|
||||
|
||||
// Add headers if provided (for HTTP/SSE)
|
||||
if let Some(headers) = headers {
|
||||
for header in headers {
|
||||
cmd.arg("-H").arg(header);
|
||||
}
|
||||
}
|
||||
|
||||
// Add name and command/URL
|
||||
cmd.arg(&name).arg(&command_or_url);
|
||||
|
||||
let output = cmd.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
tracing::info!("Successfully added MCP server: {}", name);
|
||||
Ok(message)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to add MCP server {}: {}", name, error);
|
||||
Err(format!("Failed to add MCP server: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude mcp add: {}", e);
|
||||
Err(format!("Failed to execute claude mcp add: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_mcp_server_details(name: String) -> Result<String, String> {
|
||||
tracing::debug!("Getting detailed info for MCP server: {}", name);
|
||||
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("mcp")
|
||||
.arg("get")
|
||||
.arg(&name)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let details = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
tracing::debug!("Got MCP server details: {}", details);
|
||||
Ok(details)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to get MCP server details for {}: {}", name, error);
|
||||
Err(format!("Failed to get server details: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude mcp get: {}", e);
|
||||
Err(format!("Failed to execute claude mcp get: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
use discord_rich_presence::activity::{Activity, Assets, Timestamps};
|
||||
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
|
||||
use parking_lot::RwLock;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
pub struct DiscordRpcManager {
|
||||
client: Arc<RwLock<Option<DiscordIpcClient>>>,
|
||||
session_name: Arc<RwLock<String>>,
|
||||
model: Arc<RwLock<String>>,
|
||||
started_at: Arc<RwLock<i64>>,
|
||||
log_path: Arc<RwLock<Option<PathBuf>>>,
|
||||
}
|
||||
|
||||
impl DiscordRpcManager {
|
||||
@@ -22,82 +17,47 @@ impl DiscordRpcManager {
|
||||
session_name: Arc::new(RwLock::new(String::new())),
|
||||
model: Arc::new(RwLock::new(String::new())),
|
||||
started_at: Arc::new(RwLock::new(0)),
|
||||
log_path: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_app_handle(&self, app_handle: &AppHandle) {
|
||||
if let Ok(app_data_dir) = app_handle.path().app_data_dir() {
|
||||
// Ensure the directory exists
|
||||
if let Err(e) = std::fs::create_dir_all(&app_data_dir) {
|
||||
tracing::error!("Failed to create app data directory: {}", e);
|
||||
return;
|
||||
}
|
||||
let log_path = app_data_dir.join("hikari_discord_rpc.log");
|
||||
*self.log_path.write() = Some(log_path.clone());
|
||||
self.log(&format!(
|
||||
"Log file initialised at: {}",
|
||||
log_path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn log(&self, message: &str) {
|
||||
let log_path_guard = self.log_path.read();
|
||||
let path = match log_path_guard.as_ref() {
|
||||
Some(p) => p.clone(),
|
||||
None => PathBuf::from("hikari_discord_rpc.log"),
|
||||
};
|
||||
drop(log_path_guard);
|
||||
|
||||
if let Ok(mut file) = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
{
|
||||
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let _ = writeln!(file, "[{}] {}", timestamp, message);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(&self, initial_session_name: String, initial_model: String, started_at: i64) -> Result<(), String> {
|
||||
self.log("Attempting to initialize Discord RPC...");
|
||||
self.log("DEBUG: Application ID: 1391117878182281316");
|
||||
self.log(&format!("DEBUG: Initial session: '{}', model: '{}', timestamp: {}",
|
||||
initial_session_name, initial_model, started_at));
|
||||
tracing::debug!("Attempting to initialize Discord RPC...");
|
||||
tracing::debug!("Application ID: 1391117878182281316");
|
||||
tracing::debug!("Initial session: '{}', model: '{}', timestamp: {}",
|
||||
initial_session_name, initial_model, started_at);
|
||||
|
||||
let mut client = DiscordIpcClient::new("1391117878182281316")
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Failed to create Discord RPC client: {} (is Discord running?)", e);
|
||||
self.log(&format!("ERROR: {}", error_msg));
|
||||
tracing::error!("{}", error_msg);
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
self.log("DEBUG: DiscordIpcClient created successfully");
|
||||
tracing::debug!("DiscordIpcClient created successfully");
|
||||
|
||||
client
|
||||
.connect()
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Failed to connect to Discord RPC: {} (ensure Discord is running)", e);
|
||||
self.log(&format!("ERROR: {}", error_msg));
|
||||
tracing::error!("{}", error_msg);
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
self.log("DEBUG: Connected to Discord IPC socket");
|
||||
tracing::debug!("Connected to Discord IPC socket");
|
||||
|
||||
// Set initial activity immediately after connecting
|
||||
self.log("DEBUG: Building initial activity...");
|
||||
tracing::debug!("Building initial activity...");
|
||||
let state_text = format!("Model: {}", initial_model);
|
||||
let assets = Assets::new()
|
||||
.large_image("hikari")
|
||||
.large_text("Hikari - Claude Code Assistant");
|
||||
|
||||
self.log("DEBUG: Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
|
||||
tracing::debug!("Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
|
||||
|
||||
let timestamps = Timestamps::new()
|
||||
.start(started_at);
|
||||
|
||||
self.log(&format!("DEBUG: Timestamps created - start: {}", started_at));
|
||||
tracing::debug!("Timestamps created - start: {}", started_at);
|
||||
|
||||
let activity = Activity::new()
|
||||
.details(initial_session_name.as_str())
|
||||
@@ -105,19 +65,19 @@ impl DiscordRpcManager {
|
||||
.assets(assets)
|
||||
.timestamps(timestamps);
|
||||
|
||||
self.log(&format!("DEBUG: Activity created - details: '{}', state: '{}'",
|
||||
initial_session_name, state_text));
|
||||
tracing::debug!("Activity created - details: '{}', state: '{}'",
|
||||
initial_session_name, state_text);
|
||||
|
||||
self.log("DEBUG: Attempting to set initial activity...");
|
||||
tracing::debug!("Attempting to set initial activity...");
|
||||
client
|
||||
.set_activity(activity)
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Failed to set initial Discord RPC activity: {}", e);
|
||||
self.log(&format!("ERROR: {}", error_msg));
|
||||
tracing::error!("{}", error_msg);
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
self.log("DEBUG: Initial activity set successfully!");
|
||||
tracing::debug!("Initial activity set successfully!");
|
||||
|
||||
// Store the client and initial state
|
||||
*self.client.write() = Some(client);
|
||||
@@ -125,8 +85,8 @@ impl DiscordRpcManager {
|
||||
*self.model.write() = initial_model.clone();
|
||||
*self.started_at.write() = started_at;
|
||||
|
||||
self.log(&format!("Discord RPC connected successfully with initial activity: session='{}', model='{}'",
|
||||
initial_session_name, initial_model));
|
||||
tracing::info!("Discord RPC connected successfully with initial activity: session='{}', model='{}'",
|
||||
initial_session_name, initial_model);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -136,37 +96,37 @@ impl DiscordRpcManager {
|
||||
model: String,
|
||||
started_at: i64,
|
||||
) -> Result<(), String> {
|
||||
self.log(&format!("DEBUG: update() called with session='{}', model='{}', timestamp={}",
|
||||
session_name, model, started_at));
|
||||
tracing::debug!("update() called with session='{}', model='{}', timestamp={}",
|
||||
session_name, model, started_at);
|
||||
|
||||
*self.session_name.write() = session_name.clone();
|
||||
*self.model.write() = model.clone();
|
||||
*self.started_at.write() = started_at;
|
||||
|
||||
self.log("DEBUG: State variables updated");
|
||||
tracing::debug!("State variables updated");
|
||||
|
||||
let mut client_guard = self.client.write();
|
||||
let client = client_guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| {
|
||||
let error_msg = "Discord RPC client not initialized".to_string();
|
||||
self.log(&format!("ERROR: {}", error_msg));
|
||||
tracing::error!("{}", error_msg);
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
self.log("DEBUG: Client lock acquired");
|
||||
tracing::debug!("Client lock acquired");
|
||||
|
||||
let state_text = format!("Model: {}", model);
|
||||
let assets = Assets::new()
|
||||
.large_image("hikari")
|
||||
.large_text("Hikari - Claude Code Assistant");
|
||||
|
||||
self.log("DEBUG: Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
|
||||
tracing::debug!("Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
|
||||
|
||||
let timestamps = Timestamps::new()
|
||||
.start(started_at);
|
||||
|
||||
self.log(&format!("DEBUG: Timestamps created - start: {}", started_at));
|
||||
tracing::debug!("Timestamps created - start: {}", started_at);
|
||||
|
||||
let activity = Activity::new()
|
||||
.details(session_name.as_str())
|
||||
@@ -174,38 +134,38 @@ impl DiscordRpcManager {
|
||||
.assets(assets)
|
||||
.timestamps(timestamps);
|
||||
|
||||
self.log(&format!("DEBUG: Activity created - details: '{}', state: '{}'",
|
||||
session_name, state_text));
|
||||
tracing::debug!("Activity created - details: '{}', state: '{}'",
|
||||
session_name, state_text);
|
||||
|
||||
self.log("DEBUG: Attempting to set activity...");
|
||||
tracing::debug!("Attempting to set activity...");
|
||||
client
|
||||
.set_activity(activity)
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Failed to update Discord RPC: {}", e);
|
||||
self.log(&format!("ERROR: {}", error_msg));
|
||||
tracing::error!("{}", error_msg);
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
self.log(&format!("Updated Discord RPC: session='{}', model='{}'", session_name, model));
|
||||
tracing::info!("Updated Discord RPC: session='{}', model='{}'", session_name, model);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(&self) -> Result<(), String> {
|
||||
self.log("DEBUG: stop() called");
|
||||
tracing::debug!("stop() called");
|
||||
|
||||
let mut client_guard = self.client.write();
|
||||
if let Some(mut client) = client_guard.take() {
|
||||
self.log("DEBUG: Client found, attempting to close...");
|
||||
tracing::debug!("Client found, attempting to close...");
|
||||
client
|
||||
.close()
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Failed to close Discord RPC: {}", e);
|
||||
self.log(&format!("ERROR: {}", error_msg));
|
||||
tracing::error!("{}", error_msg);
|
||||
error_msg
|
||||
})?;
|
||||
self.log("Discord RPC stopped successfully");
|
||||
tracing::info!("Discord RPC stopped successfully");
|
||||
} else {
|
||||
self.log("DEBUG: No client to stop (already stopped or never initialized)");
|
||||
tracing::debug!("No client to stop (already stopped or never initialized)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+16
-4
@@ -73,9 +73,6 @@ pub fn run() {
|
||||
// Initialize the app handle in the bridge manager
|
||||
bridge_manager.lock().set_app_handle(app.handle().clone());
|
||||
|
||||
// Initialize the app handle in the Discord RPC manager for logging
|
||||
discord_rpc.set_app_handle(app.handle());
|
||||
|
||||
// Clean up any orphaned temp files from previous sessions
|
||||
if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() {
|
||||
if count > 0 {
|
||||
@@ -195,8 +192,23 @@ pub fn run() {
|
||||
init_discord_rpc,
|
||||
update_discord_rpc,
|
||||
stop_discord_rpc,
|
||||
log_discord_rpc,
|
||||
close_application,
|
||||
list_memory_files,
|
||||
get_claude_version,
|
||||
list_plugins,
|
||||
install_plugin,
|
||||
uninstall_plugin,
|
||||
enable_plugin,
|
||||
disable_plugin,
|
||||
update_plugin,
|
||||
list_marketplaces,
|
||||
add_marketplace,
|
||||
remove_marketplace,
|
||||
list_mcp_servers,
|
||||
get_mcp_server,
|
||||
remove_mcp_server,
|
||||
add_mcp_server,
|
||||
get_mcp_server_details,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -282,6 +282,21 @@ pub struct AgentEndEvent {
|
||||
pub num_turns: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TodoItem {
|
||||
pub content: String,
|
||||
pub status: String, // "pending", "in_progress", or "completed"
|
||||
#[serde(rename = "activeForm")]
|
||||
pub active_form: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TodoUpdateEvent {
|
||||
pub todos: Vec<TodoItem>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+112
-9
@@ -16,8 +16,8 @@ use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
|
||||
use crate::types::{
|
||||
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
|
||||
ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent,
|
||||
PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent,
|
||||
UserQuestionEvent, WorkingDirectoryEvent,
|
||||
PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent, TodoItem,
|
||||
TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent,
|
||||
};
|
||||
use parking_lot::RwLock;
|
||||
use std::cell::RefCell;
|
||||
@@ -901,7 +901,8 @@ fn process_json_line(
|
||||
}
|
||||
|
||||
// Emit agent-start event for Task tool invocations
|
||||
if name == "Task" {
|
||||
// Support both "Task" and "Task(agent_type)" syntax (CLI v2.1.33+)
|
||||
if name == "Task" || name.starts_with("Task(") {
|
||||
let description = input
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -936,6 +937,34 @@ fn process_json_line(
|
||||
);
|
||||
}
|
||||
|
||||
// Emit todo-update event for TodoWrite tool invocations
|
||||
if name == "TodoWrite" {
|
||||
if let Some(todos_value) = input.get("todos") {
|
||||
if let Some(todos_array) = todos_value.as_array() {
|
||||
let todos: Vec<TodoItem> = todos_array
|
||||
.iter()
|
||||
.filter_map(|todo| {
|
||||
serde_json::from_value(todo.clone()).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
tracing::debug!(
|
||||
"Emitting todo-update: {} todos, parent={:?}",
|
||||
todos.len(),
|
||||
parent_tool_use_id
|
||||
);
|
||||
|
||||
let _ = app.emit(
|
||||
"claude:todo-update",
|
||||
TodoUpdateEvent {
|
||||
todos,
|
||||
conversation_id: conversation_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let desc = format_tool_description(name, input);
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
@@ -973,8 +1002,8 @@ fn process_json_line(
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
OutputEvent {
|
||||
line_type: "system".to_string(),
|
||||
content: format!("[Thinking] {}", thinking),
|
||||
line_type: "thinking".to_string(),
|
||||
content: thinking.clone(),
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
cost: None,
|
||||
@@ -1496,7 +1525,7 @@ fn get_tool_state(tool_name: &str) -> CharacterState {
|
||||
CharacterState::Coding
|
||||
} else if tool_name.starts_with("mcp__") {
|
||||
CharacterState::Mcp
|
||||
} else if tool_name == "Task" {
|
||||
} else if tool_name == "Task" || tool_name.starts_with("Task(") {
|
||||
CharacterState::Thinking
|
||||
} else {
|
||||
CharacterState::Typing
|
||||
@@ -1504,10 +1533,21 @@ fn get_tool_state(tool_name: &str) -> CharacterState {
|
||||
}
|
||||
|
||||
fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
|
||||
// Helper function to check if a path is a memory file
|
||||
fn is_memory_path(path: &str) -> bool {
|
||||
path.contains("/.claude/") && (path.contains("/memory/") || path.ends_with("/MEMORY.md"))
|
||||
}
|
||||
|
||||
match name {
|
||||
"Read" => {
|
||||
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
|
||||
format!("Reading file: {}", path)
|
||||
if is_memory_path(path) {
|
||||
// Extract just the filename for cleaner display
|
||||
let filename = path.split('/').next_back().unwrap_or(path);
|
||||
format!("📝 Reading memory: {}", filename)
|
||||
} else {
|
||||
format!("Reading file: {}", path)
|
||||
}
|
||||
} else {
|
||||
"Reading file...".to_string()
|
||||
}
|
||||
@@ -1526,9 +1566,26 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
|
||||
"Searching in files...".to_string()
|
||||
}
|
||||
}
|
||||
"Edit" | "Write" => {
|
||||
"Edit" => {
|
||||
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
|
||||
format!("Editing: {}", path)
|
||||
if is_memory_path(path) {
|
||||
let filename = path.split('/').next_back().unwrap_or(path);
|
||||
format!("💾 Updating memory: {}", filename)
|
||||
} else {
|
||||
format!("Editing: {}", path)
|
||||
}
|
||||
} else {
|
||||
"Editing file...".to_string()
|
||||
}
|
||||
}
|
||||
"Write" => {
|
||||
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
|
||||
if is_memory_path(path) {
|
||||
let filename = path.split('/').next_back().unwrap_or(path);
|
||||
format!("💾 Writing memory: {}", filename)
|
||||
} else {
|
||||
format!("Editing: {}", path)
|
||||
}
|
||||
} else {
|
||||
"Editing file...".to_string()
|
||||
}
|
||||
@@ -1623,6 +1680,19 @@ mod tests {
|
||||
#[test]
|
||||
fn test_get_tool_state_task() {
|
||||
assert!(matches!(get_tool_state("Task"), CharacterState::Thinking));
|
||||
// Test CLI v2.1.33+ Task(agent_type) syntax
|
||||
assert!(matches!(
|
||||
get_tool_state("Task(Explore)"),
|
||||
CharacterState::Thinking
|
||||
));
|
||||
assert!(matches!(
|
||||
get_tool_state("Task(Plan)"),
|
||||
CharacterState::Thinking
|
||||
));
|
||||
assert!(matches!(
|
||||
get_tool_state("Task(general-purpose)"),
|
||||
CharacterState::Thinking
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1700,6 +1770,39 @@ mod tests {
|
||||
assert_eq!(desc, "Using tool: CustomTool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_memory_read() {
|
||||
let input =
|
||||
serde_json::json!({"file_path": "/home/user/.claude/projects/test/memory/MEMORY.md"});
|
||||
let desc = format_tool_description("Read", &input);
|
||||
assert_eq!(desc, "📝 Reading memory: MEMORY.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_memory_write() {
|
||||
let input = serde_json::json!(
|
||||
{"file_path": "/home/user/.claude/projects/test/memory/notes.md"}
|
||||
);
|
||||
let desc = format_tool_description("Write", &input);
|
||||
assert_eq!(desc, "💾 Writing memory: notes.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_memory_edit() {
|
||||
let input = serde_json::json!(
|
||||
{"file_path": "/home/user/.claude/projects/test/memory/patterns.md"}
|
||||
);
|
||||
let desc = format_tool_description("Edit", &input);
|
||||
assert_eq!(desc, "💾 Updating memory: patterns.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_non_memory_read() {
|
||||
let input = serde_json::json!({"file_path": "/home/user/code/test.txt"});
|
||||
let desc = format_tool_description("Read", &input);
|
||||
assert_eq!(desc, "Reading file: /home/user/code/test.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wsl_bridge_new() {
|
||||
let bridge = WslBridge::new();
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let version = $state("Loading...");
|
||||
|
||||
async function fetchVersion() {
|
||||
try {
|
||||
const result = await invoke<string>("get_claude_version");
|
||||
version = result;
|
||||
} catch (error) {
|
||||
console.error("Failed to get Claude CLI version:", error);
|
||||
version = "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchVersion();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="cli-version">
|
||||
<svg
|
||||
class="terminal-icon"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
<span class="version-text">CLI {version}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cli-version {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cli-version:hover {
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.terminal-icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.version-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -51,6 +51,7 @@
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
});
|
||||
|
||||
let showCustomThemeEditor = $state(false);
|
||||
@@ -703,6 +704,22 @@
|
||||
Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Show Thinking Blocks Toggle -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={config.show_thinking_blocks}
|
||||
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||
/>
|
||||
<span class="text-sm text-[var(--text-primary)]">Show Extended Thinking Blocks</span>
|
||||
</label>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||
Display Claude's extended thinking process in the conversation. Thinking blocks can be
|
||||
expanded/collapsed to see reasoning details.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Window Section -->
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
} from "$lib/stores/historyRestore";
|
||||
import MessageModeSelector from "$lib/components/MessageModeSelector.svelte";
|
||||
import SlashCommandMenu from "$lib/components/SlashCommandMenu.svelte";
|
||||
import SystemClock from "$lib/components/SystemClock.svelte";
|
||||
import CliVersion from "$lib/components/CliVersion.svelte";
|
||||
import { getCurrentMode } from "$lib/stores/messageMode";
|
||||
import { formatMessageWithMode } from "$lib/types/messageMode";
|
||||
import {
|
||||
@@ -914,6 +916,9 @@ User: ${formattedMessage}`;
|
||||
</svg>
|
||||
<span>Clipboard</span>
|
||||
</button>
|
||||
|
||||
<CliVersion />
|
||||
<SystemClock />
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { onMount } from "svelte";
|
||||
import { Trash2, RefreshCw, Server, Globe, Terminal } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface McpServerInfo {
|
||||
name: string;
|
||||
command: string | null;
|
||||
url: string | null;
|
||||
transport: string; // "stdio", "http", or "sse"
|
||||
env: any | null;
|
||||
status: string | null; // "Connected" or "Failed to connect"
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
let servers = $state<McpServerInfo[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let selectedServer = $state<McpServerInfo | null>(null);
|
||||
let isLoadingDetails = $state(false);
|
||||
let actionInProgress = $state<string | null>(null);
|
||||
let showAddForm = $state(false);
|
||||
let serverDetails = $state<string>("");
|
||||
|
||||
// Add server form fields
|
||||
let newServerName = $state("");
|
||||
let newServerUrl = $state("");
|
||||
let newServerTransport = $state("stdio");
|
||||
let isAdding = $state(false);
|
||||
|
||||
async function loadServers(): Promise<void> {
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
servers = await invoke<McpServerInfo[]>("list_mcp_servers");
|
||||
} catch (e) {
|
||||
error = `Failed to load MCP servers: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServerDetails(name: string): Promise<void> {
|
||||
try {
|
||||
isLoadingDetails = true;
|
||||
error = null;
|
||||
selectedServer = await invoke<McpServerInfo>("get_mcp_server", { name });
|
||||
serverDetails = await invoke<string>("get_mcp_server_details", { name });
|
||||
} catch (e) {
|
||||
error = `Failed to load server details: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoadingDetails = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeServer(name: string): Promise<void> {
|
||||
try {
|
||||
actionInProgress = name;
|
||||
error = null;
|
||||
await invoke("remove_mcp_server", { name });
|
||||
if (selectedServer?.name === name) {
|
||||
selectedServer = null;
|
||||
serverDetails = "";
|
||||
}
|
||||
await loadServers();
|
||||
} catch (e) {
|
||||
error = `Failed to remove server: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function addServer(): Promise<void> {
|
||||
if (!newServerName.trim() || !newServerUrl.trim()) return;
|
||||
|
||||
try {
|
||||
isAdding = true;
|
||||
error = null;
|
||||
await invoke("add_mcp_server", {
|
||||
name: newServerName.trim(),
|
||||
commandOrUrl: newServerUrl.trim(),
|
||||
transport: newServerTransport,
|
||||
envVars: null,
|
||||
headers: null,
|
||||
});
|
||||
newServerName = "";
|
||||
newServerUrl = "";
|
||||
newServerTransport = "stdio";
|
||||
showAddForm = false;
|
||||
await loadServers();
|
||||
} catch (e) {
|
||||
error = `Failed to add server: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isAdding = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getTransportIcon(transport: string) {
|
||||
switch (transport) {
|
||||
case "http":
|
||||
return Globe;
|
||||
case "stdio":
|
||||
return Terminal;
|
||||
case "sse":
|
||||
return Server;
|
||||
default:
|
||||
return Server;
|
||||
}
|
||||
}
|
||||
|
||||
function getTransportColor(transport: string) {
|
||||
switch (transport) {
|
||||
case "http":
|
||||
return "text-blue-400";
|
||||
case "stdio":
|
||||
return "text-green-400";
|
||||
case "sse":
|
||||
return "text-purple-400";
|
||||
default:
|
||||
return "text-[var(--text-secondary)]";
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadServers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed top-0 right-0 h-full w-[700px] bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-[var(--accent-primary)]">
|
||||
<Server class="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">MCP Server Management</h2>
|
||||
<p class="text-xs text-[var(--text-secondary)]">
|
||||
{servers.length} server{servers.length !== 1 ? "s" : ""} configured
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
|
||||
aria-label="Close MCP panel"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add Server Button -->
|
||||
<div class="p-4 border-b border-[var(--border-color)]">
|
||||
<button
|
||||
onclick={() => (showAddForm = !showAddForm)}
|
||||
class="w-full px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 transition-opacity flex items-center justify-center gap-2"
|
||||
>
|
||||
<Server class="w-4 h-4" />
|
||||
{showAddForm ? "Cancel" : "Add New Server"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add Server Form -->
|
||||
{#if showAddForm}
|
||||
<div class="mx-4 mt-4 p-4 bg-[var(--bg-secondary)]/50 border border-[var(--border-color)] rounded-lg">
|
||||
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-3">Add MCP Server</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
||||
>Server Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newServerName}
|
||||
placeholder="my-server"
|
||||
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
||||
>Transport</label
|
||||
>
|
||||
<select
|
||||
bind:value={newServerTransport}
|
||||
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
>
|
||||
<option value="stdio">STDIO</option>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="sse">SSE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1">
|
||||
{newServerTransport === "stdio" ? "Command" : "URL"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newServerUrl}
|
||||
placeholder={newServerTransport === "stdio"
|
||||
? "npx my-mcp-server"
|
||||
: "https://mcp.example.com"}
|
||||
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onclick={addServer}
|
||||
disabled={isAdding || !newServerName.trim() || !newServerUrl.trim()}
|
||||
class="w-full px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if isAdding}
|
||||
<RefreshCw class="w-4 h-4 animate-spin" />
|
||||
{:else}
|
||||
<Server class="w-4 h-4" />
|
||||
{/if}
|
||||
Add Server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error Display -->
|
||||
{#if error}
|
||||
<div class="mx-4 mt-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
|
||||
<p class="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto p-4 flex gap-4">
|
||||
<!-- Server List -->
|
||||
<div class="flex-1">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center h-full text-[var(--text-secondary)]">
|
||||
<RefreshCw class="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
{:else if servers.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
|
||||
<Server class="w-16 h-16 mb-4 opacity-50" />
|
||||
<p class="text-center">No MCP servers configured</p>
|
||||
<p class="text-sm text-center mt-2">Add servers via Settings</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each servers as server (server.name)}
|
||||
<button
|
||||
onclick={() => loadServerDetails(server.name)}
|
||||
class="w-full bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all text-left"
|
||||
class:border-[var(--accent-primary)]={selectedServer?.name === server.name}
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
|
||||
<svelte:component
|
||||
this={getTransportIcon(server.transport)}
|
||||
class="w-4 h-4 {getTransportColor(server.transport)}"
|
||||
/>
|
||||
{server.name}
|
||||
{#if server.status}
|
||||
{#if server.status.includes("Connected")}
|
||||
<span
|
||||
class="px-2 py-0.5 bg-[var(--success-color)]/20 text-[var(--success-color)] text-xs rounded border border-[var(--success-color)]/30"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="px-2 py-0.5 bg-red-500/20 text-red-400 text-xs rounded border border-red-500/30"
|
||||
>
|
||||
✗
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</h4>
|
||||
<p class="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{server.transport.toUpperCase()}
|
||||
{#if server.url}
|
||||
• {server.url}
|
||||
{:else if server.command}
|
||||
• {server.command}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Server Details Panel -->
|
||||
{#if selectedServer}
|
||||
<div class="w-80 bg-[var(--bg-secondary)]/50 rounded-lg p-4 border border-[var(--border-color)]">
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Server Details</h3>
|
||||
|
||||
{#if isLoadingDetails}
|
||||
<div class="flex items-center justify-center h-32">
|
||||
<RefreshCw class="w-6 h-6 animate-spin text-[var(--text-secondary)]" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Name</label
|
||||
>
|
||||
<p class="text-sm text-[var(--text-primary)] mt-1">{selectedServer.name}</p>
|
||||
</div>
|
||||
|
||||
<!-- Transport -->
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Transport</label
|
||||
>
|
||||
<p class="text-sm text-[var(--text-primary)] mt-1 flex items-center gap-2">
|
||||
<svelte:component
|
||||
this={getTransportIcon(selectedServer.transport)}
|
||||
class="w-4 h-4 {getTransportColor(selectedServer.transport)}"
|
||||
/>
|
||||
{selectedServer.transport.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- URL or Command -->
|
||||
{#if selectedServer.url}
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>URL</label
|
||||
>
|
||||
<p
|
||||
class="text-sm text-[var(--text-primary)] mt-1 break-all font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
|
||||
>
|
||||
{selectedServer.url}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedServer.command}
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Command</label
|
||||
>
|
||||
<p
|
||||
class="text-sm text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
|
||||
>
|
||||
{selectedServer.command}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Environment Variables -->
|
||||
{#if selectedServer.env}
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Environment</label
|
||||
>
|
||||
<pre
|
||||
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto">{JSON.stringify(selectedServer.env, null, 2)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Full Server Details -->
|
||||
{#if serverDetails}
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Full Details</label
|
||||
>
|
||||
<pre
|
||||
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto whitespace-pre-wrap">{serverDetails}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="pt-4 border-t border-[var(--border-color)]">
|
||||
<button
|
||||
onclick={() => removeServer(selectedServer.name)}
|
||||
disabled={actionInProgress === selectedServer.name}
|
||||
class="w-full px-4 py-2 bg-red-500/20 border border-red-500/30 rounded-lg text-sm text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
Remove Server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,458 @@
|
||||
<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([]);
|
||||
let selectedFile: string | null = $state(null);
|
||||
let fileContent: string = $state("");
|
||||
let isLoading = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
let isPanelOpen = $state(false);
|
||||
|
||||
interface MemoryFilesResponse {
|
||||
files: string[];
|
||||
}
|
||||
|
||||
async function loadMemoryFiles() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
const response = await invoke<MemoryFilesResponse>("list_memory_files");
|
||||
memoryFiles = response.files;
|
||||
} catch (e) {
|
||||
error = `Failed to load memory files: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFileContent(filePath: string) {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
const content = await readTextFile(filePath);
|
||||
fileContent = content;
|
||||
selectedFile = filePath;
|
||||
} catch (e) {
|
||||
error = `Failed to read file: ${e}`;
|
||||
console.error(error);
|
||||
fileContent = "";
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getFileName(path: string): string {
|
||||
return path.split("/").pop() || path;
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
isPanelOpen = !isPanelOpen;
|
||||
if (isPanelOpen && memoryFiles.length === 0) {
|
||||
loadMemoryFiles();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Don't load on mount - only when panel is opened
|
||||
});
|
||||
</script>
|
||||
|
||||
<button class="memory-toggle" onclick={togglePanel} title="Memory Browser">
|
||||
<svg
|
||||
class="icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
<span class="label">Memory</span>
|
||||
</button>
|
||||
|
||||
{#if isPanelOpen}
|
||||
<div class="memory-panel">
|
||||
<div class="panel-header">
|
||||
<div class="header-title">
|
||||
<svg
|
||||
class="header-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
<h3>Memory Files</h3>
|
||||
</div>
|
||||
<button class="close-btn" onclick={togglePanel} title="Close">
|
||||
<svg
|
||||
class="close-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
{#if isLoading && memoryFiles.length === 0}
|
||||
<div class="loading">
|
||||
<svg
|
||||
class="spinner"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Loading memory files...
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error">
|
||||
<p>{error}</p>
|
||||
<button class="retry-btn" onclick={loadMemoryFiles}>Retry</button>
|
||||
</div>
|
||||
{:else if memoryFiles.length === 0}
|
||||
<div class="empty">
|
||||
<p>No memory files found.</p>
|
||||
<p class="hint">
|
||||
Memory files are created automatically as I learn from our conversations!
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="panel-layout">
|
||||
<div class="file-list">
|
||||
{#each memoryFiles as file (file)}
|
||||
<button
|
||||
class="file-item"
|
||||
class:active={selectedFile === file}
|
||||
onclick={() => loadFileContent(file)}
|
||||
>
|
||||
<svg
|
||||
class="file-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="file-name">{getFileName(file)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="file-viewer">
|
||||
{#if selectedFile && fileContent}
|
||||
<div class="viewer-header">
|
||||
<h4>{getFileName(selectedFile)}</h4>
|
||||
</div>
|
||||
<div class="viewer-content">
|
||||
<Markdown content={fileContent} />
|
||||
</div>
|
||||
{:else if selectedFile && isLoading}
|
||||
<div class="loading-file">
|
||||
<svg
|
||||
class="spinner"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Loading file...
|
||||
</div>
|
||||
{:else}
|
||||
<div class="no-selection">
|
||||
<p>Select a memory file to view its contents</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.memory-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.memory-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.memory-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 600px;
|
||||
height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
border-left: 1px solid var(--border-color);
|
||||
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 3rem 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error p {
|
||||
color: var(--terminal-error, #f87171);
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--accent-primary);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.panel-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: 1.5rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.file-item.active {
|
||||
background: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.viewer-header h4 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.viewer-content {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.loading-file,
|
||||
.no-selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 3rem 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,447 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { onMount } from "svelte";
|
||||
import { Download, Trash2, Power, PowerOff, RefreshCw } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface PluginInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface MarketplaceInfo {
|
||||
name: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
let plugins = $state<PluginInfo[]>([]);
|
||||
let marketplaces = $state<MarketplaceInfo[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let isLoadingMarketplaces = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let newPluginName = $state("");
|
||||
let isInstalling = $state(false);
|
||||
let actionInProgress = $state<string | null>(null);
|
||||
let showMarketplaces = $state(false);
|
||||
let newMarketplaceSource = $state("");
|
||||
let isAddingMarketplace = $state(false);
|
||||
|
||||
async function loadPlugins(): Promise<void> {
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
plugins = await invoke<PluginInfo[]>("list_plugins");
|
||||
} catch (e) {
|
||||
error = `Failed to load plugins: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMarketplaces(): Promise<void> {
|
||||
try {
|
||||
isLoadingMarketplaces = true;
|
||||
error = null;
|
||||
marketplaces = await invoke<MarketplaceInfo[]>("list_marketplaces");
|
||||
} catch (e) {
|
||||
error = `Failed to load marketplaces: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoadingMarketplaces = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function installPlugin(): Promise<void> {
|
||||
if (!newPluginName.trim()) return;
|
||||
|
||||
try {
|
||||
isInstalling = true;
|
||||
error = null;
|
||||
await invoke("install_plugin", { pluginName: newPluginName.trim() });
|
||||
newPluginName = "";
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
error = `Failed to install plugin: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isInstalling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallPlugin(pluginName: string): Promise<void> {
|
||||
try {
|
||||
actionInProgress = pluginName;
|
||||
error = null;
|
||||
await invoke("uninstall_plugin", { pluginName });
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
error = `Failed to uninstall plugin: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlugin(plugin: PluginInfo): Promise<void> {
|
||||
try {
|
||||
actionInProgress = plugin.name;
|
||||
error = null;
|
||||
if (plugin.enabled) {
|
||||
await invoke("disable_plugin", { pluginName: plugin.name });
|
||||
} else {
|
||||
await invoke("enable_plugin", { pluginName: plugin.name });
|
||||
}
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
error = `Failed to ${plugin.enabled ? "disable" : "enable"} plugin: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePlugin(pluginName: string): Promise<void> {
|
||||
try {
|
||||
actionInProgress = pluginName;
|
||||
error = null;
|
||||
await invoke("update_plugin", { pluginName });
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
error = `Failed to update plugin: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function addMarketplace(): Promise<void> {
|
||||
if (!newMarketplaceSource.trim()) return;
|
||||
|
||||
try {
|
||||
isAddingMarketplace = true;
|
||||
error = null;
|
||||
await invoke("add_marketplace", { source: newMarketplaceSource.trim() });
|
||||
newMarketplaceSource = "";
|
||||
await loadMarketplaces();
|
||||
} catch (e) {
|
||||
error = `Failed to add marketplace: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isAddingMarketplace = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMarketplace(name: string): Promise<void> {
|
||||
try {
|
||||
actionInProgress = name;
|
||||
error = null;
|
||||
await invoke("remove_marketplace", { name });
|
||||
await loadMarketplaces();
|
||||
} catch (e) {
|
||||
error = `Failed to remove marketplace: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadPlugins();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed top-0 right-0 h-full w-[600px] bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-[var(--accent-primary)]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Plugin Management</h2>
|
||||
<p class="text-xs text-[var(--text-secondary)]">
|
||||
{plugins.length} plugin{plugins.length !== 1 ? "s" : ""} installed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
|
||||
aria-label="Close plugin panel"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Install Plugin Section -->
|
||||
<div class="p-4 border-b border-[var(--border-color)]">
|
||||
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-2">Install New Plugin</h3>
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-3">
|
||||
Enter plugin name (e.g., "macrodata" or "macrodata@macrodata" for specific marketplace)
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newPluginName}
|
||||
placeholder="plugin-name or plugin@marketplace"
|
||||
class="flex-1 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
onkeydown={(e) => e.key === "Enter" && installPlugin()}
|
||||
disabled={isInstalling}
|
||||
/>
|
||||
<button
|
||||
onclick={installPlugin}
|
||||
disabled={isInstalling || !newPluginName.trim()}
|
||||
class="px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{#if isInstalling}
|
||||
<RefreshCw class="w-4 h-4 animate-spin" />
|
||||
{:else}
|
||||
<Download class="w-4 h-4" />
|
||||
{/if}
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Marketplace Management Section -->
|
||||
<div class="p-4 border-b border-[var(--border-color)]">
|
||||
<button
|
||||
onclick={() => {
|
||||
showMarketplaces = !showMarketplaces;
|
||||
if (showMarketplaces && marketplaces.length === 0) {
|
||||
loadMarketplaces();
|
||||
}
|
||||
}}
|
||||
class="w-full text-left flex items-center justify-between text-sm font-medium text-[var(--text-primary)] hover:text-[var(--accent-primary)] transition-colors"
|
||||
>
|
||||
<span>Manage Marketplaces</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 transition-transform"
|
||||
class:rotate-180={showMarketplaces}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showMarketplaces}
|
||||
<div class="mt-3 space-y-3">
|
||||
<!-- Add Marketplace Form -->
|
||||
<div>
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-2">
|
||||
Add a marketplace from GitHub (e.g., "ascorbic/macrodata")
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newMarketplaceSource}
|
||||
placeholder="owner/repo"
|
||||
class="flex-1 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
onkeydown={(e) => e.key === "Enter" && addMarketplace()}
|
||||
disabled={isAddingMarketplace}
|
||||
/>
|
||||
<button
|
||||
onclick={addMarketplace}
|
||||
disabled={isAddingMarketplace || !newMarketplaceSource.trim()}
|
||||
class="px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{#if isAddingMarketplace}
|
||||
<RefreshCw class="w-4 h-4 animate-spin" />
|
||||
{:else}
|
||||
<Download class="w-4 h-4" />
|
||||
{/if}
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Marketplaces List -->
|
||||
{#if isLoadingMarketplaces}
|
||||
<div class="flex items-center justify-center py-4">
|
||||
<RefreshCw class="w-5 h-5 animate-spin text-[var(--text-secondary)]" />
|
||||
</div>
|
||||
{:else if marketplaces.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each marketplaces as marketplace (marketplace.name)}
|
||||
<div
|
||||
class="bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)]"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-[var(--text-primary)]">{marketplace.name}</h4>
|
||||
<p class="text-xs text-[var(--text-secondary)] mt-1">{marketplace.source}</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => removeMarketplace(marketplace.name)}
|
||||
disabled={actionInProgress === marketplace.name}
|
||||
class="px-2 py-1 text-red-400 hover:bg-red-500/20 rounded transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-[var(--text-secondary)] text-center py-4">
|
||||
No marketplaces configured
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
{#if error}
|
||||
<div class="mx-4 mt-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
|
||||
<p class="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Plugins List -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center h-full text-[var(--text-secondary)]">
|
||||
<RefreshCw class="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
{:else if plugins.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-16 w-16 mb-4 opacity-50"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-center">No plugins installed</p>
|
||||
<p class="text-sm text-center mt-2">Install a plugin using the form above</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each plugins as plugin (plugin.name)}
|
||||
<div
|
||||
class="bg-[var(--bg-secondary)]/50 rounded-lg p-4 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
|
||||
{plugin.name}
|
||||
{#if plugin.enabled}
|
||||
<span
|
||||
class="px-2 py-0.5 bg-[var(--success-color)]/20 text-[var(--success-color)] text-xs rounded border border-[var(--success-color)]/30"
|
||||
>
|
||||
Enabled
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="px-2 py-0.5 bg-[var(--text-secondary)]/20 text-[var(--text-secondary)] text-xs rounded border border-[var(--border-color)]"
|
||||
>
|
||||
Disabled
|
||||
</span>
|
||||
{/if}
|
||||
</h4>
|
||||
<p class="text-xs text-[var(--text-secondary)] mt-1">v{plugin.version}</p>
|
||||
{#if plugin.description}
|
||||
<p class="text-sm text-[var(--text-secondary)] mt-2">{plugin.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-3">
|
||||
<button
|
||||
onclick={() => togglePlugin(plugin)}
|
||||
disabled={actionInProgress === plugin.name}
|
||||
class="flex-1 px-3 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if plugin.enabled}
|
||||
<PowerOff class="w-4 h-4" />
|
||||
Disable
|
||||
{:else}
|
||||
<Power class="w-4 h-4" />
|
||||
Enable
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => updatePlugin(plugin.name)}
|
||||
disabled={actionInProgress === plugin.name}
|
||||
class="px-3 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" />
|
||||
Update
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => uninstallPlugin(plugin.name)}
|
||||
disabled={actionInProgress === plugin.name}
|
||||
class="px-3 py-1.5 bg-red-500/20 border border-red-500/30 rounded text-sm text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
Uninstall
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,7 @@
|
||||
let showDeleteConfirm = $state<string | null>(null);
|
||||
let showExportMenu = $state<string | null>(null);
|
||||
let isImporting = $state(false);
|
||||
let showClearAllConfirm = $state(false);
|
||||
|
||||
const sessions = $derived(sessionsStore.sessions);
|
||||
const isLoading = $derived(sessionsStore.isLoading);
|
||||
@@ -121,6 +122,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearAll(): Promise<void> {
|
||||
await sessionsStore.clearAllSessions();
|
||||
showClearAllConfirm = false;
|
||||
}
|
||||
|
||||
function toggleExportMenu(sessionId: string): void {
|
||||
if (showExportMenu === sessionId) {
|
||||
showExportMenu = null;
|
||||
@@ -186,6 +192,22 @@
|
||||
</svg>
|
||||
{isImporting ? "Importing..." : "Import"}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showClearAllConfirm = true)}
|
||||
disabled={sessions.length === 0}
|
||||
class="px-3 py-1.5 text-sm font-medium bg-red-500/10 text-red-500 border border-red-500/30 rounded hover:bg-red-500/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
title="Clear all sessions"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
Clear All
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={onClose}
|
||||
@@ -428,6 +450,64 @@
|
||||
</div>
|
||||
</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)}
|
||||
role="button"
|
||||
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()}
|
||||
role="dialog"
|
||||
aria-labelledby="clear-all-title"
|
||||
aria-describedby="clear-all-description"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 text-red-500">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 id="clear-all-title" class="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
Clear All Sessions?
|
||||
</h3>
|
||||
<p id="clear-all-description" class="text-[var(--text-secondary)] mb-4">
|
||||
This will permanently delete all {sessions.length} session{sessions.length === 1
|
||||
? ""
|
||||
: "s"}. This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
onclick={() => (showClearAllConfirm = false)}
|
||||
class="px-4 py-2 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleClearAll}
|
||||
class="px-4 py-2 text-sm font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Clear All Sessions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
|
||||
@@ -23,9 +23,12 @@
|
||||
import { achievementProgress } from "$lib/stores/achievements";
|
||||
import { runningAgentCount } from "$lib/stores/agents";
|
||||
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
|
||||
import TodoPanel from "./TodoPanel.svelte";
|
||||
import GitPanel from "./GitPanel.svelte";
|
||||
import ProfilePanel from "./ProfilePanel.svelte";
|
||||
import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
|
||||
import PluginManagementPanel from "./PluginManagementPanel.svelte";
|
||||
import McpManagementPanel from "./McpManagementPanel.svelte";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import {
|
||||
generateContextInjection,
|
||||
@@ -49,9 +52,12 @@
|
||||
let showHelp = $state(false);
|
||||
let showKeyboardShortcuts = $state(false);
|
||||
let showSessionHistory = $state(false);
|
||||
let showTodoPanel = $state(false);
|
||||
let showGitPanel = $state(false);
|
||||
let showProfile = $state(false);
|
||||
let showAgentMonitor = $state(false);
|
||||
let showPluginPanel = $state(false);
|
||||
let showMcpPanel = $state(false);
|
||||
let isSummarising = $state(false);
|
||||
const progress = $derived($achievementProgress);
|
||||
const activeAgentCount = $derived($runningAgentCount);
|
||||
@@ -92,6 +98,7 @@
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
});
|
||||
|
||||
let streamerModeActive = $state(false);
|
||||
@@ -437,6 +444,20 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showTodoPanel = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Todo List"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showGitPanel = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
@@ -451,6 +472,34 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showPluginPanel = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Plugin Management"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showMcpPanel = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="MCP Server Management"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={toggleEditor}
|
||||
disabled={connectionStatus !== "connected"}
|
||||
@@ -672,6 +721,10 @@
|
||||
<SessionHistoryPanel onClose={() => (showSessionHistory = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showTodoPanel}
|
||||
<TodoPanel onClose={() => (showTodoPanel = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showGitPanel}
|
||||
<GitPanel isOpen={showGitPanel} onClose={() => (showGitPanel = false)} />
|
||||
{/if}
|
||||
@@ -683,3 +736,11 @@
|
||||
{#if showAgentMonitor}
|
||||
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showPluginPanel}
|
||||
<PluginManagementPanel onClose={() => (showPluginPanel = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showMcpPanel}
|
||||
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
let currentTime = $state("");
|
||||
|
||||
function updateTime() {
|
||||
const now = new Date();
|
||||
|
||||
// Format date as "1 January 2026"
|
||||
const day = now.getDate();
|
||||
const month = now.toLocaleString("en-GB", { month: "long" });
|
||||
const year = now.getFullYear();
|
||||
|
||||
// Format time as HH:MM:SS (24-hour)
|
||||
const hours = String(now.getHours()).padStart(2, "0");
|
||||
const minutes = String(now.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(now.getSeconds()).padStart(2, "0");
|
||||
|
||||
currentTime = `${day} ${month} ${year}, ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
// Update immediately on mount
|
||||
updateTime();
|
||||
|
||||
// Update every second
|
||||
const interval = setInterval(updateTime, 1000);
|
||||
|
||||
// Cleanup on component destroy
|
||||
$effect(() => {
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="system-clock">
|
||||
<svg
|
||||
class="clock-icon"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<span class="clock-text">{currentTime}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.system-clock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
transition: all 0.2s;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.system-clock:hover {
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.clock-icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.clock-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* SystemClock Component Tests
|
||||
*
|
||||
* Note: This file tests the time formatting logic used by the SystemClock component.
|
||||
* Full component rendering tests are challenging with Svelte 5 + @testing-library/svelte
|
||||
* due to SSR/CSR compatibility issues. The component itself is simple and visually
|
||||
* testable - it displays the current date and time, updating every second.
|
||||
*
|
||||
* What this component does:
|
||||
* - Displays date in British format: "7 February 2026"
|
||||
* - Displays time in 24-hour format: "14:35:42"
|
||||
* - Updates every second via setInterval
|
||||
* - Cleans up interval on unmount via $effect
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] Clock appears above the Send button
|
||||
* - [ ] Time updates every second
|
||||
* - [ ] Date format is "DD Month YYYY"
|
||||
* - [ ] Time format is "HH:MM:SS" (24-hour)
|
||||
* - [ ] Hover effect works (border turns accent colour)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// Helper function that mirrors the component's formatting logic
|
||||
function formatDateTime(date: Date): string {
|
||||
const day = date.getDate();
|
||||
const month = date.toLocaleString("en-GB", { month: "long" });
|
||||
const year = date.getFullYear();
|
||||
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||
|
||||
return `${day} ${month} ${year}, ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
describe("SystemClock date/time formatting", () => {
|
||||
it("formats date in British format (DD Month YYYY)", () => {
|
||||
// Use local timezone (not UTC) since the component uses local time
|
||||
const date = new Date(2026, 1, 7, 14, 35, 42); // Feb 7, 2026 14:35:42 local
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toContain("7 February 2026");
|
||||
});
|
||||
|
||||
it("formats time in 24-hour format (HH:MM:SS)", () => {
|
||||
const date = new Date(2026, 1, 7, 14, 35, 42);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
// Should have the pattern HH:MM:SS
|
||||
expect(formatted).toMatch(/\d{2}:\d{2}:\d{2}/);
|
||||
expect(formatted).toContain("14:35:42");
|
||||
});
|
||||
|
||||
it("combines date and time with comma separator", () => {
|
||||
const date = new Date(2026, 1, 7, 14, 35, 42);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toBe("7 February 2026, 14:35:42");
|
||||
});
|
||||
|
||||
it("pads single-digit hours, minutes, and seconds with zeros", () => {
|
||||
const date = new Date(2026, 1, 7, 3, 5, 8);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
// Should have leading zeros: 03:05:08, not 3:5:8
|
||||
expect(formatted).toContain("03:05:08");
|
||||
});
|
||||
|
||||
it("handles different months correctly", () => {
|
||||
const date = new Date(2026, 11, 25, 12, 0, 0); // December is month 11
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toContain("25 December 2026");
|
||||
});
|
||||
|
||||
it("handles year changes correctly", () => {
|
||||
const date = new Date(2027, 0, 1, 0, 0, 0); // January is month 0
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toContain("1 January 2027");
|
||||
expect(formatted).toContain("00:00:00");
|
||||
});
|
||||
|
||||
it("handles midnight correctly", () => {
|
||||
const date = new Date(2026, 1, 7, 0, 0, 0);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toContain("00:00:00");
|
||||
});
|
||||
|
||||
it("handles noon correctly", () => {
|
||||
const date = new Date(2026, 1, 7, 12, 0, 0);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
// 24-hour format, so noon is 12:00:00, not 00:00:00
|
||||
expect(formatted).toContain("12:00:00");
|
||||
});
|
||||
|
||||
it("handles end of day correctly", () => {
|
||||
const date = new Date(2026, 1, 7, 23, 59, 59);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toContain("23:59:59");
|
||||
});
|
||||
|
||||
it("handles month boundaries correctly", () => {
|
||||
// Last day of January
|
||||
const jan31 = new Date(2026, 0, 31, 23, 59, 59);
|
||||
expect(formatDateTime(jan31)).toContain("31 January 2026");
|
||||
|
||||
// First day of February
|
||||
const feb1 = new Date(2026, 1, 1, 0, 0, 0);
|
||||
expect(formatDateTime(feb1)).toContain("1 February 2026");
|
||||
});
|
||||
|
||||
it("handles leap year February correctly", () => {
|
||||
// 2024 is a leap year
|
||||
const feb29 = new Date(2024, 1, 29, 12, 0, 0);
|
||||
const formatted = formatDateTime(feb29);
|
||||
|
||||
expect(formatted).toContain("29 February 2024");
|
||||
});
|
||||
|
||||
it("handles all 12 months correctly", () => {
|
||||
const months = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
months.forEach((month, index) => {
|
||||
const date = new Date(2026, index, 15, 12, 0, 0);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toContain(month);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,10 @@
|
||||
import ConversationTabs from "./ConversationTabs.svelte";
|
||||
import Markdown from "./Markdown.svelte";
|
||||
import HighlightedText from "./HighlightedText.svelte";
|
||||
import ThinkingBlock from "./ThinkingBlock.svelte";
|
||||
import { searchState, searchQuery } from "$lib/stores/search";
|
||||
import { clipboardStore } from "$lib/stores/clipboard";
|
||||
import { shouldHidePaths, maskPaths } from "$lib/stores/config";
|
||||
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
|
||||
|
||||
let terminalElement: HTMLDivElement;
|
||||
let shouldAutoScroll = true;
|
||||
@@ -24,6 +25,11 @@
|
||||
hidePaths = value;
|
||||
});
|
||||
|
||||
let showThinking = true;
|
||||
showThinkingBlocks.subscribe((value) => {
|
||||
showThinking = value;
|
||||
});
|
||||
|
||||
claudeStore.terminalLines.subscribe((value) => {
|
||||
lines = value;
|
||||
});
|
||||
@@ -84,6 +90,8 @@
|
||||
return "terminal-tool";
|
||||
case "error":
|
||||
return "terminal-error";
|
||||
case "thinking":
|
||||
return "terminal-thinking";
|
||||
default:
|
||||
return "terminal-default";
|
||||
}
|
||||
@@ -209,80 +217,86 @@
|
||||
</div>
|
||||
{:else}
|
||||
{#each lines as line (line.id)}
|
||||
<div
|
||||
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
|
||||
style={line.parentToolUseId
|
||||
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
|
||||
: ""}
|
||||
>
|
||||
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
|
||||
{#if line.parentToolUseId}
|
||||
<span class="text-xs mr-2 opacity-60" title="Message from subagent">
|
||||
<svg
|
||||
class="inline-block w-3 h-3 -mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
{#if line.type === "thinking"}
|
||||
{#if showThinking}
|
||||
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div
|
||||
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
|
||||
style={line.parentToolUseId
|
||||
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
|
||||
: ""}
|
||||
>
|
||||
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
|
||||
{#if line.parentToolUseId}
|
||||
<span class="text-xs mr-2 opacity-60" title="Message from subagent">
|
||||
<svg
|
||||
class="inline-block w-3 h-3 -mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
{#if line.cost && line.cost.costUsd > 0}
|
||||
<span
|
||||
class="terminal-cost text-xs mr-2"
|
||||
title="Input: {line.cost.inputTokens} | Output: {line.cost.outputTokens}"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
${line.cost.costUsd < 0.01
|
||||
? line.cost.costUsd.toFixed(4)
|
||||
: line.cost.costUsd.toFixed(3)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if getLinePrefix(line.type)}
|
||||
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
||||
{/if}
|
||||
{#if line.toolName}
|
||||
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
||||
{/if}
|
||||
{#if line.type === "assistant" || line.type === "user"}
|
||||
<div class="message-content-wrapper">
|
||||
<Markdown
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
searchQuery={currentSearchQuery}
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
{#if line.cost && line.cost.costUsd > 0}
|
||||
<span
|
||||
class="terminal-cost text-xs mr-2"
|
||||
title="Input: {line.cost.inputTokens} | Output: {line.cost.outputTokens}"
|
||||
>
|
||||
${line.cost.costUsd < 0.01
|
||||
? line.cost.costUsd.toFixed(4)
|
||||
: line.cost.costUsd.toFixed(3)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if getLinePrefix(line.type)}
|
||||
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
||||
{/if}
|
||||
{#if line.toolName}
|
||||
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
||||
{/if}
|
||||
{#if line.type === "assistant" || line.type === "user"}
|
||||
<div class="message-content-wrapper">
|
||||
<Markdown
|
||||
<button
|
||||
class="copy-message-btn opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick={() => handleCopyMessage(line.id, line.content)}
|
||||
title="Copy message"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<HighlightedText
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
searchQuery={currentSearchQuery}
|
||||
/>
|
||||
<button
|
||||
class="copy-message-btn opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick={() => handleCopyMessage(line.id, line.content)}
|
||||
title="Copy message"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<HighlightedText
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
searchQuery={currentSearchQuery}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
let { content, timestamp }: Props = $props();
|
||||
let isExpanded = $state(false);
|
||||
|
||||
function toggleExpanded() {
|
||||
isExpanded = !isExpanded;
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="thinking-block">
|
||||
<button class="thinking-header" onclick={toggleExpanded} type="button">
|
||||
<span class="thinking-timestamp">{formatTime(timestamp)}</span>
|
||||
<svg
|
||||
class="thinking-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="thinking-label">Extended Thinking</span>
|
||||
<svg
|
||||
class="chevron"
|
||||
class:expanded={isExpanded}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="thinking-content">
|
||||
{content}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.thinking-block {
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--bg-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.thinking-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.thinking-header:hover {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.thinking-timestamp {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.thinking-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thinking-label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.chevron.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.thinking-content {
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import { todos, type TodoItem } from "$lib/stores/todos";
|
||||
import { CheckCircle, Circle, Loader } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
const currentTodos = $derived($todos);
|
||||
const hasTodos = $derived(currentTodos.length > 0);
|
||||
const completedCount = $derived(currentTodos.filter((t) => t.status === "completed").length);
|
||||
const totalCount = $derived(currentTodos.length);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed top-0 right-0 h-full w-96 bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-[var(--accent-primary)]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Hikari's Todo List</h2>
|
||||
{#if hasTodos}
|
||||
<p class="text-xs text-[var(--text-secondary)]">
|
||||
{completedCount} of {totalCount} completed
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
|
||||
aria-label="Close todo panel"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
{#if !hasTodos}
|
||||
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-16 w-16 mb-4 opacity-50"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-center">No active todos</p>
|
||||
<p class="text-sm text-center mt-2">I'll update this when I start working on tasks!</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each currentTodos as todo (todo.content)}
|
||||
<div
|
||||
class="group bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all"
|
||||
class:opacity-60={todo.status === "completed"}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Status Icon -->
|
||||
<div class="mt-0.5 flex-shrink-0">
|
||||
{#if todo.status === "completed"}
|
||||
<CheckCircle class="w-5 h-5 text-[var(--success-color)]" />
|
||||
{:else if todo.status === "in_progress"}
|
||||
<Loader class="w-5 h-5 text-[var(--accent-primary)] animate-spin" />
|
||||
{:else}
|
||||
<Circle class="w-5 h-5 text-[var(--text-secondary)]" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-sm font-medium"
|
||||
class:text-[var(--text-secondary)]={todo.status === "completed"}
|
||||
class:line-through={todo.status === "completed"}
|
||||
class:text-[var(--text-primary)]={todo.status !== "completed"}
|
||||
>
|
||||
{todo.status === "in_progress" ? todo.activeForm : todo.content}
|
||||
</p>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<div class="mt-1">
|
||||
{#if todo.status === "completed"}
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[var(--success-color)]/20 text-[var(--success-color)] border border-[var(--success-color)]/30"
|
||||
>
|
||||
✓ Completed
|
||||
</span>
|
||||
{:else if todo.status === "in_progress"}
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] border border-[var(--accent-primary)]/30 animate-pulse"
|
||||
>
|
||||
⚡ In Progress
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-color)]"
|
||||
>
|
||||
○ Pending
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer with Progress Bar -->
|
||||
{#if hasTodos}
|
||||
<div class="border-t border-[var(--accent-primary)]/30 p-4 bg-[var(--bg-secondary)]/50">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-[var(--text-secondary)]">Progress</span>
|
||||
<span class="text-xs font-medium text-[var(--accent-primary)]">
|
||||
{Math.round((completedCount / totalCount) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-[var(--bg-secondary)] rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] h-2 rounded-full transition-all duration-500 ease-out"
|
||||
style="width: {(completedCount / totalCount) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -193,6 +193,7 @@ describe("config store", () => {
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
};
|
||||
|
||||
expect(config.model).toBe("claude-sonnet-4");
|
||||
@@ -238,6 +239,7 @@ describe("config store", () => {
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
};
|
||||
|
||||
expect(config.model).toBeNull();
|
||||
@@ -773,6 +775,7 @@ describe("config store", () => {
|
||||
budget_action: "block",
|
||||
budget_warning_threshold: 0.9,
|
||||
discord_rpc_enabled: false,
|
||||
show_thinking_blocks: true,
|
||||
};
|
||||
|
||||
const mockInvokeImpl = vi.mocked(invoke);
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface HikariConfig {
|
||||
budget_warning_threshold: number;
|
||||
// Discord RPC settings
|
||||
discord_rpc_enabled: boolean;
|
||||
// Thinking blocks settings
|
||||
show_thinking_blocks: boolean;
|
||||
}
|
||||
|
||||
const defaultConfig: HikariConfig = {
|
||||
@@ -84,6 +86,7 @@ const defaultConfig: HikariConfig = {
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
};
|
||||
|
||||
function createConfigStore() {
|
||||
@@ -297,6 +300,10 @@ export const shouldHidePaths = derived(
|
||||
configStore.config,
|
||||
($config) => $config.streamer_mode && $config.streamer_hide_paths
|
||||
);
|
||||
export const showThinkingBlocks = derived(
|
||||
configStore.config,
|
||||
($config) => $config.show_thinking_blocks
|
||||
);
|
||||
|
||||
/**
|
||||
* Masks file paths in text when streamer mode with hide paths is enabled.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
export interface TodoItem {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
activeForm: string;
|
||||
}
|
||||
|
||||
interface TodoUpdatePayload {
|
||||
todos: TodoItem[];
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
// Create the writable store
|
||||
const { subscribe, set, update } = writable<TodoItem[]>([]);
|
||||
|
||||
// Listen for todo updates from the backend
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
export async function initializeTodoListener(): Promise<void> {
|
||||
if (unlisten) {
|
||||
return; // Already initialized
|
||||
}
|
||||
|
||||
unlisten = await listen<TodoUpdatePayload>("claude:todo-update", (event) => {
|
||||
set(event.payload.todos);
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanupTodoListener(): void {
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Export the store
|
||||
export const todos = {
|
||||
subscribe,
|
||||
set,
|
||||
update,
|
||||
clear: () => set([]),
|
||||
};
|
||||
+7
-16
@@ -14,6 +14,7 @@ import type {
|
||||
import type { CharacterState } from "$lib/types/states";
|
||||
import type { AgentStartPayload, AgentEndPayload } from "$lib/types/agents";
|
||||
import { agentStore } from "$lib/stores/agents";
|
||||
import { todos } from "$lib/stores/todos";
|
||||
import {
|
||||
initializeNotificationRules,
|
||||
cleanupNotificationRules,
|
||||
@@ -196,6 +197,9 @@ export async function initializeTauriListeners() {
|
||||
"system",
|
||||
"Disconnected from Claude Code"
|
||||
);
|
||||
|
||||
// Clear todos on real disconnect (not on reconnects for permissions)
|
||||
todos.clear();
|
||||
}
|
||||
|
||||
// Update character state for this conversation
|
||||
@@ -272,7 +276,7 @@ export async function initializeTauriListeners() {
|
||||
if (conversation_id) {
|
||||
claudeStore.addLineToConversation(
|
||||
conversation_id,
|
||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
||||
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
|
||||
content,
|
||||
tool_name || undefined,
|
||||
costData,
|
||||
@@ -281,7 +285,7 @@ export async function initializeTauriListeners() {
|
||||
} else {
|
||||
// Fallback to active conversation if no conversation_id provided
|
||||
claudeStore.addLine(
|
||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
||||
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
|
||||
content,
|
||||
tool_name || undefined,
|
||||
costData,
|
||||
@@ -448,10 +452,6 @@ export async function initializeDiscordRpc() {
|
||||
const startedAtUnixSeconds = Math.floor(startedAt.getTime() / 1000);
|
||||
const model = config.model || "claude";
|
||||
|
||||
await invoke("log_discord_rpc", {
|
||||
message: `[FRONTEND] Attempting to initialize Discord RPC: session='Idle', model='${model}', timestamp=${startedAtUnixSeconds}`,
|
||||
});
|
||||
|
||||
console.log("Initializing Discord RPC with initial activity:", {
|
||||
session_name: "Idle",
|
||||
model,
|
||||
@@ -464,23 +464,14 @@ export async function initializeDiscordRpc() {
|
||||
startedAt: startedAtUnixSeconds,
|
||||
});
|
||||
|
||||
await invoke("log_discord_rpc", {
|
||||
message: "[FRONTEND] Discord RPC initialized successfully!",
|
||||
});
|
||||
|
||||
console.log("Discord RPC initialized successfully with initial presence");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await invoke("log_discord_rpc", {
|
||||
message: `[FRONTEND] ERROR: Failed to initialize Discord RPC: ${errorMessage}`,
|
||||
});
|
||||
console.error("Failed to initialize Discord RPC:", error);
|
||||
console.warn("Discord RPC will be unavailable. Make sure Discord is running.");
|
||||
}
|
||||
} else {
|
||||
await invoke("log_discord_rpc", {
|
||||
message: "[FRONTEND] Discord RPC is disabled in config, skipping initialization",
|
||||
});
|
||||
console.log("Discord RPC is disabled in config, skipping initialization");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export interface TerminalLine {
|
||||
id: string;
|
||||
type: "user" | "assistant" | "system" | "tool" | "error";
|
||||
type: "user" | "assistant" | "system" | "tool" | "error" | "thinking";
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolName?: string;
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
|
||||
import UpdateNotification from "$lib/components/UpdateNotification.svelte";
|
||||
import CloseAppConfirmModal from "$lib/components/CloseAppConfirmModal.svelte";
|
||||
import MemoryBrowserPanel from "$lib/components/MemoryBrowserPanel.svelte";
|
||||
import { debugConsoleStore } from "$lib/stores/debugConsole";
|
||||
import { initializeTodoListener, cleanupTodoListener } from "$lib/stores/todos";
|
||||
|
||||
let initialized = false;
|
||||
let updateNotification: UpdateNotification | undefined = $state(undefined);
|
||||
@@ -444,6 +446,9 @@
|
||||
// Initialize Discord RPC
|
||||
await initializeDiscordRpc();
|
||||
|
||||
// Initialize todo listener
|
||||
await initializeTodoListener();
|
||||
|
||||
// Listen for window close requests
|
||||
const unlisten = await listen("window-close-requested", () => {
|
||||
handleCloseRequest();
|
||||
@@ -460,6 +465,7 @@
|
||||
if (initialized) {
|
||||
cleanupTauriListeners();
|
||||
cleanupNotificationSync();
|
||||
cleanupTodoListener();
|
||||
stopDiscordRpc();
|
||||
window.removeEventListener("keydown", handleGlobalKeydown);
|
||||
initialized = false;
|
||||
@@ -513,6 +519,7 @@
|
||||
<PermissionModal />
|
||||
<UserQuestionModal />
|
||||
<ConfigSidebar />
|
||||
<MemoryBrowserPanel />
|
||||
<AchievementNotification />
|
||||
<AchievementsPanel
|
||||
bind:isOpen={achievementPanelOpen}
|
||||
|
||||
Reference in New Issue
Block a user