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');