generated from nhcarrigan/template
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
6a12a7a34d
|
|||
| 479652b69e | |||
| a72f2afaff |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hikari-desktop",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+135
-13
@@ -437,7 +437,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"fnv",
|
||||
"uuid",
|
||||
"uuid 1.19.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -767,13 +767,34 @@ dependencies = [
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
dependencies = [
|
||||
"dirs-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
"dirs-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.4.6",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -784,10 +805,23 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "discord-rich-presence"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75db747ecd252c01bfecaf709b07fcb4c634adf0edb5fed47bc9c3052e7076b"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"uuid 0.8.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
@@ -1602,9 +1636,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hikari-desktop"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"discord-rich-presence",
|
||||
"parking_lot",
|
||||
"semver",
|
||||
"serde",
|
||||
@@ -1622,7 +1658,7 @@ dependencies = [
|
||||
"tauri-plugin-store",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"uuid 1.19.0",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
@@ -3338,6 +3374,17 @@ dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
@@ -3578,7 +3625,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"uuid",
|
||||
"uuid 1.19.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4173,7 +4220,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"embed_plist",
|
||||
"getrandom 0.3.4",
|
||||
@@ -4224,7 +4271,7 @@ checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"glob",
|
||||
"heck 0.5.0",
|
||||
"json-patch",
|
||||
@@ -4261,7 +4308,7 @@ dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
"time",
|
||||
"url",
|
||||
"uuid",
|
||||
"uuid 1.19.0",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -4557,7 +4604,7 @@ dependencies = [
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"uuid",
|
||||
"uuid 1.19.0",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -4948,7 +4995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"libappindicator",
|
||||
"muda",
|
||||
"objc2",
|
||||
@@ -5099,6 +5146,15 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.19.0"
|
||||
@@ -5687,6 +5743,15 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@@ -5738,6 +5803,21 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
@@ -5804,6 +5884,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -5822,6 +5908,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -5840,6 +5932,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -5870,6 +5968,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -5888,6 +5992,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -5906,6 +6016,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -5924,6 +6040,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -6004,7 +6126,7 @@ dependencies = [
|
||||
"block2",
|
||||
"cookie",
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"dpi",
|
||||
"dunce",
|
||||
"gdkx11",
|
||||
@@ -6127,7 +6249,7 @@ dependencies = [
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"uuid 1.19.0",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 0.7.14",
|
||||
"zbus_macros",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hikari-desktop"
|
||||
version = "1.2.0"
|
||||
version = "1.3.0"
|
||||
description = "Hikari - Claude Code Visual Assistant"
|
||||
authors = ["Naomi Carrigan"]
|
||||
edition = "2021"
|
||||
@@ -31,6 +31,8 @@ tauri-plugin-fs = "2"
|
||||
tempfile = "3"
|
||||
semver = "1"
|
||||
chrono = { version = "0.4.43", features = ["serde"] }
|
||||
discord-rich-presence = "0.2"
|
||||
dirs = "5"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62", features = [
|
||||
|
||||
+572
-62
@@ -1,5 +1,5 @@
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tauri_plugin_http::reqwest;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
@@ -11,6 +11,43 @@ use crate::temp_manager::SharedTempFileManager;
|
||||
|
||||
const CONFIG_STORE_KEY: &str = "config";
|
||||
|
||||
/// Convert a Windows path to a WSL path
|
||||
/// Example: C:\Users\accou\Documents\item.txt -> /mnt/c/Users/accou/Documents/item.txt
|
||||
fn windows_path_to_wsl(windows_path: &str) -> Option<String> {
|
||||
// Check if it's a Windows path (has drive letter like C:\)
|
||||
if windows_path.len() >= 3 && windows_path.chars().nth(1) == Some(':') {
|
||||
let drive_letter = windows_path.chars().next()?.to_lowercase().to_string();
|
||||
let path_without_drive = &windows_path[2..]; // Remove "C:"
|
||||
|
||||
// Replace backslashes with forward slashes and convert to WSL mount point
|
||||
let wsl_path = path_without_drive.replace('\\', "/");
|
||||
Some(format!("/mnt/{}{}", drive_letter, wsl_path))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a WSL path to a Windows path
|
||||
/// Example: /mnt/c/Users/accou/Documents/item.txt -> C:\Users\accou\Documents\item.txt
|
||||
#[allow(dead_code)]
|
||||
fn wsl_path_to_windows(wsl_path: &str) -> Option<String> {
|
||||
// Check if it's a WSL mount point path
|
||||
if wsl_path.starts_with("/mnt/") && wsl_path.len() > 6 {
|
||||
let rest = &wsl_path[5..]; // Remove "/mnt/"
|
||||
if let Some(drive_letter) = rest.chars().next() {
|
||||
let path_after_drive = &rest[1..]; // Remove drive letter
|
||||
|
||||
// Convert to Windows path with backslashes
|
||||
let windows_path = path_after_drive.replace('/', "\\");
|
||||
Some(format!("{}:{}", drive_letter.to_uppercase(), windows_path))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_claude(
|
||||
bridge_manager: State<'_, SharedBridgeManager>,
|
||||
@@ -69,7 +106,10 @@ pub async fn get_working_directory(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn select_wsl_directory() -> Result<String, String> {
|
||||
Ok("/home".to_string())
|
||||
// Return the user's home directory cross-platform
|
||||
dirs::home_dir()
|
||||
.ok_or_else(|| "Could not determine home directory".to_string())
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -120,53 +160,93 @@ pub async fn validate_directory(
|
||||
path: String,
|
||||
current_dir: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
let path = Path::new(&path);
|
||||
// Detect if we're dealing with a WSL path (starts with / on Windows, or current_dir is a WSL path)
|
||||
let is_wsl_path = cfg!(windows) && (path.starts_with('/') || current_dir.as_ref().is_some_and(|p| p.starts_with('/')));
|
||||
|
||||
// Expand ~ to home directory
|
||||
let expanded_path = if path.starts_with("~") {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
let home_path = Path::new(&home);
|
||||
if path == Path::new("~") {
|
||||
home_path.to_path_buf()
|
||||
if is_wsl_path {
|
||||
// WSL path - handle as Unix-style path without filesystem validation
|
||||
// since the Windows binary can't validate WSL filesystem paths
|
||||
let resolved = if path.starts_with('/') {
|
||||
// Absolute WSL path - use as-is
|
||||
path
|
||||
} else if let Some(ref cwd) = current_dir {
|
||||
// Relative path - resolve manually using Unix path logic
|
||||
if path == "." {
|
||||
cwd.clone()
|
||||
} else if path == ".." {
|
||||
// Go up one directory
|
||||
cwd.rsplit_once('/').map(|x| x.0).unwrap_or("/").to_string()
|
||||
} else if path.starts_with("../") {
|
||||
// Handle ../ prefix
|
||||
let parent = cwd.rsplit_once('/').map(|x| x.0).unwrap_or("/");
|
||||
let remainder = path.strip_prefix("../").unwrap();
|
||||
if remainder.is_empty() {
|
||||
parent.to_string()
|
||||
} else {
|
||||
format!("{}/{}", parent, remainder)
|
||||
}
|
||||
} else if path.starts_with("./") {
|
||||
// Handle ./ prefix
|
||||
format!("{}/{}", cwd, path.strip_prefix("./").unwrap())
|
||||
} else {
|
||||
home_path.join(path.strip_prefix("~").unwrap())
|
||||
// Regular relative path
|
||||
format!("{}/{}", cwd, path)
|
||||
}
|
||||
} else {
|
||||
return Err("Could not determine home directory".to_string());
|
||||
}
|
||||
} else if path.is_relative() {
|
||||
// Handle relative paths (., .., or any relative path) by resolving against current_dir
|
||||
if let Some(ref cwd) = current_dir {
|
||||
Path::new(cwd).join(path)
|
||||
return Err("Cannot resolve relative WSL path without current directory".to_string());
|
||||
};
|
||||
|
||||
// Normalize the path (remove duplicate slashes, etc.)
|
||||
let normalized = resolved.split('/').filter(|s| !s.is_empty()).collect::<Vec<_>>().join("/");
|
||||
Ok(if normalized.is_empty() { "/".to_string() } else { format!("/{}", normalized) })
|
||||
} else {
|
||||
// Native path (Windows on Windows, Unix on Unix) - validate normally
|
||||
let path = Path::new(&path);
|
||||
|
||||
let expanded_path = if path.starts_with("~") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
if path == Path::new("~") {
|
||||
home
|
||||
} else {
|
||||
home.join(path.strip_prefix("~").unwrap())
|
||||
}
|
||||
} else {
|
||||
return Err("Could not determine home directory".to_string());
|
||||
}
|
||||
} else if path.is_relative() {
|
||||
if let Some(ref cwd) = current_dir {
|
||||
let cwd_path = PathBuf::from(cwd);
|
||||
cwd_path.join(path)
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
}
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
|
||||
// Check if the path exists and is a directory
|
||||
if !expanded_path.exists() {
|
||||
return Err(format!(
|
||||
"Directory does not exist: {}",
|
||||
expanded_path.display()
|
||||
));
|
||||
}
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
|
||||
// Check if the path exists and is a directory
|
||||
if !expanded_path.exists() {
|
||||
return Err(format!(
|
||||
"Directory does not exist: {}",
|
||||
expanded_path.display()
|
||||
));
|
||||
if !expanded_path.is_dir() {
|
||||
return Err(format!(
|
||||
"Path is not a directory: {}",
|
||||
expanded_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Return the canonicalized (absolute) path
|
||||
expanded_path
|
||||
.canonicalize()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.map_err(|e| format!("Failed to resolve path: {}", e))
|
||||
}
|
||||
|
||||
if !expanded_path.is_dir() {
|
||||
return Err(format!(
|
||||
"Path is not a directory: {}",
|
||||
expanded_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Return the canonicalized (absolute) path
|
||||
expanded_path
|
||||
.canonicalize()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.map_err(|e| format!("Failed to resolve path: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -202,21 +282,22 @@ pub async fn answer_question(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_skills() -> Result<Vec<String>, String> {
|
||||
// On Windows, we need to use WSL to access the skills directory
|
||||
// since skills are stored in the WSL home directory
|
||||
if cfg!(windows) {
|
||||
return list_skills_via_wsl().await;
|
||||
}
|
||||
|
||||
// On Unix systems, use the native filesystem
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
// Get the home directory
|
||||
let home =
|
||||
std::env::var_os("HOME").ok_or_else(|| "Could not determine home directory".to_string())?;
|
||||
let home = dirs::home_dir().ok_or_else(|| "Could not determine home directory".to_string())?;
|
||||
let skills_dir = home.join(".claude").join("skills");
|
||||
|
||||
let skills_dir = Path::new(&home).join(".claude").join("skills");
|
||||
|
||||
// If the skills directory doesn't exist, return empty list
|
||||
if !skills_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Read the directory and collect skill names
|
||||
let mut skills = Vec::new();
|
||||
let entries =
|
||||
fs::read_dir(&skills_dir).map_err(|e| format!("Failed to read skills directory: {}", e))?;
|
||||
@@ -225,7 +306,6 @@ pub async fn list_skills() -> Result<Vec<String>, String> {
|
||||
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
|
||||
let path = entry.path();
|
||||
|
||||
// Only include directories that contain a SKILL.md file
|
||||
if path.is_dir() {
|
||||
let skill_file = path.join("SKILL.md");
|
||||
if skill_file.exists() {
|
||||
@@ -236,9 +316,42 @@ pub async fn list_skills() -> Result<Vec<String>, String> {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort alphabetically
|
||||
skills.sort();
|
||||
Ok(skills)
|
||||
}
|
||||
|
||||
/// List skills by executing commands through WSL (for Windows)
|
||||
#[allow(dead_code)]
|
||||
async fn list_skills_via_wsl() -> Result<Vec<String>, String> {
|
||||
use std::process::Command;
|
||||
|
||||
// Use WSL to list directories in ~/.claude/skills that contain SKILL.md
|
||||
let output = Command::new("wsl")
|
||||
.args([
|
||||
"-e",
|
||||
"sh",
|
||||
"-c",
|
||||
"if [ -d ~/.claude/skills ]; then for d in ~/.claude/skills/*/; do [ -f \"${d}SKILL.md\" ] && basename \"$d\"; done; fi",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("not found") || stderr.contains("No such file") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
return Err(format!("WSL command failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut skills: Vec<String> = stdout
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(|line| line.to_string())
|
||||
.collect();
|
||||
|
||||
skills.sort();
|
||||
Ok(skills)
|
||||
}
|
||||
|
||||
@@ -335,8 +448,18 @@ pub async fn save_temp_file(
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let path_string = path.to_string_lossy().to_string();
|
||||
|
||||
// On Windows, convert the path to WSL format if needed
|
||||
// so Claude Code (running in WSL) can access it via /mnt/c/...
|
||||
let final_path = if cfg!(windows) {
|
||||
windows_path_to_wsl(&path_string).unwrap_or(path_string)
|
||||
} else {
|
||||
path_string
|
||||
};
|
||||
|
||||
Ok(SavedFileInfo {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
path: final_path,
|
||||
filename,
|
||||
})
|
||||
}
|
||||
@@ -405,42 +528,142 @@ pub struct FileEntry {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_directory(path: String) -> Result<Vec<FileEntry>, String> {
|
||||
pub async fn list_directory(app: AppHandle, path: String) -> Result<Vec<FileEntry>, String> {
|
||||
// Set up logging
|
||||
let log_path = if let Ok(app_data_dir) = app.path().app_data_dir() {
|
||||
let _ = std::fs::create_dir_all(&app_data_dir);
|
||||
app_data_dir.join("hikari_editor_debug.log")
|
||||
} else {
|
||||
PathBuf::from("hikari_editor_debug.log")
|
||||
};
|
||||
|
||||
let mut log_file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
.ok();
|
||||
|
||||
let mut log = |msg: String| {
|
||||
if let Some(ref mut file) = log_file {
|
||||
use std::io::Write;
|
||||
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let _ = writeln!(file, "[{}] {}", timestamp, msg);
|
||||
}
|
||||
};
|
||||
|
||||
log(format!("list_directory called with path: {}", path));
|
||||
log(format!("cfg!(windows) = {}", cfg!(windows)));
|
||||
log(format!("path.starts_with('/') = {}", path.starts_with('/')));
|
||||
|
||||
// On Windows with a WSL path (starts with /), use WSL to list the directory
|
||||
if cfg!(windows) && path.starts_with('/') {
|
||||
log("Using WSL path".to_string());
|
||||
return list_directory_via_wsl(&path).await;
|
||||
}
|
||||
|
||||
log("Using native filesystem access".to_string());
|
||||
|
||||
// Native filesystem access
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
let dir_path = Path::new(&path);
|
||||
|
||||
if !dir_path.exists() {
|
||||
return Err(format!("Directory does not exist: {}", path));
|
||||
let err = format!("Directory does not exist: {}", path);
|
||||
log(format!("ERROR: {}", err));
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if !dir_path.is_dir() {
|
||||
return Err(format!("Path is not a directory: {}", path));
|
||||
let err = format!("Path is not a directory: {}", path);
|
||||
log(format!("ERROR: {}", err));
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let entries = fs::read_dir(dir_path)
|
||||
.map_err(|e| format!("Failed to read directory: {}", e))?;
|
||||
.map_err(|e| {
|
||||
let err = format!("Failed to read directory: {}", e);
|
||||
log(format!("ERROR: {}", err));
|
||||
err
|
||||
})?;
|
||||
|
||||
let mut file_entries = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
|
||||
let entry = entry.map_err(|e| {
|
||||
let err = format!("Failed to read entry: {}", e);
|
||||
log(format!("ERROR: {}", err));
|
||||
err
|
||||
})?;
|
||||
let path = entry.path();
|
||||
let name = entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
// Skip hidden files by default (can be made configurable later)
|
||||
if name.starts_with('.') {
|
||||
file_entries.push(FileEntry {
|
||||
name: name.clone(),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
is_directory: path.is_dir(),
|
||||
});
|
||||
}
|
||||
|
||||
log(format!("Successfully listed {} entries", file_entries.len()));
|
||||
Ok(file_entries)
|
||||
}
|
||||
|
||||
/// List directory contents via WSL (for Windows with WSL paths)
|
||||
#[allow(dead_code)]
|
||||
async fn list_directory_via_wsl(path: &str) -> Result<Vec<FileEntry>, String> {
|
||||
use std::process::Command;
|
||||
|
||||
// Use WSL to list directory contents
|
||||
// Output format: type<tab>name (d for directory, f for file)
|
||||
let script = format!(
|
||||
r#"if [ -d '{}' ]; then for f in '{}'/* '{}'/.* ; do [ -e "$f" ] || continue; name=$(basename "$f"); if [ "$name" = "." ] || [ "$name" = ".." ]; then continue; fi; if [ -d "$f" ]; then echo "d $name"; else echo "f $name"; fi; done; else echo "ERROR: Directory does not exist"; exit 1; fi"#,
|
||||
path, path, path
|
||||
);
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.args(["-e", "sh", "-c", &script])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
if !output.status.success() || stdout.starts_with("ERROR:") {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stdout.starts_with("ERROR:") {
|
||||
return Err(stdout.trim().to_string());
|
||||
}
|
||||
return Err(format!("WSL command failed: {}", stderr));
|
||||
}
|
||||
|
||||
let mut file_entries = Vec::new();
|
||||
|
||||
for line in stdout.lines() {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = line.splitn(2, '\t').collect();
|
||||
if parts.len() != 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_directory = parts[0] == "d";
|
||||
let name = parts[1].to_string();
|
||||
let entry_path = if path == "/" {
|
||||
format!("/{}", name)
|
||||
} else {
|
||||
format!("{}/{}", path, name)
|
||||
};
|
||||
|
||||
file_entries.push(FileEntry {
|
||||
name,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
is_directory: path.is_dir(),
|
||||
path: entry_path,
|
||||
is_directory,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -449,22 +672,80 @@ pub async fn list_directory(path: String) -> Result<Vec<FileEntry>, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_file_content(path: String) -> Result<String, String> {
|
||||
use std::fs;
|
||||
// On Windows with a WSL path, use WSL to read the file
|
||||
if cfg!(windows) && path.starts_with('/') {
|
||||
return read_file_via_wsl(&path).await;
|
||||
}
|
||||
|
||||
use std::fs;
|
||||
fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read file: {}", e))
|
||||
}
|
||||
|
||||
/// Read file content via WSL (for Windows with WSL paths)
|
||||
#[allow(dead_code)]
|
||||
async fn read_file_via_wsl(path: &str) -> Result<String, String> {
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.args(["-e", "cat", path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to read file: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn write_file_content(path: String, content: String) -> Result<(), String> {
|
||||
use std::fs;
|
||||
// On Windows with a WSL path, use WSL to write the file
|
||||
if cfg!(windows) && path.starts_with('/') {
|
||||
return write_file_via_wsl(&path, &content).await;
|
||||
}
|
||||
|
||||
use std::fs;
|
||||
fs::write(&path, content)
|
||||
.map_err(|e| format!("Failed to write file: {}", e))
|
||||
}
|
||||
|
||||
/// Write file content via WSL (for Windows with WSL paths)
|
||||
#[allow(dead_code)]
|
||||
async fn write_file_via_wsl(path: &str, content: &str) -> Result<(), String> {
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut child = Command::new("wsl")
|
||||
.args(["-e", "sh", "-c", &format!("cat > '{}'", path)])
|
||||
.stdin(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(content.as_bytes())
|
||||
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
|
||||
}
|
||||
|
||||
let status = child.wait()
|
||||
.map_err(|e| format!("Failed to wait for WSL command: {}", e))?;
|
||||
|
||||
if !status.success() {
|
||||
return Err("Failed to write file via WSL".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_file(path: String) -> Result<(), String> {
|
||||
// On Windows with a WSL path, use WSL to create the file
|
||||
if cfg!(windows) && path.starts_with('/') {
|
||||
return create_file_via_wsl(&path).await;
|
||||
}
|
||||
|
||||
use std::fs::File;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -479,8 +760,41 @@ pub async fn create_file(path: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create file via WSL (for Windows with WSL paths)
|
||||
#[allow(dead_code)]
|
||||
async fn create_file_via_wsl(path: &str) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
// Check if file exists first
|
||||
let check = Command::new("wsl")
|
||||
.args(["-e", "test", "-e", path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
|
||||
if check.success() {
|
||||
return Err("File already exists".to_string());
|
||||
}
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.args(["-e", "touch", path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to create file: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_directory(path: String) -> Result<(), String> {
|
||||
// On Windows with a WSL path, use WSL to create the directory
|
||||
if cfg!(windows) && path.starts_with('/') {
|
||||
return create_directory_via_wsl(&path).await;
|
||||
}
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -495,8 +809,41 @@ pub async fn create_directory(path: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create directory via WSL (for Windows with WSL paths)
|
||||
#[allow(dead_code)]
|
||||
async fn create_directory_via_wsl(path: &str) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
// Check if directory exists first
|
||||
let check = Command::new("wsl")
|
||||
.args(["-e", "test", "-e", path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
|
||||
if check.success() {
|
||||
return Err("Directory already exists".to_string());
|
||||
}
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.args(["-e", "mkdir", "-p", path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to create directory: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_file(path: String) -> Result<(), String> {
|
||||
// On Windows with a WSL path, use WSL to delete the file
|
||||
if cfg!(windows) && path.starts_with('/') {
|
||||
return delete_file_via_wsl(&path).await;
|
||||
}
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -515,8 +862,51 @@ pub async fn delete_file(path: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete file via WSL (for Windows with WSL paths)
|
||||
#[allow(dead_code)]
|
||||
async fn delete_file_via_wsl(path: &str) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
// Check if path exists
|
||||
let check_exists = Command::new("wsl")
|
||||
.args(["-e", "test", "-e", path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
|
||||
if !check_exists.success() {
|
||||
return Err("File does not exist".to_string());
|
||||
}
|
||||
|
||||
// Check if path is a directory
|
||||
let check_dir = Command::new("wsl")
|
||||
.args(["-e", "test", "-d", path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
|
||||
if check_dir.success() {
|
||||
return Err("Path is a directory, use delete_directory instead".to_string());
|
||||
}
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.args(["-e", "rm", path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to delete file: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_directory(path: String) -> Result<(), String> {
|
||||
// On Windows with a WSL path, use WSL to delete the directory
|
||||
if cfg!(windows) && path.starts_with('/') {
|
||||
return delete_directory_via_wsl(&path).await;
|
||||
}
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -535,8 +925,51 @@ pub async fn delete_directory(path: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete directory via WSL (for Windows with WSL paths)
|
||||
#[allow(dead_code)]
|
||||
async fn delete_directory_via_wsl(path: &str) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
// Check if path exists
|
||||
let check_exists = Command::new("wsl")
|
||||
.args(["-e", "test", "-e", path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
|
||||
if !check_exists.success() {
|
||||
return Err("Directory does not exist".to_string());
|
||||
}
|
||||
|
||||
// Check if path is a directory
|
||||
let check_dir = Command::new("wsl")
|
||||
.args(["-e", "test", "-d", path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
|
||||
if !check_dir.success() {
|
||||
return Err("Path is not a directory".to_string());
|
||||
}
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.args(["-e", "rm", "-rf", path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to delete directory: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn rename_path(old_path: String, new_path: String) -> Result<(), String> {
|
||||
// On Windows with WSL paths, use WSL to rename
|
||||
if cfg!(windows) && old_path.starts_with('/') {
|
||||
return rename_path_via_wsl(&old_path, &new_path).await;
|
||||
}
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -556,6 +989,44 @@ pub async fn rename_path(old_path: String, new_path: String) -> Result<(), Strin
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rename path via WSL (for Windows with WSL paths)
|
||||
#[allow(dead_code)]
|
||||
async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
// Check if old path exists
|
||||
let check_old = Command::new("wsl")
|
||||
.args(["-e", "test", "-e", old_path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
|
||||
if !check_old.success() {
|
||||
return Err("Path does not exist".to_string());
|
||||
}
|
||||
|
||||
// Check if new path already exists
|
||||
let check_new = Command::new("wsl")
|
||||
.args(["-e", "test", "-e", new_path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
|
||||
if check_new.success() {
|
||||
return Err("Destination already exists".to_string());
|
||||
}
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.args(["-e", "mv", old_path, new_path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to rename: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== Cost Tracking Commands ====================
|
||||
|
||||
const COST_HISTORY_STORE_KEY: &str = "cost_history";
|
||||
@@ -647,6 +1118,42 @@ async fn save_cost_history(app: &AppHandle, history: &crate::cost_tracking::Cost
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn init_discord_rpc(
|
||||
discord_rpc: State<'_, std::sync::Arc<crate::discord_rpc::DiscordRpcManager>>,
|
||||
session_name: String,
|
||||
model: String,
|
||||
started_at: i64,
|
||||
) -> Result<(), String> {
|
||||
discord_rpc.init(session_name, model, started_at)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_discord_rpc(
|
||||
discord_rpc: State<'_, std::sync::Arc<crate::discord_rpc::DiscordRpcManager>>,
|
||||
session_name: String,
|
||||
model: String,
|
||||
started_at: i64,
|
||||
) -> Result<(), String> {
|
||||
discord_rpc.update(session_name, model, started_at)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_discord_rpc(
|
||||
discord_rpc: State<'_, std::sync::Arc<crate::discord_rpc::DiscordRpcManager>>,
|
||||
) -> Result<(), String> {
|
||||
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(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -856,7 +1363,10 @@ mod tests {
|
||||
fn test_select_wsl_directory_returns_home() {
|
||||
let result = run_async(select_wsl_directory());
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "/home");
|
||||
|
||||
// Should return the user's home directory
|
||||
let home_dir = result.unwrap();
|
||||
assert!(home_dir.starts_with("/home/") || home_dir == "/root");
|
||||
}
|
||||
|
||||
// ==================== UpdateInfo struct tests ====================
|
||||
|
||||
@@ -112,6 +112,9 @@ pub struct HikariConfig {
|
||||
|
||||
#[serde(default = "default_budget_warning_threshold")]
|
||||
pub budget_warning_threshold: f32,
|
||||
|
||||
#[serde(default = "default_discord_rpc_enabled")]
|
||||
pub discord_rpc_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for HikariConfig {
|
||||
@@ -144,6 +147,7 @@ impl Default for HikariConfig {
|
||||
session_cost_budget: None,
|
||||
budget_action: BudgetAction::Warn,
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,6 +180,10 @@ fn default_budget_warning_threshold() -> f32 {
|
||||
0.8
|
||||
}
|
||||
|
||||
fn default_discord_rpc_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BudgetAction {
|
||||
@@ -247,6 +255,7 @@ mod tests {
|
||||
assert!(config.session_cost_budget.is_none());
|
||||
assert_eq!(config.budget_action, BudgetAction::Warn);
|
||||
assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON);
|
||||
assert!(config.discord_rpc_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -279,6 +288,7 @@ mod tests {
|
||||
session_cost_budget: Some(1.50),
|
||||
budget_action: BudgetAction::Block,
|
||||
budget_warning_threshold: 0.75,
|
||||
discord_rpc_enabled: true,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
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 {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: Arc::new(RwLock::new(None)),
|
||||
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) {
|
||||
eprintln!("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));
|
||||
|
||||
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));
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
self.log("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));
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
self.log("DEBUG: Connected to Discord IPC socket");
|
||||
|
||||
// Set initial activity immediately after connecting
|
||||
self.log("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'");
|
||||
|
||||
let timestamps = Timestamps::new()
|
||||
.start(started_at);
|
||||
|
||||
self.log(&format!("DEBUG: Timestamps created - start: {}", started_at));
|
||||
|
||||
let activity = Activity::new()
|
||||
.details(initial_session_name.as_str())
|
||||
.state(state_text.as_str())
|
||||
.assets(assets)
|
||||
.timestamps(timestamps);
|
||||
|
||||
self.log(&format!("DEBUG: Activity created - details: '{}', state: '{}'",
|
||||
initial_session_name, state_text));
|
||||
|
||||
self.log("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));
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
self.log("DEBUG: Initial activity set successfully!");
|
||||
|
||||
// Store the client and initial state
|
||||
*self.client.write() = Some(client);
|
||||
*self.session_name.write() = initial_session_name.clone();
|
||||
*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));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update(
|
||||
&self,
|
||||
session_name: String,
|
||||
model: String,
|
||||
started_at: i64,
|
||||
) -> Result<(), String> {
|
||||
self.log(&format!("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");
|
||||
|
||||
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));
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
self.log("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'");
|
||||
|
||||
let timestamps = Timestamps::new()
|
||||
.start(started_at);
|
||||
|
||||
self.log(&format!("DEBUG: Timestamps created - start: {}", started_at));
|
||||
|
||||
let activity = Activity::new()
|
||||
.details(session_name.as_str())
|
||||
.state(state_text.as_str())
|
||||
.assets(assets)
|
||||
.timestamps(timestamps);
|
||||
|
||||
self.log(&format!("DEBUG: Activity created - details: '{}', state: '{}'",
|
||||
session_name, state_text));
|
||||
|
||||
self.log("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));
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
self.log(&format!("Updated Discord RPC: session='{}', model='{}'", session_name, model));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(&self) -> Result<(), String> {
|
||||
self.log("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...");
|
||||
client
|
||||
.close()
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Failed to close Discord RPC: {}", e);
|
||||
self.log(&format!("ERROR: {}", error_msg));
|
||||
error_msg
|
||||
})?;
|
||||
self.log("Discord RPC stopped successfully");
|
||||
} else {
|
||||
self.log("DEBUG: No client to stop (already stopped or never initialized)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DiscordRpcManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ mod clipboard;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod cost_tracking;
|
||||
mod discord_rpc;
|
||||
mod git;
|
||||
mod notifications;
|
||||
mod quick_actions;
|
||||
@@ -23,11 +24,13 @@ use bridge_manager::create_shared_bridge_manager;
|
||||
use clipboard::*;
|
||||
use commands::load_saved_achievements;
|
||||
use commands::*;
|
||||
use discord_rpc::DiscordRpcManager;
|
||||
use git::*;
|
||||
use notifications::*;
|
||||
use quick_actions::*;
|
||||
use sessions::*;
|
||||
use snippets::*;
|
||||
use std::sync::Arc;
|
||||
use tauri::Manager;
|
||||
use temp_manager::create_shared_temp_manager;
|
||||
use tray::{setup_tray, should_minimize_to_tray};
|
||||
@@ -39,6 +42,7 @@ use wsl_notifications::*;
|
||||
pub fn run() {
|
||||
let bridge_manager = create_shared_bridge_manager();
|
||||
let temp_manager = create_shared_temp_manager().expect("Failed to create temp file manager");
|
||||
let discord_rpc = Arc::new(DiscordRpcManager::new());
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
@@ -52,10 +56,14 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.manage(bridge_manager.clone())
|
||||
.manage(temp_manager.clone())
|
||||
.manage(discord_rpc.clone())
|
||||
.setup(move |app| {
|
||||
// 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 {
|
||||
@@ -169,6 +177,10 @@ pub fn run() {
|
||||
get_today_cost,
|
||||
get_week_cost,
|
||||
get_month_cost,
|
||||
init_discord_rpc,
|
||||
update_discord_rpc,
|
||||
stop_discord_rpc,
|
||||
log_discord_rpc,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "hikari-desktop",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"identifier": "com.naomi.hikari-desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
@@ -8,9 +8,13 @@ import {
|
||||
} from "./slashCommands";
|
||||
|
||||
// Mock all external dependencies
|
||||
vi.mock("svelte/store", () => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
vi.mock("svelte/store", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("svelte/store")>();
|
||||
return {
|
||||
...actual,
|
||||
get: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
|
||||
@@ -2,8 +2,10 @@ import { get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { setSkipNextGreeting } from "$lib/tauri";
|
||||
import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri";
|
||||
import { searchState } from "$lib/stores/search";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
@@ -51,6 +53,17 @@ async function changeDirectory(path: string): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when reconnecting after directory change
|
||||
const config = configStore.getConfig();
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for connection to establish
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
@@ -105,6 +118,17 @@ async function startNewConversation(): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when starting new conversation
|
||||
const config = configStore.getConfig();
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
|
||||
claudeStore.addLine("system", "New conversation started!");
|
||||
characterState.setState("idle");
|
||||
} catch (error) {
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
session_cost_budget: null,
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
});
|
||||
|
||||
let showCustomThemeEditor = $state(false);
|
||||
@@ -967,6 +968,30 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Discord Rich Presence Section -->
|
||||
<section class="pt-6 pb-6 border-t border-[var(--border-color)]">
|
||||
<h3 class="text-lg font-semibold text-[var(--accent-primary)] mb-4 flex items-center gap-2">
|
||||
<span>🎮</span>
|
||||
<span>Discord Rich Presence</span>
|
||||
</h3>
|
||||
|
||||
<!-- Enable/Disable Discord RPC -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={config.discord_rpc_enabled}
|
||||
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
|
||||
/>
|
||||
<span class="text-sm text-[var(--text-primary)]">Show activity in Discord</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-[var(--text-tertiary)]">
|
||||
Display your current conversation session name and model in Discord when enabled.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="sticky bottom-0 pt-4 pb-2 bg-[var(--bg-secondary)]">
|
||||
<button
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { handleNewUserMessage } from "$lib/notifications/rules";
|
||||
import { setSkipNextGreeting } from "$lib/tauri";
|
||||
import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri";
|
||||
import { clipboardStore } from "$lib/stores/clipboard";
|
||||
import {
|
||||
setShouldRestoreHistory,
|
||||
@@ -26,6 +26,7 @@
|
||||
type SlashCommand,
|
||||
} from "$lib/commands/slashCommands";
|
||||
import { configStore, isStreamerMode } from "$lib/stores/config";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { stats, estimateMessageCost, formatTokenCount } from "$lib/stores/stats";
|
||||
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
|
||||
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
|
||||
@@ -350,6 +351,17 @@ User: ${formattedMessage}`;
|
||||
working_dir: workingDir,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when reconnecting
|
||||
const config = configStore.getConfig();
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
} catch (reconnectError) {
|
||||
console.error("Failed to auto-reconnect:", reconnectError);
|
||||
claudeStore.addLine("error", `Failed to reconnect: ${reconnectError}`);
|
||||
@@ -435,11 +447,12 @@ User: ${formattedMessage}`;
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
||||
savedPath = await invoke<string>("save_temp_file", {
|
||||
const result = await invoke<{ path: string; filename: string }>("save_temp_file", {
|
||||
conversationId,
|
||||
filename,
|
||||
data: bytes,
|
||||
});
|
||||
savedPath = result.path;
|
||||
} catch (error) {
|
||||
console.error("Failed to save dropped file to temp:", error);
|
||||
savedPath = file.name;
|
||||
@@ -573,11 +586,12 @@ User: ${formattedMessage}`;
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
||||
savedPath = await invoke<string>("save_temp_file", {
|
||||
const result = await invoke<{ path: string; filename: string }>("save_temp_file", {
|
||||
conversationId,
|
||||
filename,
|
||||
data: bytes,
|
||||
});
|
||||
savedPath = result.path;
|
||||
} catch (error) {
|
||||
console.error("Failed to save pasted file to temp:", error);
|
||||
}
|
||||
@@ -635,11 +649,12 @@ User: ${formattedMessage}`;
|
||||
try {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
||||
savedPath = await invoke<string>("save_temp_file", {
|
||||
const result = await invoke<{ path: string; filename: string }>("save_temp_file", {
|
||||
conversationId,
|
||||
filename,
|
||||
data: bytes,
|
||||
});
|
||||
savedPath = result.path;
|
||||
} catch (error) {
|
||||
console.error("Failed to save clipboard image to temp:", error);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
import { claudeStore, hasPermissionPending } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import type { PermissionRequest } from "$lib/types/messages";
|
||||
import { updateDiscordRpc } from "$lib/tauri";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
let isVisible = $state(false);
|
||||
let permission: PermissionRequest | null = $state(null);
|
||||
@@ -64,6 +67,17 @@
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when reconnecting after permission grant
|
||||
const config = configStore.getConfig();
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for connection to establish
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
createSummary,
|
||||
sanitizeForJson,
|
||||
} from "$lib/utils/conversationUtils";
|
||||
import { updateDiscordRpc } from "$lib/tauri";
|
||||
|
||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||
const DONATE_URL = "https://donate.nhcarrigan.com";
|
||||
@@ -86,6 +87,7 @@
|
||||
session_cost_budget: null,
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
});
|
||||
|
||||
let streamerModeActive = $state(false);
|
||||
@@ -165,6 +167,16 @@
|
||||
allowed_tools: allAllowedTools,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when a new session starts
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
currentConfig.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to start Claude:", error);
|
||||
claudeStore.addLine("error", `Connection failed: ${error}`);
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
import { claudeStore, hasQuestionPending } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import type { UserQuestionEvent } from "$lib/types/messages";
|
||||
import { updateDiscordRpc } from "$lib/tauri";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
let isVisible = $state(false);
|
||||
let question: UserQuestionEvent | null = $state(null);
|
||||
@@ -98,6 +101,17 @@
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when reconnecting after answering question
|
||||
const config = configStore.getConfig();
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
if (conversationHistory) {
|
||||
|
||||
@@ -192,6 +192,7 @@ describe("config store", () => {
|
||||
session_cost_budget: null,
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
};
|
||||
|
||||
expect(config.model).toBe("claude-sonnet-4");
|
||||
@@ -237,6 +238,7 @@ describe("config store", () => {
|
||||
session_cost_budget: null,
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
};
|
||||
|
||||
expect(config.model).toBeNull();
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface HikariConfig {
|
||||
session_cost_budget: number | null;
|
||||
budget_action: BudgetAction;
|
||||
budget_warning_threshold: number;
|
||||
// Discord RPC settings
|
||||
discord_rpc_enabled: boolean;
|
||||
}
|
||||
|
||||
const defaultConfig: HikariConfig = {
|
||||
@@ -83,6 +85,7 @@ const defaultConfig: HikariConfig = {
|
||||
session_cost_budget: null,
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
};
|
||||
|
||||
function createConfigStore() {
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface Conversation {
|
||||
lastActivityAt: Date;
|
||||
attachments: Attachment[];
|
||||
summary: ConversationSummary | null;
|
||||
startedAt: Date;
|
||||
}
|
||||
|
||||
function createConversationsStore() {
|
||||
@@ -72,6 +73,7 @@ function createConversationsStore() {
|
||||
lastActivityAt: new Date(),
|
||||
attachments: [],
|
||||
summary: null,
|
||||
startedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { writable, derived } from "svelte/store";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { costTrackingStore } from "./costTracking";
|
||||
import { configStore } from "./config";
|
||||
|
||||
export type ContextWarning = "moderate" | "high" | "critical";
|
||||
export type BudgetType = "token" | "cost";
|
||||
@@ -133,7 +134,7 @@ export function formatTokenCount(tokens: number): string {
|
||||
}
|
||||
|
||||
// Derived store for formatted display values
|
||||
export const formattedStats = derived(stats, ($stats) => {
|
||||
export const formattedStats = derived([stats, configStore.config], ([$stats, $config]) => {
|
||||
const formatNumber = (num: number) => num.toLocaleString();
|
||||
const formatCost = (cost: number) => `$${cost.toFixed(4)}`;
|
||||
const formatDuration = (seconds: number) => {
|
||||
@@ -164,6 +165,9 @@ export const formattedStats = derived(stats, ($stats) => {
|
||||
}));
|
||||
};
|
||||
|
||||
// Use the model from stats if available, otherwise fall back to the configured model
|
||||
const currentModel = $stats.model ?? $config.model ?? "No model selected";
|
||||
|
||||
return {
|
||||
totalTokens: formatNumber($stats.total_input_tokens + $stats.total_output_tokens),
|
||||
totalInputTokens: formatNumber($stats.total_input_tokens),
|
||||
@@ -173,7 +177,7 @@ export const formattedStats = derived(stats, ($stats) => {
|
||||
sessionInputTokens: formatNumber($stats.session_input_tokens),
|
||||
sessionOutputTokens: formatNumber($stats.session_output_tokens),
|
||||
sessionCost: formatCost($stats.session_cost_usd),
|
||||
model: $stats.model || "No model selected",
|
||||
model: currentModel,
|
||||
|
||||
// New formatted fields
|
||||
messagesTotal: formatNumber($stats.messages_exchanged),
|
||||
|
||||
@@ -373,3 +373,73 @@ export function cleanupTauriListeners() {
|
||||
// Cleanup notification rules
|
||||
cleanupNotificationRules();
|
||||
}
|
||||
|
||||
export async function initializeDiscordRpc() {
|
||||
const config = configStore.getConfig();
|
||||
if (config.discord_rpc_enabled) {
|
||||
try {
|
||||
const startedAt = new Date();
|
||||
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,
|
||||
started_at: startedAtUnixSeconds,
|
||||
});
|
||||
|
||||
await invoke("init_discord_rpc", {
|
||||
sessionName: "Idle",
|
||||
model,
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDiscordRpc(sessionName: string, model: string, startedAt: Date) {
|
||||
const config = configStore.getConfig();
|
||||
if (!config.discord_rpc_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const startedAtUnixSeconds = Math.floor(startedAt.getTime() / 1000);
|
||||
await invoke("update_discord_rpc", {
|
||||
sessionName: sessionName,
|
||||
model,
|
||||
startedAt: startedAtUnixSeconds,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update Discord RPC:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopDiscordRpc() {
|
||||
try {
|
||||
await invoke("stop_discord_rpc");
|
||||
} catch (error) {
|
||||
console.error("Failed to stop Discord RPC:", error);
|
||||
}
|
||||
}
|
||||
|
||||
+29
-1
@@ -2,7 +2,13 @@
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
|
||||
import {
|
||||
initializeTauriListeners,
|
||||
cleanupTauriListeners,
|
||||
initializeDiscordRpc,
|
||||
stopDiscordRpc,
|
||||
updateDiscordRpc,
|
||||
} from "$lib/tauri";
|
||||
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
|
||||
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
@@ -57,6 +63,24 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Get reactive references to conversation stores
|
||||
const activeConversationId = conversationsStore.activeConversationId;
|
||||
const conversations = conversationsStore.conversations;
|
||||
|
||||
// Update Discord RPC when active conversation or model changes
|
||||
$effect(() => {
|
||||
// Access stores directly (without get()) to create reactive dependencies
|
||||
const activeId = $activeConversationId;
|
||||
const convs = $conversations;
|
||||
const activeConv = activeId ? convs.get(activeId) : null;
|
||||
const config = configStore.getConfig();
|
||||
const model = config.model || "claude";
|
||||
|
||||
if (activeConv && config.discord_rpc_enabled) {
|
||||
updateDiscordRpc(activeConv.name, model, activeConv.startedAt);
|
||||
}
|
||||
});
|
||||
|
||||
// Window size constants
|
||||
const COMPACT_WIDTH = 280;
|
||||
const COMPACT_HEIGHT = 400;
|
||||
@@ -356,6 +380,9 @@
|
||||
const window = getCurrentWindow();
|
||||
await window.setSize(new LogicalSize(COMPACT_WIDTH, COMPACT_HEIGHT));
|
||||
}
|
||||
|
||||
// Initialize Discord RPC
|
||||
await initializeDiscordRpc();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -363,6 +390,7 @@
|
||||
if (initialized) {
|
||||
cleanupTauriListeners();
|
||||
cleanupNotificationSync();
|
||||
stopDiscordRpc();
|
||||
window.removeEventListener("keydown", handleGlobalKeydown);
|
||||
initialized = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user