From 9a6dee2da04e6d5b38e82e6ac5f5b0366dd68629 Mon Sep 17 00:00:00 2001 From: tylergraydev Date: Mon, 9 Mar 2026 08:24:01 -0400 Subject: [PATCH] Add WSL2 Claude Code detection and sync support, fix preexisting type errors Adds support for detecting and managing Claude Code installations inside WSL2 distros from the Windows host. Each WSL distro appears as a separate toggleable editor in the settings UI, enabling independent MCP config sync to both native Windows and WSL Claude Code. Also fixes 12 preexisting TypeScript errors across analytics, sessions, skills, and test files. --- src-tauri/src/commands/config.rs | 12 + src-tauri/src/commands/settings.rs | 44 +- src-tauri/src/db/models.rs | 14 +- src-tauri/src/lib.rs | 1 + src-tauri/src/services/mod.rs | 1 + src-tauri/src/services/wsl_config.rs | 146 +++++++ src-tauri/src/utils/mod.rs | 1 + src-tauri/src/utils/wsl.rs | 386 ++++++++++++++++++ .../tabs/SettingsEditorSyncTab.svelte | 16 +- src/lib/stores/usageStore.svelte.ts | 48 +++ src/lib/types/skill.ts | 2 + src/routes/sessions/+page.svelte | 1 - src/tests/helpers/invokeMock.ts | 4 +- src/tests/stores/skillLibrary.test.ts | 6 +- 14 files changed, 670 insertions(+), 12 deletions(-) create mode 100644 src-tauri/src/services/wsl_config.rs create mode 100644 src-tauri/src/utils/wsl.rs diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index c20d176..a2f8126 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -212,6 +212,18 @@ pub fn sync_global_config_from_db(db: &Database) -> Result<(), String> { info!("[Config] Wrote global config to Gemini CLI"); } } + editor_id if crate::utils::wsl::is_wsl_editor(editor_id) => { + if let Some(distro) = crate::utils::wsl::distro_from_editor_id(editor_id) { + crate::services::wsl_config::write_wsl_global_config(&distro, &mcps) + .map_err(|e| e.to_string())?; + info!("[Config] Wrote global config to WSL distro '{}'", distro); + } else { + warn!( + "[Config] WSL editor '{}' but could not resolve distro name. Skipping.", + editor_id + ); + } + } unknown => warn!("[Config] Unknown editor type '{}'. Skipping.", unknown), } } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 34c6171..bf5bc81 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -1,6 +1,6 @@ use crate::db::{ AppSettings, CodexPaths, CopilotPaths, CursorPaths, Database, EditorInfo, GeminiPaths, - OpenCodePaths, + OpenCodePaths, WslClaudePaths, }; use crate::utils::codex_paths::{get_codex_paths, is_codex_installed}; use crate::utils::copilot_paths::{get_copilot_paths, is_copilot_installed}; @@ -8,6 +8,7 @@ use crate::utils::cursor_paths::{get_cursor_paths, is_cursor_installed}; use crate::utils::gemini_paths::{get_gemini_paths, is_gemini_installed}; use crate::utils::opencode_paths::{get_opencode_paths, is_opencode_installed}; use crate::utils::paths::get_claude_paths; +use crate::utils::wsl; use log::info; use std::sync::{Arc, Mutex}; use tauri::State; @@ -111,6 +112,25 @@ pub fn get_available_editors( }); } + // WSL Claude Code installations (Windows only) + let wsl_installations = wsl::detect_wsl_claude_installations(); + for wsl_info in &wsl_installations { + let editor_id = wsl::wsl_editor_id(&wsl_info.distro); + let config_path = wsl_info + .wsl_home + .as_ref() + .map(|h| format!("{}/.claude.json", h)) + .unwrap_or_else(|| "~/.claude.json".to_string()); + + editors.push(EditorInfo { + id: editor_id.clone(), + name: format!("Claude Code (WSL: {})", wsl_info.distro), + is_installed: wsl_info.is_installed, + is_enabled: enabled.contains(&editor_id), + config_path: format!("WSL:{} {}", wsl_info.distro, config_path), + }); + } + Ok(editors) } @@ -242,6 +262,28 @@ pub fn get_gemini_paths_cmd() -> Result { }) } +/// Get WSL Claude Code paths for all detected distros +#[tauri::command] +pub fn get_wsl_claude_paths_cmd() -> Result, String> { + info!("[Settings] Getting WSL Claude Code paths"); + + let installations = wsl::detect_wsl_claude_installations(); + + Ok(installations + .into_iter() + .filter(|info| info.is_installed) + .map(|info| { + let home = info.wsl_home.unwrap_or_else(|| "~".to_string()); + WslClaudePaths { + distro: info.distro, + wsl_home: home.clone(), + claude_json: format!("{}/.claude.json", home), + claude_dir: format!("{}/.claude", home), + } + }) + .collect()) +} + // ============================================================================ // Testable helper functions (no Tauri State dependency) // ============================================================================ diff --git a/src-tauri/src/db/models.rs b/src-tauri/src/db/models.rs index aa791f8..1302c1a 100644 --- a/src-tauri/src/db/models.rs +++ b/src-tauri/src/db/models.rs @@ -476,12 +476,22 @@ pub struct GeminiPaths { pub settings_file: String, // ~/.gemini/settings.json } +// WSL Claude Code paths +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WslClaudePaths { + pub distro: String, // WSL distro name (e.g., "Ubuntu") + pub wsl_home: String, // Home directory inside WSL (e.g., "/home/user") + pub claude_json: String, // ~/.claude.json inside WSL + pub claude_dir: String, // ~/.claude/ inside WSL +} + // Editor info for frontend #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorInfo { - pub id: String, // "claude_code" or "opencode" - pub name: String, // "Claude Code" or "OpenCode" + pub id: String, // "claude_code", "opencode", "wsl_ubuntu", etc. + pub name: String, // "Claude Code", "Claude Code (WSL: Ubuntu)", etc. pub is_installed: bool, // Whether config directory exists pub is_enabled: bool, // Whether syncing to this editor is enabled pub config_path: String, // Path to main config file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c3e2825..4878b3f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -339,6 +339,7 @@ pub fn run() { commands::settings::get_copilot_paths_cmd, commands::settings::get_cursor_paths_cmd, commands::settings::get_gemini_paths_cmd, + commands::settings::get_wsl_claude_paths_cmd, commands::settings::toggle_editor, commands::settings::set_github_token, commands::settings::clear_github_token, diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index eb9b812..90be6f2 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -30,3 +30,4 @@ pub mod stats_cache; pub mod statusline_gallery; pub mod statusline_writer; pub mod subagent_writer; +pub mod wsl_config; diff --git a/src-tauri/src/services/wsl_config.rs b/src-tauri/src/services/wsl_config.rs new file mode 100644 index 0000000..8b80f60 --- /dev/null +++ b/src-tauri/src/services/wsl_config.rs @@ -0,0 +1,146 @@ +use crate::utils::wsl; +use anyhow::Result; +use log::info; +use serde_json::{json, Map, Value}; + +/// MCP tuple type (same as used by other config writers) +pub type McpTuple = ( + String, // name + String, // type (stdio, sse, http) + Option, // command + Option, // args (JSON) + Option, // url + Option, // headers (JSON) + Option, // env (JSON) +); + +/// Write global MCP config to a WSL distro's ~/.claude.json +pub fn write_wsl_global_config(distro: &str, mcps: &[McpTuple]) -> Result<()> { + let config_path = "$HOME/.claude.json"; + + // Read existing config or start fresh + let mut claude_json: Value = match wsl::read_wsl_file(distro, config_path) { + Ok(content) => serde_json::from_str(&content).map_err(|e| { + anyhow::anyhow!( + "Failed to parse existing Claude config in WSL distro '{}': {}. \ + Refusing to overwrite to prevent data loss.", + distro, + e + ) + })?, + Err(_) => json!({}), + }; + + // Build mcpServers object + let mut servers = Map::new(); + for mcp in mcps { + let (name, mcp_type, command, args, url, headers, env) = mcp; + + let config = match mcp_type.as_str() { + "stdio" => { + let mut obj = Map::new(); + if let Some(cmd) = command { + obj.insert("command".to_string(), json!(cmd)); + } + if let Some(args_json) = args { + if let Ok(args_val) = serde_json::from_str::>(args_json) { + obj.insert("args".to_string(), json!(args_val)); + } + } + if let Some(env_json) = env { + if let Ok(env_val) = serde_json::from_str::>(env_json) { + obj.insert("env".to_string(), Value::Object(env_val)); + } + } + Some(Value::Object(obj)) + } + "sse" => { + let mut obj = Map::new(); + obj.insert("type".to_string(), json!("sse")); + if let Some(u) = url { + obj.insert("url".to_string(), json!(u)); + } + if let Some(headers_json) = headers { + if let Ok(headers_val) = + serde_json::from_str::>(headers_json) + { + obj.insert("headers".to_string(), Value::Object(headers_val)); + } + } + Some(Value::Object(obj)) + } + "http" => { + let mut obj = Map::new(); + obj.insert("type".to_string(), json!("http")); + if let Some(u) = url { + obj.insert("url".to_string(), json!(u)); + } + if let Some(headers_json) = headers { + if let Ok(headers_val) = + serde_json::from_str::>(headers_json) + { + obj.insert("headers".to_string(), Value::Object(headers_val)); + } + } + Some(Value::Object(obj)) + } + _ => None, + }; + + if let Some(cfg) = config { + servers.insert(name.clone(), cfg); + } + } + + claude_json["mcpServers"] = Value::Object(servers); + + // Backup existing file + let _ = wsl::backup_wsl_file(distro, config_path); + + // Write the config + let content = serde_json::to_string_pretty(&claude_json)?; + wsl::write_wsl_file(distro, config_path, &content)?; + + info!( + "[WSL] Wrote global config to distro '{}' with {} MCPs", + distro, + mcps.len() + ); + + Ok(()) +} + +/// Write a command/skill markdown file to a WSL distro +pub fn write_wsl_command_file( + distro: &str, + dir: &str, + filename: &str, + content: &str, +) -> Result<()> { + wsl::mkdir_wsl(distro, dir)?; + let path = format!("{}/{}", dir, filename); + wsl::write_wsl_file(distro, &path, content)?; + info!("[WSL] Wrote command file '{}' to distro '{}'", path, distro); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mcp_tuple_type_alias() { + // Verify the type alias works correctly + let mcp: McpTuple = ( + "test".to_string(), + "stdio".to_string(), + Some("npx".to_string()), + None, + None, + None, + None, + ); + assert_eq!(mcp.0, "test"); + assert_eq!(mcp.1, "stdio"); + } +} diff --git a/src-tauri/src/utils/mod.rs b/src-tauri/src/utils/mod.rs index 021846f..17379e7 100644 --- a/src-tauri/src/utils/mod.rs +++ b/src-tauri/src/utils/mod.rs @@ -4,3 +4,4 @@ pub mod cursor_paths; pub mod gemini_paths; pub mod opencode_paths; pub mod paths; +pub mod wsl; diff --git a/src-tauri/src/utils/wsl.rs b/src-tauri/src/utils/wsl.rs new file mode 100644 index 0000000..ec16208 --- /dev/null +++ b/src-tauri/src/utils/wsl.rs @@ -0,0 +1,386 @@ +use anyhow::Result; +use log::{info, warn}; +use std::process::Command; + +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; + +/// Information about a WSL distro with Claude Code installed +#[derive(Debug, Clone)] +pub struct WslClaudeInfo { + /// The WSL distro name (e.g., "Ubuntu", "Debian") + pub distro: String, + /// Whether Claude Code's .claude/ directory exists in this distro + pub is_installed: bool, + /// The home directory path inside WSL (e.g., "/home/user") + pub wsl_home: Option, +} + +/// Check if WSL2 is available on this Windows system +#[cfg(target_os = "windows")] +pub fn is_wsl_available() -> bool { + Command::new("wsl.exe") + .args(["--status"]) + .creation_flags(0x08000000) // CREATE_NO_WINDOW + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +#[cfg(not(target_os = "windows"))] +pub fn is_wsl_available() -> bool { + false +} + +/// List all installed WSL distros +#[cfg(target_os = "windows")] +pub fn list_wsl_distros() -> Result> { + let output = Command::new("wsl.exe") + .args(["--list", "--quiet"]) + .creation_flags(0x08000000) // CREATE_NO_WINDOW + .output() + .map_err(|e| anyhow::anyhow!("Failed to run wsl.exe: {}", e))?; + + if !output.status.success() { + return Ok(Vec::new()); + } + + // WSL outputs UTF-16LE, decode it + let stdout = decode_wsl_output(&output.stdout); + + let distros: Vec = stdout + .lines() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()) + .collect(); + + Ok(distros) +} + +#[cfg(not(target_os = "windows"))] +pub fn list_wsl_distros() -> Result> { + Ok(Vec::new()) +} + +/// Decode WSL output which may be UTF-16LE (Windows) or UTF-8 +fn decode_wsl_output(bytes: &[u8]) -> String { + // Check for UTF-16LE BOM (0xFF 0xFE) + let has_bom = bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE; + + // Also detect UTF-16LE by checking for null bytes in alternating positions + // (common for ASCII text encoded as UTF-16LE) + let looks_like_utf16 = has_bom + || (bytes.len() >= 4 + && bytes.len() % 2 == 0 + && bytes[1] == 0 + && bytes[3] == 0 + && bytes[0].is_ascii_alphanumeric()); + + if looks_like_utf16 { + let u16_iter: Vec = bytes + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + + if let Ok(decoded) = String::from_utf16(&u16_iter) { + // Strip BOM if present + let result = decoded.strip_prefix('\u{feff}').unwrap_or(&decoded); + return result.to_string(); + } + } + + // Fall back to UTF-8 + String::from_utf8_lossy(bytes).to_string() +} + +/// Check if Claude Code is installed in a specific WSL distro +#[cfg(target_os = "windows")] +pub fn check_claude_in_distro(distro: &str) -> WslClaudeInfo { + // Get the home directory + let wsl_home = get_wsl_home(distro); + + // Check if .claude/ directory exists + let is_installed = Command::new("wsl.exe") + .args(["-d", distro, "--", "test", "-d", "$HOME/.claude"]) + .creation_flags(0x08000000) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + WslClaudeInfo { + distro: distro.to_string(), + is_installed, + wsl_home, + } +} + +#[cfg(not(target_os = "windows"))] +pub fn check_claude_in_distro(distro: &str) -> WslClaudeInfo { + WslClaudeInfo { + distro: distro.to_string(), + is_installed: false, + wsl_home: None, + } +} + +/// Get the home directory path inside a WSL distro +#[cfg(target_os = "windows")] +fn get_wsl_home(distro: &str) -> Option { + let output = Command::new("wsl.exe") + .args(["-d", distro, "--", "echo", "$HOME"]) + .creation_flags(0x08000000) + .output() + .ok()?; + + if output.status.success() { + let home = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !home.is_empty() { + return Some(home); + } + } + None +} + +/// Detect all WSL distros that have Claude Code installed +pub fn detect_wsl_claude_installations() -> Vec { + if !is_wsl_available() { + return Vec::new(); + } + + let distros = match list_wsl_distros() { + Ok(d) => d, + Err(e) => { + warn!("[WSL] Failed to list distros: {}", e); + return Vec::new(); + } + }; + + info!("[WSL] Found {} distro(s): {:?}", distros.len(), distros); + + distros + .iter() + .map(|distro| { + let info = check_claude_in_distro(distro); + info!( + "[WSL] Distro '{}': claude_installed={}, home={:?}", + distro, info.is_installed, info.wsl_home + ); + info + }) + .collect() +} + +/// Read a file from inside a WSL distro +#[cfg(target_os = "windows")] +pub fn read_wsl_file(distro: &str, path: &str) -> Result { + let output = Command::new("wsl.exe") + .args(["-d", distro, "--", "cat", path]) + .creation_flags(0x08000000) + .output() + .map_err(|e| anyhow::anyhow!("Failed to read file from WSL distro '{}': {}", distro, e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!( + "Failed to read '{}' in WSL distro '{}': {}", + path, + distro, + stderr + )); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +#[cfg(not(target_os = "windows"))] +pub fn read_wsl_file(_distro: &str, _path: &str) -> Result { + Err(anyhow::anyhow!("WSL is only available on Windows")) +} + +/// Write a file inside a WSL distro +#[cfg(target_os = "windows")] +pub fn write_wsl_file(distro: &str, path: &str, content: &str) -> Result<()> { + use std::process::Stdio; + + // Use a shell command to write via stdin to avoid escaping issues + let mut child = Command::new("wsl.exe") + .args(["-d", distro, "--", "sh", "-c", &format!("cat > '{}'", path)]) + .creation_flags(0x08000000) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| anyhow::anyhow!("Failed to write file to WSL distro '{}': {}", distro, e))?; + + { + use std::io::Write; + let stdin = child + .stdin + .as_mut() + .ok_or_else(|| anyhow::anyhow!("Failed to open stdin for WSL write"))?; + stdin.write_all(content.as_bytes()).map_err(|e| { + anyhow::anyhow!("Failed to write content to WSL distro '{}': {}", distro, e) + })?; + } + + let output = child.wait_with_output().map_err(|e| { + anyhow::anyhow!("Failed to wait for WSL write in distro '{}': {}", distro, e) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!( + "Failed to write '{}' in WSL distro '{}': {}", + path, + distro, + stderr + )); + } + + Ok(()) +} + +#[cfg(not(target_os = "windows"))] +pub fn write_wsl_file(_distro: &str, _path: &str, _content: &str) -> Result<()> { + Err(anyhow::anyhow!("WSL is only available on Windows")) +} + +/// Create a backup of a file inside a WSL distro +#[cfg(target_os = "windows")] +pub fn backup_wsl_file(distro: &str, path: &str) -> Result<()> { + let backup_path = format!("{}.bak", path); + + let output = Command::new("wsl.exe") + .args([ + "-d", + distro, + "--", + "sh", + "-c", + &format!("[ -f '{}' ] && cp '{}' '{}'", path, path, backup_path), + ]) + .creation_flags(0x08000000) + .output() + .map_err(|e| anyhow::anyhow!("Failed to backup file in WSL distro '{}': {}", distro, e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + warn!( + "[WSL] Backup of '{}' in '{}' may have failed: {}", + path, distro, stderr + ); + } + + Ok(()) +} + +#[cfg(not(target_os = "windows"))] +pub fn backup_wsl_file(_distro: &str, _path: &str) -> Result<()> { + Err(anyhow::anyhow!("WSL is only available on Windows")) +} + +/// Create a directory inside a WSL distro (mkdir -p) +#[cfg(target_os = "windows")] +pub fn mkdir_wsl(distro: &str, path: &str) -> Result<()> { + let output = Command::new("wsl.exe") + .args(["-d", distro, "--", "mkdir", "-p", path]) + .creation_flags(0x08000000) + .output() + .map_err(|e| { + anyhow::anyhow!( + "Failed to create directory in WSL distro '{}': {}", + distro, + e + ) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!( + "Failed to create '{}' in WSL distro '{}': {}", + path, + distro, + stderr + )); + } + + Ok(()) +} + +#[cfg(not(target_os = "windows"))] +pub fn mkdir_wsl(_distro: &str, _path: &str) -> Result<()> { + Err(anyhow::anyhow!("WSL is only available on Windows")) +} + +/// Generate the editor ID for a WSL distro +pub fn wsl_editor_id(distro: &str) -> String { + format!("wsl_{}", distro.to_lowercase().replace(' ', "_")) +} + +/// Check if an editor ID represents a WSL distro +pub fn is_wsl_editor(editor_id: &str) -> bool { + editor_id.starts_with("wsl_") +} + +/// Extract the distro name from a WSL editor ID +pub fn distro_from_editor_id(editor_id: &str) -> Option { + if !is_wsl_editor(editor_id) { + return None; + } + + // We need to find the original distro name - try listing distros + let distros = list_wsl_distros().unwrap_or_default(); + let normalized_id = &editor_id[4..]; // strip "wsl_" + + distros + .into_iter() + .find(|d| d.to_lowercase().replace(' ', "_") == normalized_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_wsl_editor_id() { + assert_eq!(wsl_editor_id("Ubuntu"), "wsl_ubuntu"); + assert_eq!(wsl_editor_id("Debian"), "wsl_debian"); + assert_eq!(wsl_editor_id("Ubuntu 22.04"), "wsl_ubuntu_22.04"); + } + + #[test] + fn test_is_wsl_editor() { + assert!(is_wsl_editor("wsl_ubuntu")); + assert!(is_wsl_editor("wsl_debian")); + assert!(!is_wsl_editor("claude_code")); + assert!(!is_wsl_editor("opencode")); + } + + #[test] + fn test_decode_wsl_output_utf8() { + let input = b"Ubuntu\nDebian\n"; + let result = decode_wsl_output(input); + assert!(result.contains("Ubuntu")); + assert!(result.contains("Debian")); + } + + #[test] + fn test_decode_wsl_output_utf16le() { + // "Ubuntu\r\n" in UTF-16LE with BOM + let mut bytes: Vec = Vec::new(); + // BOM + bytes.extend_from_slice(&[0xFF, 0xFE]); + // "Ubuntu\r\n" + for c in "Ubuntu\r\n".encode_utf16() { + bytes.extend_from_slice(&c.to_le_bytes()); + } + let result = decode_wsl_output(&bytes); + assert!(result.contains("Ubuntu")); + } + + #[test] + fn test_decode_wsl_output_empty() { + let result = decode_wsl_output(b""); + assert!(result.is_empty()); + } +} diff --git a/src/lib/components/settings/tabs/SettingsEditorSyncTab.svelte b/src/lib/components/settings/tabs/SettingsEditorSyncTab.svelte index 42c652c..c547087 100644 --- a/src/lib/components/settings/tabs/SettingsEditorSyncTab.svelte +++ b/src/lib/components/settings/tabs/SettingsEditorSyncTab.svelte @@ -407,9 +407,19 @@ case 'copilot': return 'Copilot CLI'; case 'cursor': return 'Cursor'; case 'gemini': return 'Gemini CLI'; - default: return editorId; + default: + if (editorId.startsWith('wsl_')) { + // Find the editor in our list for proper display name + const editor = editors.find(e => e.id === editorId); + return editor?.name ?? `Claude Code (WSL)`; + } + return editorId; } } + + function isWslEditor(editorId: string): boolean { + return editorId.startsWith('wsl_'); + }
@@ -429,7 +439,7 @@ >
{#if editor.id === 'claude_code'} C @@ -443,6 +453,8 @@ U {:else if editor.id === 'gemini'} M + {:else if isWslEditor(editor.id)} + W {:else} {editor.name.charAt(0)} {/if} diff --git a/src/lib/stores/usageStore.svelte.ts b/src/lib/stores/usageStore.svelte.ts index cc96bd8..b61001b 100644 --- a/src/lib/stores/usageStore.svelte.ts +++ b/src/lib/stores/usageStore.svelte.ts @@ -2,6 +2,12 @@ import { invoke } from '@tauri-apps/api/core'; import type { StatsCacheInfo, DateRangeFilter } from '$lib/types'; import { estimateModelCost } from '$lib/types/usage'; +export interface DailyCost { + date: string; + costByModel: Record; + total: number; +} + class UsageStoreState { data = $state(null); isLoading = $state(false); @@ -57,6 +63,32 @@ class UsageStoreState { return total; }); + filteredDailyCosts = $derived.by((): DailyCost[] => { + const tokens = this.stats?.dailyModelTokens ?? []; + const filtered = this.filterByDateRange(tokens); + const usage = this.stats?.modelUsage ?? {}; + + return filtered.map((day) => { + const costByModel: Record = {}; + for (const [model, tokenCount] of Object.entries(day.tokensByModel)) { + const detail = usage[model]; + if (detail && detail.costUSD > 0) { + // Proportional cost based on token share + const totalModelTokens = detail.inputTokens + detail.outputTokens; + const share = totalModelTokens > 0 ? tokenCount / totalModelTokens : 0; + costByModel[model] = detail.costUSD * share; + } else { + // Rough estimate: split tokens 30/70 input/output + const inputEst = Math.round(tokenCount * 0.3); + const outputEst = tokenCount - inputEst; + costByModel[model] = estimateModelCost(model, inputEst, outputEst, 0, 0); + } + } + const total = Object.values(costByModel).reduce((s, v) => s + v, 0); + return { date: day.date, costByModel, total }; + }); + }); + hourCountsArray = $derived.by(() => { const counts = this.stats?.hourCounts ?? {}; const arr: number[] = new Array(24).fill(0); @@ -94,6 +126,22 @@ class UsageStoreState { setDateRange(range: DateRangeFilter) { this.dateRange = range; } + + private pollingInterval: ReturnType | null = null; + + startPolling(intervalMs: number) { + this.stopPolling(); + this.pollingInterval = setInterval(() => { + this.load(); + }, intervalMs); + } + + stopPolling() { + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + this.pollingInterval = null; + } + } } export const usageStore = new UsageStoreState(); diff --git a/src/lib/types/skill.ts b/src/lib/types/skill.ts index 0419356..ed56c98 100644 --- a/src/lib/types/skill.ts +++ b/src/lib/types/skill.ts @@ -1,3 +1,5 @@ +export type SkillType = 'command' | 'skill'; + export interface Skill { id: number; name: string; diff --git a/src/routes/sessions/+page.svelte b/src/routes/sessions/+page.svelte index ca10949..f5c4c4e 100644 --- a/src/routes/sessions/+page.svelte +++ b/src/routes/sessions/+page.svelte @@ -137,7 +137,6 @@ {:else if sessionStore.sessionDetail} sessionStore.clearSession()} /> {/if} diff --git a/src/tests/helpers/invokeMock.ts b/src/tests/helpers/invokeMock.ts index 7fc5fc7..94b09f2 100644 --- a/src/tests/helpers/invokeMock.ts +++ b/src/tests/helpers/invokeMock.ts @@ -33,7 +33,7 @@ export function mockInvokeResponses(responses: Record): void { export function mockInvokeHandler( handler: (cmd: string, args?: Record) => unknown ): void { - vi.mocked(invoke).mockImplementation(async (cmd: string, args?: Record) => { - return handler(cmd, args); + vi.mocked(invoke).mockImplementation(async (cmd: string, args?: unknown) => { + return handler(cmd, args as Record | undefined); }); } diff --git a/src/tests/stores/skillLibrary.test.ts b/src/tests/stores/skillLibrary.test.ts index 49ddcd4..76c1a08 100644 --- a/src/tests/stores/skillLibrary.test.ts +++ b/src/tests/stores/skillLibrary.test.ts @@ -205,8 +205,7 @@ describe('Skill Library Store', () => { const result = await skillLibrary.create({ name: 'new-skill', description: 'New', - content: 'Content', - skillType: 'command' + content: 'Content' }); expect(result.id).toBe(3); @@ -228,8 +227,7 @@ describe('Skill Library Store', () => { await skillLibrary.update(1, { name: 'new-name', description: '', - content: '', - skillType: 'command' + content: '' }); expect(skillLibrary.skills[0].name).toBe('new-name');