diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8f0016d..85f26b9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -480,10 +480,57 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ + "percent-encoding", "time", "version_check", ] +[[package]] +name = "cookie_store" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "cookie_store" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fc4bff745c9b4c7fb1e97b25d13153da2bc7796260141df62378998d070207f" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -507,7 +554,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -520,7 +567,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -639,6 +686,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "deranged" version = "0.5.5" @@ -745,6 +798,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dpi" version = "0.1.2" @@ -1209,8 +1271,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1220,9 +1284,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1373,6 +1439,25 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1415,11 +1500,13 @@ version = "0.2.0" dependencies = [ "chrono", "parking_lot", + "semver", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-http", "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-os", @@ -1492,6 +1579,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1503,6 +1591,23 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.19" @@ -1522,9 +1627,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1910,6 +2017,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1925,6 +2038,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac" version = "0.1.1" @@ -2810,6 +2929,22 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "quick-xml" version = "0.37.5" @@ -2828,6 +2963,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.43" @@ -3036,22 +3226,32 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "cookie", + "cookie_store 0.22.0", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", + "mime", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3061,6 +3261,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots", ] [[package]] @@ -3087,6 +3288,26 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3109,6 +3330,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3569,6 +3825,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3631,6 +3893,27 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3652,7 +3935,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ "bitflags 2.10.0", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch", @@ -3872,6 +4155,30 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-http" +version = "2.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68bef611ccbfbce67c813959c11b23c1c084d201aa94222de9eba5f9edc3f897" +dependencies = [ + "bytes", + "cookie_store 0.21.1", + "data-url", + "http", + "regex", + "reqwest", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.17", + "tokio", + "url", + "urlpattern", +] + [[package]] name = "tauri-plugin-notification" version = "2.3.3" @@ -4186,6 +4493,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.49.0" @@ -4214,6 +4536,16 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4503,6 +4835,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4706,6 +5044,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webkit2gtk" version = "2.0.1" @@ -4750,6 +5098,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4977,6 +5334,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5022,6 +5390,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5493,6 +5870,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fe46b5a..65c8351 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,7 +25,9 @@ uuid = { version = "1", features = ["v4"] } tauri-plugin-store = "2.4.2" tauri-plugin-notification = "2" tauri-plugin-os = "2" +tauri-plugin-http = "2" tempfile = "3" +semver = "1" chrono = { version = "0.4.43", features = ["serde"] } [target.'cfg(windows)'.dependencies] diff --git a/src-tauri/src/achievements.rs b/src-tauri/src/achievements.rs index 7f54cc1..095df88 100644 --- a/src-tauri/src/achievements.rs +++ b/src-tauri/src/achievements.rs @@ -1,6 +1,6 @@ +use chrono::{DateTime, Datelike, Timelike, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; -use chrono::{DateTime, Utc, Timelike, Datelike}; use tauri_plugin_store::StoreExt; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] @@ -12,9 +12,9 @@ pub enum AchievementId { TokenMaster, // 1,000,000 tokens // Code Generation - HelloWorld, // First code block - CodeWizard, // 100 code blocks - ThousandBlocks, // 1,000 code blocks + HelloWorld, // First code block + CodeWizard, // 100 code blocks + ThousandBlocks, // 1,000 code blocks // File Operations FileManipulator, // 10 files edited @@ -22,23 +22,23 @@ pub enum AchievementId { // Conversation milestones ConversationStarter, // 10 messages - ChattyKathy, // 100 messages - Conversationalist, // 1,000 messages + ChattyKathy, // 100 messages + Conversationalist, // 1,000 messages // Tool usage - Toolsmith, // 5 different tools - ToolMaster, // 10 different tools + Toolsmith, // 5 different tools + ToolMaster, // 10 different tools // Time-based achievements - EarlyBird, // Started session 5-7 AM - NightOwl, // Coding after midnight - AllNighter, // Worked 2-5 AM - WeekendWarrior, // Coding on weekend + EarlyBird, // Started session 5-7 AM + NightOwl, // Coding after midnight + AllNighter, // Worked 2-5 AM + WeekendWarrior, // Coding on weekend DedicatedDeveloper, // 30 days in a row // Search and exploration - Explorer, // 50 searches - MasterSearcher, // 500 searches + Explorer, // 50 searches + MasterSearcher, // 500 searches // Session achievements QuickSession, // Productive session < 5 min @@ -47,36 +47,36 @@ pub enum AchievementId { MarathonSession, // 5+ hour session // Special achievements - FirstMessage, // First message sent - FirstTool, // First tool used - FirstCodeBlock, // First code generated - FirstFileEdit, // First file edit - Polyglot, // 5+ languages in one session - SpeedCoder, // 10 code blocks in 10 minutes + FirstMessage, // First message sent + FirstTool, // First tool used + FirstCodeBlock, // First code generated + FirstFileEdit, // First file edit + Polyglot, // 5+ languages in one session + SpeedCoder, // 10 code blocks in 10 minutes ClaudeConnoisseur, // Used all Claude models - MarathonCoder, // 10k tokens in one session + MarathonCoder, // 10k tokens in one session // Relationship & Greetings - GoodMorning, // Say "good morning" - GoodNight, // Say "good night" or "goodnight" - ThankYou, // Say "thank you" or "thanks" - LoveYou, // Say "love you" or "ily" + GoodMorning, // Say "good morning" + GoodNight, // Say "good night" or "goodnight" + ThankYou, // Say "thank you" or "thanks" + LoveYou, // Say "love you" or "ily" // Personality & Fun - EmojiUser, // Use an emoji in a message - QuestionMaster, // Use "?" in 20 messages - CapsLock, // Send a message in ALL CAPS + EmojiUser, // Use an emoji in a message + QuestionMaster, // Use "?" in 20 messages + CapsLock, // Send a message in ALL CAPS PleaseAndThankYou, // Use "please" in messages // Git & Development - GitGuru, // Use git commands 10 times - TestWriter, // Create test files - Debugger, // Fix bugs (messages with "fix", "bug", "error") + GitGuru, // Use git commands 10 times + TestWriter, // Create test files + Debugger, // Fix bugs (messages with "fix", "bug", "error") // Tool Mastery - BashMaster, // Use Bash tool 50 times - FileExplorer, // Use Read tool 100 times - SearchExpert, // Use Grep tool 50 times + BashMaster, // Use Bash tool 50 times + FileExplorer, // Use Read tool 100 times + SearchExpert, // Use Grep tool 50 times } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -509,15 +509,20 @@ pub fn check_message_achievements( newly_unlocked.push(AchievementId::GoodMorning); } if (message_lower.contains("good night") || message_lower.contains("goodnight")) - && progress.unlock(AchievementId::GoodNight) { + && progress.unlock(AchievementId::GoodNight) + { newly_unlocked.push(AchievementId::GoodNight); } - if (message_lower.contains("thank you") || message_lower.contains("thanks") || message_lower.contains("thx")) - && progress.unlock(AchievementId::ThankYou) { + if (message_lower.contains("thank you") + || message_lower.contains("thanks") + || message_lower.contains("thx")) + && progress.unlock(AchievementId::ThankYou) + { newly_unlocked.push(AchievementId::ThankYou); } if (message_lower.contains("love you") || message_lower.contains("ily")) - && progress.unlock(AchievementId::LoveYou) { + && progress.unlock(AchievementId::LoveYou) + { newly_unlocked.push(AchievementId::LoveYou); } @@ -525,9 +530,11 @@ pub fn check_message_achievements( if message.chars().any(|c| c as u32 >= 0x1F300) && progress.unlock(AchievementId::EmojiUser) { newly_unlocked.push(AchievementId::EmojiUser); } - if message == message.to_uppercase() && message.len() > 5 + if message == message.to_uppercase() + && message.len() > 5 && message.chars().any(|c| c.is_alphabetic()) - && progress.unlock(AchievementId::CapsLock) { + && progress.unlock(AchievementId::CapsLock) + { newly_unlocked.push(AchievementId::CapsLock); } if message_lower.contains("please") && progress.unlock(AchievementId::PleaseAndThankYou) { @@ -535,8 +542,11 @@ pub fn check_message_achievements( } // Git & Development patterns in messages - if (message_lower.contains("fix") || message_lower.contains("bug") || message_lower.contains("error")) - && progress.unlock(AchievementId::Debugger) { + if (message_lower.contains("fix") + || message_lower.contains("bug") + || message_lower.contains("error")) + && progress.unlock(AchievementId::Debugger) + { newly_unlocked.push(AchievementId::Debugger); } @@ -550,10 +560,12 @@ pub fn check_achievements( ) -> Vec { let mut newly_unlocked = Vec::new(); - println!("Checking achievements with stats: messages={}, tokens={}, code_blocks={}", + println!( + "Checking achievements with stats: messages={}, tokens={}, code_blocks={}", stats.messages_exchanged, stats.total_input_tokens + stats.total_output_tokens, - stats.code_blocks_generated); + stats.code_blocks_generated + ); println!("Currently unlocked: {:?}", progress.unlocked); // Token milestones @@ -617,7 +629,8 @@ pub fn check_achievements( // Search and exploration let search_tools = ["Glob", "Grep", "search", "Task"]; - let search_count: u64 = search_tools.iter() + let search_count: u64 = search_tools + .iter() .filter_map(|tool| stats.tools_usage.get(*tool)) .sum(); if search_count >= 50 && progress.unlock(AchievementId::Explorer) { @@ -629,7 +642,10 @@ pub fn check_achievements( // Session duration achievements let session_secs = stats.session_duration_seconds; - if session_secs < 300 && stats.session_messages_exchanged >= 5 && progress.unlock(AchievementId::QuickSession) { + if session_secs < 300 + && stats.session_messages_exchanged >= 5 + && progress.unlock(AchievementId::QuickSession) + { newly_unlocked.push(AchievementId::QuickSession); } if session_secs >= 1800 && progress.unlock(AchievementId::FocusedWork) { @@ -716,7 +732,9 @@ pub fn check_achievements( // Weekend warrior use chrono::Weekday; - if (weekday == Weekday::Sat || weekday == Weekday::Sun) && progress.unlock(AchievementId::WeekendWarrior) { + if (weekday == Weekday::Sat || weekday == Weekday::Sun) + && progress.unlock(AchievementId::WeekendWarrior) + { newly_unlocked.push(AchievementId::WeekendWarrior); } } @@ -733,16 +751,21 @@ pub struct AchievementUnlockedEvent { } // Save achievements to persistent store -pub async fn save_achievements(app: &tauri::AppHandle, progress: &AchievementProgress) -> Result<(), String> { - let store = app.store("achievements.json") - .map_err(|e| e.to_string())?; +pub async fn save_achievements( + app: &tauri::AppHandle, + progress: &AchievementProgress, +) -> Result<(), String> { + let store = app.store("achievements.json").map_err(|e| e.to_string())?; // Create a serializable version with just the unlocked achievement IDs let unlocked_list: Vec = progress.unlocked.iter().cloned().collect(); println!("Saving achievements: {:?}", unlocked_list); - store.set("unlocked", serde_json::to_value(unlocked_list).map_err(|e| e.to_string())?); + store.set( + "unlocked", + serde_json::to_value(unlocked_list).map_err(|e| e.to_string())?, + ); store.save().map_err(|e| e.to_string())?; println!("Achievements saved successfully"); @@ -766,7 +789,9 @@ pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress { // Get unlocked achievements if let Some(unlocked_value) = store.get("unlocked") { println!("Found unlocked value in store: {:?}", unlocked_value); - if let Ok(unlocked_list) = serde_json::from_value::>(unlocked_value.clone()) { + if let Ok(unlocked_list) = + serde_json::from_value::>(unlocked_value.clone()) + { println!("Loaded {} achievements", unlocked_list.len()); for achievement_id in unlocked_list { progress.unlocked.insert(achievement_id); @@ -805,4 +830,4 @@ mod tests { let newly = progress.take_newly_unlocked(); assert!(newly.is_empty()); } -} \ No newline at end of file +} diff --git a/src-tauri/src/bridge_manager.rs b/src-tauri/src/bridge_manager.rs index 6dd95d3..4a2370b 100644 --- a/src-tauri/src/bridge_manager.rs +++ b/src-tauri/src/bridge_manager.rs @@ -29,30 +29,40 @@ impl BridgeManager { conversation_id: &str, options: ClaudeStartOptions, ) -> Result<(), String> { - // Check if a bridge already exists for this conversation - if self.bridges.get(conversation_id).map(|b| b.is_running()).unwrap_or(false) { + // Check if a bridge already exists and is running for this conversation + if self + .bridges + .get(conversation_id) + .map(|b| b.is_running()) + .unwrap_or(false) + { return Err("Claude is already running for this conversation".to_string()); } - let app = self.app_handle.as_ref() + let app = self + .app_handle + .as_ref() .ok_or_else(|| "App handle not set".to_string())? .clone(); - // Create a new bridge for this conversation - let mut bridge = WslBridge::new_with_conversation_id(conversation_id.to_string()); + // Reuse existing bridge if it exists (preserves stats across reconnects) + // Only create a new bridge if one doesn't exist for this conversation + let bridge = self + .bridges + .entry(conversation_id.to_string()) + .or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string())); // Start the Claude process bridge.start(app, options)?; - // Store the bridge - self.bridges.insert(conversation_id.to_string(), bridge); - Ok(()) } pub fn stop_claude(&mut self, conversation_id: &str) -> Result<(), String> { if let Some(bridge) = self.bridges.get_mut(conversation_id) { - let app = self.app_handle.as_ref() + let app = self + .app_handle + .as_ref() .ok_or_else(|| "App handle not set".to_string())?; bridge.stop(app); Ok(()) @@ -63,7 +73,9 @@ impl BridgeManager { pub fn interrupt_claude(&mut self, conversation_id: &str) -> Result<(), String> { if let Some(bridge) = self.bridges.get_mut(conversation_id) { - let app = self.app_handle.as_ref() + let app = self + .app_handle + .as_ref() .ok_or_else(|| "App handle not set".to_string())?; bridge.interrupt(app) } else { @@ -79,7 +91,12 @@ impl BridgeManager { } } - pub fn send_tool_result(&mut self, conversation_id: &str, tool_use_id: &str, result: serde_json::Value) -> Result<(), String> { + pub fn send_tool_result( + &mut self, + conversation_id: &str, + tool_use_id: &str, + result: serde_json::Value, + ) -> Result<(), String> { if let Some(bridge) = self.bridges.get_mut(conversation_id) { bridge.send_tool_result(tool_use_id, result) } else { @@ -88,19 +105,22 @@ impl BridgeManager { } pub fn is_claude_running(&self, conversation_id: &str) -> bool { - self.bridges.get(conversation_id) + self.bridges + .get(conversation_id) .map(|b| b.is_running()) .unwrap_or(false) } pub fn get_working_directory(&self, conversation_id: &str) -> Result { - self.bridges.get(conversation_id) + self.bridges + .get(conversation_id) .map(|b| b.get_working_directory().to_string()) .ok_or_else(|| "No Claude instance found for this conversation".to_string()) } pub fn get_usage_stats(&self, conversation_id: &str) -> Result { - self.bridges.get(conversation_id) + self.bridges + .get(conversation_id) .map(|b| b.get_stats()) .ok_or_else(|| "No Claude instance found for this conversation".to_string()) } @@ -123,8 +143,14 @@ impl BridgeManager { #[allow(dead_code)] pub fn get_active_conversations(&self) -> Vec { - self.bridges.keys() - .filter(|id| self.bridges.get(*id).map(|b| b.is_running()).unwrap_or(false)) + self.bridges + .keys() + .filter(|id| { + self.bridges + .get(*id) + .map(|b| b.is_running()) + .unwrap_or(false) + }) .cloned() .collect() } @@ -140,4 +166,4 @@ pub type SharedBridgeManager = Arc>; pub fn create_shared_bridge_manager() -> SharedBridgeManager { Arc::new(Mutex::new(BridgeManager::new())) -} \ No newline at end of file +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 43677ef..2b3ce85 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,10 +1,11 @@ use tauri::{AppHandle, State}; +use tauri_plugin_http::reqwest; use tauri_plugin_store::StoreExt; +use crate::achievements::{get_achievement_info, load_achievements, AchievementUnlockedEvent}; +use crate::bridge_manager::SharedBridgeManager; use crate::config::{ClaudeStartOptions, HikariConfig}; use crate::stats::UsageStats; -use crate::bridge_manager::SharedBridgeManager; -use crate::achievements::{load_achievements, get_achievement_info, AchievementUnlockedEvent}; const CONFIG_STORE_KEY: &str = "config"; @@ -71,23 +72,17 @@ pub async fn select_wsl_directory() -> Result { #[tauri::command] pub async fn get_config(app: AppHandle) -> Result { - let store = app - .store("hikari-config.json") - .map_err(|e| e.to_string())?; + let store = app.store("hikari-config.json").map_err(|e| e.to_string())?; match store.get(CONFIG_STORE_KEY) { - Some(value) => { - serde_json::from_value(value.clone()).map_err(|e| e.to_string()) - } + Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()), None => Ok(HikariConfig::default()), } } #[tauri::command] pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), String> { - let store = app - .store("hikari-config.json") - .map_err(|e| e.to_string())?; + let store = app.store("hikari-config.json").map_err(|e| e.to_string())?; let value = serde_json::to_value(&config).map_err(|e| e.to_string())?; store.set(CONFIG_STORE_KEY, value); @@ -106,7 +101,10 @@ pub async fn get_usage_stats( } #[tauri::command] -pub async fn validate_directory(path: String, current_dir: Option) -> Result { +pub async fn validate_directory( + path: String, + current_dir: Option, +) -> Result { use std::path::Path; let path = Path::new(&path); @@ -136,11 +134,17 @@ pub async fn validate_directory(path: String, current_dir: Option) -> Re // Check if the path exists and is a directory if !expanded_path.exists() { - return Err(format!("Directory does not exist: {}", expanded_path.display())); + 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 Err(format!( + "Path is not a directory: {}", + expanded_path.display() + )); } // Return the canonicalized (absolute) path @@ -151,7 +155,9 @@ pub async fn validate_directory(path: String, current_dir: Option) -> Re } #[tauri::command] -pub async fn load_saved_achievements(app: AppHandle) -> Result, String> { +pub async fn load_saved_achievements( + app: AppHandle, +) -> Result, String> { use chrono::Utc; // Load achievements from persistent store @@ -162,9 +168,7 @@ pub async fn load_saved_achievements(app: AppHandle) -> Result Result, String> { + 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 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))?; + + for entry in entries { + 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() { + if let Some(name) = path.file_name() { + skills.push(name.to_string_lossy().to_string()); + } + } + } + } + + // Sort alphabetically + skills.sort(); + + Ok(skills) +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct UpdateInfo { + pub current_version: String, + pub latest_version: String, + pub has_update: bool, + pub release_url: String, + pub release_notes: Option, +} + +#[derive(Debug, serde::Deserialize)] +struct GiteaRelease { + tag_name: String, + html_url: String, + body: Option, + prerelease: bool, +} + +#[tauri::command] +pub async fn check_for_updates() -> Result { + const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + const RELEASES_API: &str = + "https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/hikari-desktop/releases"; + + // Fetch releases from Gitea API + let client = reqwest::Client::new(); + let response = client + .get(RELEASES_API) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| format!("Failed to fetch releases: {}", e))?; + + if !response.status().is_success() { + return Err(format!("API returned status: {}", response.status())); + } + + let text = response + .text() + .await + .map_err(|e| format!("Failed to read response: {}", e))?; + + let releases: Vec = + serde_json::from_str(&text).map_err(|e| format!("Failed to parse releases: {}", e))?; + + // Find the latest non-prerelease, or fall back to latest prerelease + let latest = releases + .iter() + .find(|r| !r.prerelease) + .or_else(|| releases.first()); + + let latest = match latest { + Some(r) => r, + None => return Err("No releases found".to_string()), + }; + + // Parse version strings (remove 'v' prefix if present) + let current = semver::Version::parse(CURRENT_VERSION) + .map_err(|e| format!("Failed to parse current version: {}", e))?; + + let latest_tag = latest.tag_name.trim_start_matches('v'); + let latest_ver = semver::Version::parse(latest_tag) + .map_err(|e| format!("Failed to parse latest version: {}", e))?; + + Ok(UpdateInfo { + current_version: CURRENT_VERSION.to_string(), + latest_version: latest.tag_name.clone(), + has_update: latest_ver > current, + release_url: latest.html_url.clone(), + release_notes: latest.body.clone(), + }) +} diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 5ab84fc..d6f6fae 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -61,6 +61,15 @@ pub struct HikariConfig { #[serde(default)] pub always_on_top: bool, + + #[serde(default = "default_update_checks_enabled")] + pub update_checks_enabled: bool, + + #[serde(default)] + pub character_panel_width: Option, + + #[serde(default = "default_font_size")] + pub font_size: u32, } impl Default for HikariConfig { @@ -77,10 +86,17 @@ impl Default for HikariConfig { notifications_enabled: true, notification_volume: 0.7, always_on_top: false, + update_checks_enabled: true, + character_panel_width: None, + font_size: 14, } } } +fn default_update_checks_enabled() -> bool { + true +} + fn default_greeting_enabled() -> bool { true } @@ -93,6 +109,10 @@ fn default_notification_volume() -> f32 { 0.7 } +fn default_font_size() -> u32 { + 14 +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[serde(rename_all = "lowercase")] pub enum Theme { @@ -117,6 +137,9 @@ mod tests { assert!(config.greeting_enabled); assert!(config.greeting_custom_prompt.is_none()); assert!(!config.always_on_top); + assert!(config.update_checks_enabled); + assert!(config.character_panel_width.is_none()); + assert_eq!(config.font_size, 14); } #[test] @@ -133,6 +156,9 @@ mod tests { notifications_enabled: true, notification_volume: 0.7, always_on_top: true, + update_checks_enabled: true, + character_panel_width: Some(400), + font_size: 16, }; let json = serde_json::to_string(&config).unwrap(); @@ -143,7 +169,10 @@ mod tests { assert_eq!(deserialized.auto_granted_tools, config.auto_granted_tools); assert_eq!(deserialized.theme, Theme::Light); assert!(deserialized.greeting_enabled); - assert_eq!(deserialized.greeting_custom_prompt, Some("Hello!".to_string())); + assert_eq!( + deserialized.greeting_custom_prompt, + Some("Hello!".to_string()) + ); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5655334..685d3da 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,18 +5,18 @@ mod config; mod notifications; mod stats; mod types; -mod wsl_bridge; -mod wsl_notifications; mod vbs_notification; mod windows_toast; +mod wsl_bridge; +mod wsl_notifications; -use commands::*; -use notifications::*; use bridge_manager::create_shared_bridge_manager; use commands::load_saved_achievements; -use wsl_notifications::*; +use commands::*; +use notifications::*; use vbs_notification::*; use windows_toast::*; +use wsl_notifications::*; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -29,6 +29,7 @@ pub fn run() { .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_http::init()) .manage(bridge_manager.clone()) .setup(move |app| { // Initialize the app handle in the bridge manager @@ -55,6 +56,8 @@ pub fn run() { send_wsl_notification, send_vbs_notification, validate_directory, + list_skills, + check_for_updates, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs index d0f421c..6d0ed2e 100644 --- a/src-tauri/src/notifications.rs +++ b/src-tauri/src/notifications.rs @@ -1,5 +1,5 @@ -use tauri::command; use std::process::Command; +use tauri::command; #[command] pub async fn send_notify_send(title: String, body: String) -> Result<(), String> { @@ -10,7 +10,12 @@ pub async fn send_notify_send(title: String, body: String) -> Result<(), String> .arg("--urgency=normal") .arg("--app-name=Hikari Desktop") .output() - .map_err(|e| format!("Failed to execute notify-send: {}. Make sure libnotify-bin is installed.", e))?; + .map_err(|e| { + format!( + "Failed to execute notify-send: {}. Make sure libnotify-bin is installed.", + e + ) + })?; if !output.status.success() { let error = String::from_utf8_lossy(&output.stderr); @@ -93,4 +98,4 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(), .map_err(|e| format!("Failed to send message: {}", e))?; Ok(()) -} \ No newline at end of file +} diff --git a/src-tauri/src/stats.rs b/src-tauri/src/stats.rs index d174bd0..d04d7a4 100644 --- a/src-tauri/src/stats.rs +++ b/src-tauri/src/stats.rs @@ -1,7 +1,7 @@ +use crate::achievements::{check_achievements, AchievementProgress}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::time::Instant; -use crate::achievements::{AchievementProgress, check_achievements}; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct UsageStats { @@ -89,7 +89,10 @@ impl UsageStats { pub fn increment_tool_usage(&mut self, tool_name: &str) { *self.tools_usage.entry(tool_name.to_string()).or_insert(0) += 1; - *self.session_tools_usage.entry(tool_name.to_string()).or_insert(0) += 1; + *self + .session_tools_usage + .entry(tool_name.to_string()) + .or_insert(0) += 1; } pub fn get_session_duration(&mut self) -> u64 { @@ -213,4 +216,4 @@ mod tests { assert_eq!(stats.session_cost_usd, 0.0); assert!(stats.total_cost_usd > 0.0); } -} \ No newline at end of file +} diff --git a/src-tauri/src/vbs_notification.rs b/src-tauri/src/vbs_notification.rs index 3667a6e..f100d91 100644 --- a/src-tauri/src/vbs_notification.rs +++ b/src-tauri/src/vbs_notification.rs @@ -1,7 +1,7 @@ -use std::process::Command; use std::io::Write; -use tempfile::NamedTempFile; +use std::process::Command; use tauri::command; +use tempfile::NamedTempFile; #[command] pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> { @@ -17,8 +17,8 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64 ); // Create a temporary VBS file - let mut temp_file = NamedTempFile::new() - .map_err(|e| format!("Failed to create temp file: {}", e))?; + let mut temp_file = + NamedTempFile::new().map_err(|e| format!("Failed to create temp file: {}", e))?; temp_file .write_all(vbs_content.as_bytes()) @@ -40,10 +40,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64 } else if temp_path.starts_with("/tmp/") { // WSL temp files might be in a different location // Try to use wslpath to convert - let output = Command::new("wslpath") - .arg("-w") - .arg(&temp_path) - .output(); + let output = Command::new("wslpath").arg("-w").arg(&temp_path).output(); if let Ok(result) = output { if result.status.success() { @@ -71,4 +68,4 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64 } Ok(()) -} \ No newline at end of file +} diff --git a/src-tauri/src/windows_toast.rs b/src-tauri/src/windows_toast.rs index 8bf3e48..fd75676 100644 --- a/src-tauri/src/windows_toast.rs +++ b/src-tauri/src/windows_toast.rs @@ -2,7 +2,7 @@ use tauri::command; #[cfg(target_os = "windows")] use windows::{ - core::{HSTRING, Result as WindowsResult}, + core::{Result as WindowsResult, HSTRING}, Data::Xml::Dom::*, UI::Notifications::*, }; @@ -38,7 +38,8 @@ fn show_toast_notification(title: &str, body: &str) -> WindowsResult<()> { let toast = ToastNotification::CreateToastNotification(&xml_doc)?; // Create a toast notifier with an application ID - let notifier = ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from("Hikari Desktop"))?; + let notifier = + ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from("Hikari Desktop"))?; // Show the notification notifier.Show(&toast)?; @@ -60,4 +61,4 @@ fn escape_xml(text: &str) -> String { #[command] pub async fn send_windows_toast(_title: String, _body: String) -> Result<(), String> { Err("Windows toast notifications are only available on Windows".to_string()) -} \ No newline at end of file +} diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 899366d..e0a6fcd 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -8,11 +8,15 @@ use tempfile::NamedTempFile; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; -use crate::config::ClaudeStartOptions; -use crate::stats::{UsageStats, StatsUpdateEvent}; -use parking_lot::RwLock; -use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent, ConnectionEvent, SessionEvent, WorkingDirectoryEvent, UserQuestionEvent, QuestionOption}; use crate::achievements::{get_achievement_info, AchievementUnlockedEvent}; +use crate::config::ClaudeStartOptions; +use crate::stats::{StatsUpdateEvent, UsageStats}; +use crate::types::{ + CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, OutputEvent, + PermissionPromptEvent, QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent, + WorkingDirectoryEvent, +}; +use parking_lot::RwLock; const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"]; const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"]; @@ -103,7 +107,6 @@ impl WslBridge { } } - pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> { if self.process.is_some() { return Err("Process already running".to_string()); @@ -115,14 +118,21 @@ impl WslBridge { tauri::async_runtime::spawn(async move { println!("Loading saved achievements..."); let achievements = crate::achievements::load_achievements(&app_clone).await; - println!("Loaded {} unlocked achievements", achievements.unlocked.len()); + println!( + "Loaded {} unlocked achievements", + achievements.unlocked.len() + ); stats.write().achievements = achievements; }); let working_dir = &options.working_dir; self.working_directory = working_dir.clone(); - emit_connection_status(&app, ConnectionStatus::Connecting, self.conversation_id.clone()); + emit_connection_status( + &app, + ConnectionStatus::Connecting, + self.conversation_id.clone(), + ); // Create temp file for MCP config if provided let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json { @@ -158,16 +168,19 @@ impl WslBridge { let mut command = if is_wsl { // Running inside WSL - call claude directly // Try to find claude in common locations since GUI apps may not inherit shell PATH - let claude_path = find_claude_binary() - .ok_or_else(|| "Could not find claude binary. Is Claude Code installed?".to_string())?; + let claude_path = find_claude_binary().ok_or_else(|| { + "Could not find claude binary. Is Claude Code installed?".to_string() + })?; eprintln!("[DEBUG] Found claude at: {}", claude_path); eprintln!("[DEBUG] Working dir: {}", working_dir); let mut cmd = Command::new(&claude_path); cmd.args([ - "--output-format", "stream-json", - "--input-format", "stream-json", + "--output-format", + "stream-json", + "--input-format", + "stream-json", "--verbose", ]); @@ -218,10 +231,7 @@ impl WslBridge { let mut cmd = Command::new("wsl"); // Build the claude command with all arguments - let mut claude_cmd = format!( - "cd '{}' && ", - working_dir - ); + let mut claude_cmd = format!("cd '{}' && ", working_dir); // Set API key as environment variable if specified if let Some(ref api_key) = options.api_key { @@ -230,7 +240,9 @@ impl WslBridge { } } - claude_cmd.push_str("claude --output-format stream-json --input-format stream-json --verbose"); + claude_cmd.push_str( + "claude --output-format stream-json --input-format stream-json --verbose", + ); // Add model if specified if let Some(ref model) = options.model { @@ -292,8 +304,8 @@ impl WslBridge { self.stdin = stdin; self.process = Some(child); - // Reset session stats when starting new session - self.stats.write().reset_session(); + // Note: We no longer reset stats here - stats persist across reconnects + // Stats are only reset when explicitly disconnecting via stop() // Load saved achievements let app_handle = app.clone(); @@ -320,7 +332,11 @@ impl WslBridge { }); } - emit_connection_status(&app, ConnectionStatus::Connected, self.conversation_id.clone()); + emit_connection_status( + &app, + ConnectionStatus::Connected, + self.conversation_id.clone(), + ); Ok(()) } @@ -345,12 +361,18 @@ impl WslBridge { .write_all(format!("{}\n", json_line).as_bytes()) .map_err(|e| format!("Failed to write to stdin: {}", e))?; - stdin.flush().map_err(|e| format!("Failed to flush stdin: {}", e))?; + stdin + .flush() + .map_err(|e| format!("Failed to flush stdin: {}", e))?; Ok(()) } - pub fn send_tool_result(&mut self, tool_use_id: &str, result: serde_json::Value) -> Result<(), String> { + pub fn send_tool_result( + &mut self, + tool_use_id: &str, + result: serde_json::Value, + ) -> Result<(), String> { let stdin = self.stdin.as_mut().ok_or("Process not running")?; // The content should be a JSON string representation of the result @@ -374,7 +396,9 @@ impl WslBridge { .write_all(format!("{}\n", json_line).as_bytes()) .map_err(|e| format!("Failed to write to stdin: {}", e))?; - stdin.flush().map_err(|e| format!("Failed to flush stdin: {}", e))?; + stdin + .flush() + .map_err(|e| format!("Failed to flush stdin: {}", e))?; Ok(()) } @@ -395,7 +419,11 @@ impl WslBridge { // The user will see what session was interrupted // Emit disconnected status - emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone()); + emit_connection_status( + app, + ConnectionStatus::Disconnected, + self.conversation_id.clone(), + ); Ok(()) } else { @@ -411,7 +439,15 @@ impl WslBridge { self.stdin = None; self.session_id = None; self.mcp_config_file = None; // Temp file is automatically deleted when dropped - emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone()); + + // Reset session stats on explicit disconnect + self.stats.write().reset_session(); + + emit_connection_status( + app, + ConnectionStatus::Disconnected, + self.conversation_id.clone(), + ); } pub fn is_running(&self) -> bool { @@ -425,7 +461,6 @@ impl WslBridge { pub fn get_stats(&self) -> UsageStats { self.stats.read().clone() } - } impl Default for WslBridge { @@ -434,7 +469,12 @@ impl Default for WslBridge { } } -fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc>, conversation_id: Option) { +fn handle_stdout( + stdout: std::process::ChildStdout, + app: AppHandle, + stats: Arc>, + conversation_id: Option, +) { let reader = BufReader::new(stdout); for line in reader.lines() { @@ -455,18 +495,25 @@ fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc) { +fn handle_stderr( + stderr: std::process::ChildStderr, + app: AppHandle, + conversation_id: Option, +) { let reader = BufReader::new(stderr); for line in reader.lines() { match line { Ok(line) if !line.is_empty() => { - let _ = app.emit("claude:output", OutputEvent { - line_type: "error".to_string(), - content: line, - tool_name: None, - conversation_id: conversation_id.clone(), - }); + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "error".to_string(), + content: line, + tool_name: None, + conversation_id: conversation_id.clone(), + }, + ); } Err(_) => break, _ => {} @@ -474,24 +521,40 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle, conversation } } -fn process_json_line(line: &str, app: &AppHandle, stats: &Arc>, conversation_id: &Option) -> Result<(), String> { +fn process_json_line( + line: &str, + app: &AppHandle, + stats: &Arc>, + conversation_id: &Option, +) -> Result<(), String> { let message: ClaudeMessage = serde_json::from_str(line) .map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?; match &message { - ClaudeMessage::System { subtype, session_id, cwd, .. } => { + ClaudeMessage::System { + subtype, + session_id, + cwd, + .. + } => { if subtype == "init" { if let Some(id) = session_id { - let _ = app.emit("claude:session", SessionEvent { - session_id: id.clone(), - conversation_id: conversation_id.clone(), - }); + let _ = app.emit( + "claude:session", + SessionEvent { + session_id: id.clone(), + conversation_id: conversation_id.clone(), + }, + ); } if let Some(dir) = cwd { - let _ = app.emit("claude:cwd", WorkingDirectoryEvent { - directory: dir.clone(), - conversation_id: conversation_id.clone(), - }); + let _ = app.emit( + "claude:cwd", + WorkingDirectoryEvent { + directory: dir.clone(), + conversation_id: conversation_id.clone(), + }, + ); } emit_state_change(app, CharacterState::Idle, None, conversation_id.clone()); } @@ -543,12 +606,15 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc } let desc = format_tool_description(name, input); - let _ = app.emit("claude:output", OutputEvent { - line_type: "tool".to_string(), - content: desc, - tool_name: Some(name.clone()), - conversation_id: conversation_id.clone(), - }); + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "tool".to_string(), + content: desc, + tool_name: Some(name.clone()), + conversation_id: conversation_id.clone(), + }, + ); } ContentBlock::Text { text } => { // Count code blocks in the text @@ -557,21 +623,27 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc stats.write().increment_code_blocks(); } - let _ = app.emit("claude:output", OutputEvent { - line_type: "assistant".to_string(), - content: text.clone(), - tool_name: None, - conversation_id: conversation_id.clone(), - }); + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "assistant".to_string(), + content: text.clone(), + tool_name: None, + conversation_id: conversation_id.clone(), + }, + ); } ContentBlock::Thinking { thinking } => { state = CharacterState::Thinking; - let _ = app.emit("claude:output", OutputEvent { - line_type: "system".to_string(), - content: format!("[Thinking] {}", thinking), - tool_name: None, - conversation_id: conversation_id.clone(), - }); + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "system".to_string(), + content: format!("[Thinking] {}", thinking), + tool_name: None, + conversation_id: conversation_id.clone(), + }, + ); } _ => {} } @@ -606,7 +678,13 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc } } - ClaudeMessage::Result { subtype, result, permission_denials, usage: _, .. } => { + ClaudeMessage::Result { + subtype, + result, + permission_denials, + usage: _, + .. + } => { let state = if subtype == "success" { CharacterState::Success } else { @@ -627,9 +705,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc // Emit achievement events for any newly unlocked achievements for achievement_id in &newly_unlocked { let info = get_achievement_info(achievement_id); - let _ = app.emit("achievement:unlocked", AchievementUnlockedEvent { - achievement: info, - }); + let _ = app.emit( + "achievement:unlocked", + AchievementUnlockedEvent { achievement: info }, + ); } // Save achievements after unlocking new ones @@ -641,7 +720,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc // Use Tauri's async runtime instead of tokio::spawn tauri::async_runtime::spawn(async move { println!("Spawned save task for achievements"); - if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress).await { + if let Err(e) = + crate::achievements::save_achievements(&app_handle, &achievements_progress) + .await + { eprintln!("Failed to save achievements: {}", e); } else { println!("Achievement save task completed successfully"); @@ -658,12 +740,15 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc // Only emit error results - success content is already sent via Assistant message if subtype != "success" { if let Some(text) = result { - let _ = app.emit("claude:output", OutputEvent { - line_type: "error".to_string(), - content: text.clone(), - tool_name: None, - conversation_id: conversation_id.clone(), - }); + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "error".to_string(), + content: text.clone(), + tool_name: None, + conversation_id: conversation_id.clone(), + }, + ); } } @@ -674,64 +759,88 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc for denial in denials { // Special handling for AskUserQuestion tool if denial.tool_name == "AskUserQuestion" { - if let Some(questions) = denial.tool_input.get("questions").and_then(|q| q.as_array()) { + if let Some(questions) = denial + .tool_input + .get("questions") + .and_then(|q| q.as_array()) + { // For now, handle the first question (most common case) if let Some(first_question) = questions.first() { - let question_text = first_question.get("question") + let question_text = first_question + .get("question") .and_then(|q| q.as_str()) .unwrap_or("Claude has a question for you") .to_string(); - let header = first_question.get("header") + let header = first_question + .get("header") .and_then(|h| h.as_str()) .map(|s| s.to_string()); - let multi_select = first_question.get("multiSelect") + let multi_select = first_question + .get("multiSelect") .and_then(|m| m.as_bool()) .unwrap_or(false); - let options: Vec = first_question.get("options") + let options: Vec = first_question + .get("options") .and_then(|opts| opts.as_array()) .map(|opts| { - opts.iter().filter_map(|opt| { - let label = opt.get("label").and_then(|l| l.as_str())?; - let description = opt.get("description") - .and_then(|d| d.as_str()) - .map(|s| s.to_string()); - Some(QuestionOption { - label: label.to_string(), - description, + opts.iter() + .filter_map(|opt| { + let label = + opt.get("label").and_then(|l| l.as_str())?; + let description = opt + .get("description") + .and_then(|d| d.as_str()) + .map(|s| s.to_string()); + Some(QuestionOption { + label: label.to_string(), + description, + }) }) - }).collect() + .collect() }) .unwrap_or_default(); - let _ = app.emit("claude:question", UserQuestionEvent { - id: denial.tool_use_id.clone(), - question: question_text, - header, - options, - multi_select, - conversation_id: conversation_id.clone(), - }); + let _ = app.emit( + "claude:question", + UserQuestionEvent { + id: denial.tool_use_id.clone(), + question: question_text, + header, + options, + multi_select, + conversation_id: conversation_id.clone(), + }, + ); } } } else { has_regular_denials = true; - let description = format_tool_description(&denial.tool_name, &denial.tool_input); - let _ = app.emit("claude:permission", PermissionPromptEvent { - id: denial.tool_use_id.clone(), - tool_name: denial.tool_name.clone(), - tool_input: denial.tool_input.clone(), - description, - conversation_id: conversation_id.clone(), - }); + let description = + format_tool_description(&denial.tool_name, &denial.tool_input); + let _ = app.emit( + "claude:permission", + PermissionPromptEvent { + id: denial.tool_use_id.clone(), + tool_name: denial.tool_name.clone(), + tool_input: denial.tool_input.clone(), + description, + conversation_id: conversation_id.clone(), + }, + ); } } // Show permission state if there were any denials (questions or regular) if has_regular_denials || !denials.is_empty() { - emit_state_change(app, CharacterState::Permission, None, conversation_id.clone()); + emit_state_change( + app, + CharacterState::Permission, + None, + conversation_id.clone(), + ); return Ok(()); } } @@ -744,7 +853,9 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc stats.write().increment_messages(); // Extract text content from the message - let message_text = message.content.iter() + let message_text = message + .content + .iter() .filter_map(|block| match block { crate::types::ContentBlock::Text { text } => Some(text.clone()), _ => None, @@ -774,9 +885,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc for achievement_id in &newly_unlocked { println!("User message unlocked achievement: {:?}", achievement_id); let info = get_achievement_info(achievement_id); - let _ = app.emit("achievement:unlocked", AchievementUnlockedEvent { - achievement: info, - }); + let _ = app.emit( + "achievement:unlocked", + AchievementUnlockedEvent { achievement: info }, + ); } // Save achievements after unlocking new ones @@ -785,7 +897,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc let app_handle = app.clone(); let achievements_progress = stats.read().achievements.clone(); tauri::async_runtime::spawn(async move { - if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress).await { + if let Err(e) = + crate::achievements::save_achievements(&app_handle, &achievements_progress) + .await + { eprintln!("Failed to save achievements: {}", e); } else { println!("Achievements saved after user message"); @@ -860,15 +975,36 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String { } } -fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option, conversation_id: Option) { - let _ = app.emit("claude:state", StateChangeEvent { state, tool_name, conversation_id }); +fn emit_state_change( + app: &AppHandle, + state: CharacterState, + tool_name: Option, + conversation_id: Option, +) { + let _ = app.emit( + "claude:state", + StateChangeEvent { + state, + tool_name, + conversation_id, + }, + ); } -fn emit_connection_status(app: &AppHandle, status: ConnectionStatus, conversation_id: Option) { - let _ = app.emit("claude:connection", ConnectionEvent { status, conversation_id }); +fn emit_connection_status( + app: &AppHandle, + status: ConnectionStatus, + conversation_id: Option, +) { + let _ = app.emit( + "claude:connection", + ConnectionEvent { + status, + conversation_id, + }, + ); } - #[cfg(test)] mod tests { use super::*; @@ -878,21 +1014,36 @@ mod tests { assert!(matches!(get_tool_state("Read"), CharacterState::Searching)); assert!(matches!(get_tool_state("Glob"), CharacterState::Searching)); assert!(matches!(get_tool_state("Grep"), CharacterState::Searching)); - assert!(matches!(get_tool_state("WebSearch"), CharacterState::Searching)); - assert!(matches!(get_tool_state("WebFetch"), CharacterState::Searching)); + assert!(matches!( + get_tool_state("WebSearch"), + CharacterState::Searching + )); + assert!(matches!( + get_tool_state("WebFetch"), + CharacterState::Searching + )); } #[test] fn test_get_tool_state_coding_tools() { assert!(matches!(get_tool_state("Edit"), CharacterState::Coding)); assert!(matches!(get_tool_state("Write"), CharacterState::Coding)); - assert!(matches!(get_tool_state("NotebookEdit"), CharacterState::Coding)); + assert!(matches!( + get_tool_state("NotebookEdit"), + CharacterState::Coding + )); } #[test] fn test_get_tool_state_mcp_tools() { - assert!(matches!(get_tool_state("mcp__github__create_issue"), CharacterState::Mcp)); - assert!(matches!(get_tool_state("mcp__notion__search"), CharacterState::Mcp)); + assert!(matches!( + get_tool_state("mcp__github__create_issue"), + CharacterState::Mcp + )); + assert!(matches!( + get_tool_state("mcp__notion__search"), + CharacterState::Mcp + )); } #[test] @@ -902,7 +1053,10 @@ mod tests { #[test] fn test_get_tool_state_unknown() { - assert!(matches!(get_tool_state("SomeUnknownTool"), CharacterState::Typing)); + assert!(matches!( + get_tool_state("SomeUnknownTool"), + CharacterState::Typing + )); assert!(matches!(get_tool_state("Bash"), CharacterState::Typing)); } diff --git a/src-tauri/src/wsl_notifications.rs b/src-tauri/src/wsl_notifications.rs index ea61d5c..2d9752c 100644 --- a/src-tauri/src/wsl_notifications.rs +++ b/src-tauri/src/wsl_notifications.rs @@ -81,4 +81,4 @@ $notifier.Show($toast) // If all methods fail, return an error Err("All WSL notification methods failed".to_string()) -} \ No newline at end of file +} diff --git a/src/lib/commands/slashCommands.ts b/src/lib/commands/slashCommands.ts index cf83a7b..04bdd97 100644 --- a/src/lib/commands/slashCommands.ts +++ b/src/lib/commands/slashCommands.ts @@ -183,6 +183,61 @@ export const slashCommands: SlashCommand[] = [ } }, }, + { + name: "skill", + description: "Invoke a Claude Code skill from ~/.claude/skills/", + usage: "/skill [name] [data]", + execute: async (args: string) => { + const conversationId = get(claudeStore.activeConversationId); + if (!conversationId) { + claudeStore.addLine("error", "No active conversation"); + return; + } + + const parts = args.trim().split(/\s+/); + const skillName = parts[0]; + const skillData = parts.slice(1).join(" "); + + // If no skill name provided, list available skills + if (!skillName) { + try { + const skills = await invoke("list_skills"); + if (skills.length === 0) { + claudeStore.addLine( + "system", + "No skills found in ~/.claude/skills/\nCreate a skill by adding a folder with a SKILL.md file." + ); + } else { + const skillList = skills.map((s) => ` • ${s}`).join("\n"); + claudeStore.addLine( + "system", + `Available skills:\n${skillList}\n\nUsage: /skill [data]` + ); + } + } catch (error) { + claudeStore.addLine("error", `Failed to list skills: ${error}`); + } + return; + } + + try { + claudeStore.addLine("system", `Invoking skill: ${skillName}`); + characterState.setState("thinking"); + + const message = skillData + ? `Please run the /${skillName} skill with the following data:\n\n${skillData}` + : `Please run the /${skillName} skill.`; + + await invoke("send_prompt", { + conversationId, + message, + }); + } catch (error) { + claudeStore.addLine("error", `Failed to invoke skill: ${error}`); + characterState.setTemporaryState("error", 3000); + } + }, + }, ]; export function parseSlashCommand(input: string): { diff --git a/src/lib/components/AnimeGirl.svelte b/src/lib/components/AnimeGirl.svelte index 129b03d..58c0c50 100644 --- a/src/lib/components/AnimeGirl.svelte +++ b/src/lib/components/AnimeGirl.svelte @@ -57,30 +57,34 @@ } -
-
-
+
+
+
Hikari - {info.label} { const target = e.currentTarget as HTMLImageElement; target.src = "/sprites/placeholder.svg"; }} />
+
-
-
- {info.label} -
+
+
+ {info.label}
-
+
diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index 2ae9fc5..8981690 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -1,5 +1,13 @@ + +{#if updateInfo && !dismissed} +
+
+
🎉
+
+

Update Available!

+

+ A new version of Hikari Desktop is available: + {updateInfo.latest_version} +

+

+ Current version: {updateInfo.current_version} +

+
+ + +
+
+ +
+
+{/if} diff --git a/src/lib/stores/claude.ts b/src/lib/stores/claude.ts index d912df0..7b8983e 100644 --- a/src/lib/stores/claude.ts +++ b/src/lib/stores/claude.ts @@ -64,6 +64,8 @@ export const claudeStore = { deleteConversation: conversationsStore.deleteConversation, switchConversation: conversationsStore.switchConversation, renameConversation: conversationsStore.renameConversation, + saveScrollPosition: conversationsStore.saveScrollPosition, + getScrollPosition: conversationsStore.getScrollPosition, getGrantedTools: (): string[] => { let tools: string[] = []; diff --git a/src/lib/stores/config.ts b/src/lib/stores/config.ts index 9dd41b3..9c6c0d3 100644 --- a/src/lib/stores/config.ts +++ b/src/lib/stores/config.ts @@ -15,6 +15,9 @@ export interface HikariConfig { notifications_enabled: boolean; notification_volume: number; always_on_top: boolean; + update_checks_enabled: boolean; + character_panel_width: number | null; + font_size: number; } const defaultConfig: HikariConfig = { @@ -29,6 +32,9 @@ const defaultConfig: HikariConfig = { notifications_enabled: true, notification_volume: 0.7, always_on_top: false, + update_checks_enabled: true, + character_panel_width: null, + font_size: 14, }; function createConfigStore() { @@ -89,6 +95,33 @@ function createConfigStore() { applyTheme(theme); }, + setFontSize: async (size: number) => { + const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size)); + await updateConfig({ font_size: clampedSize }); + applyFontSize(clampedSize); + }, + + increaseFontSize: async () => { + let currentConfig: HikariConfig = defaultConfig; + config.subscribe((c) => (currentConfig = c))(); + const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2); + await updateConfig({ font_size: newSize }); + applyFontSize(newSize); + }, + + decreaseFontSize: async () => { + let currentConfig: HikariConfig = defaultConfig; + config.subscribe((c) => (currentConfig = c))(); + const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2); + await updateConfig({ font_size: newSize }); + applyFontSize(newSize); + }, + + resetFontSize: async () => { + await updateConfig({ font_size: DEFAULT_FONT_SIZE }); + applyFontSize(DEFAULT_FONT_SIZE); + }, + addAutoGrantedTool: async (tool: string) => { let currentConfig: HikariConfig = defaultConfig; config.subscribe((c) => (currentConfig = c))(); @@ -119,6 +152,23 @@ export function applyTheme(theme: Theme) { } } +const MIN_FONT_SIZE = 10; +const MAX_FONT_SIZE = 24; +const DEFAULT_FONT_SIZE = 14; + +export function applyFontSize(size: number) { + if (typeof document !== "undefined") { + const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size)); + document.documentElement.style.setProperty("--terminal-font-size", `${clampedSize}px`); + } +} + +export function clampFontSize(size: number): number { + return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size)); +} + +export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE }; + export const configStore = createConfigStore(); export const isDarkTheme = derived(configStore.config, ($config) => $config.theme === "dark"); diff --git a/src/lib/stores/conversations.ts b/src/lib/stores/conversations.ts index 3a8b8ac..6948aaf 100644 --- a/src/lib/stores/conversations.ts +++ b/src/lib/stores/conversations.ts @@ -21,6 +21,7 @@ export interface Conversation { grantedTools: Set; pendingPermission: PermissionRequest | null; pendingQuestion: UserQuestionEvent | null; + scrollPosition: number; createdAt: Date; lastActivityAt: Date; } @@ -55,6 +56,7 @@ function createConversationsStore() { grantedTools: new Set(), pendingPermission: null, pendingQuestion: null, + scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll) createdAt: new Date(), lastActivityAt: new Date(), }; @@ -106,6 +108,7 @@ function createConversationsStore() { ($conv) => $conv?.pendingPermission || null ); const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null); + const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1); return { // Expose derived stores for compatibility @@ -118,6 +121,7 @@ function createConversationsStore() { isProcessing: { subscribe: isProcessing.subscribe }, grantedTools: { subscribe: grantedTools.subscribe }, pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe }, + scrollPosition: { subscribe: scrollPosition.subscribe }, // New conversation-specific stores conversations: { subscribe: conversations.subscribe }, @@ -325,6 +329,22 @@ function createConversationsStore() { }); }, + saveScrollPosition: (id: string, position: number) => { + conversations.update((convs) => { + const conv = convs.get(id); + if (conv) { + conv.scrollPosition = position; + } + return convs; + }); + }, + + getScrollPosition: (id: string): number => { + const convs = get(conversations); + const conv = convs.get(id); + return conv?.scrollPosition ?? -1; + }, + // Methods that operate on the active conversation setSessionId: (id: string | null) => { ensureInitialized(); diff --git a/src/lib/types/messages.ts b/src/lib/types/messages.ts index 9eb6834..2985bd0 100644 --- a/src/lib/types/messages.ts +++ b/src/lib/types/messages.ts @@ -141,3 +141,11 @@ export interface UserQuestionEvent { } export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"; + +export interface UpdateInfo { + current_version: string; + latest_version: string; + has_update: boolean; + release_url: string; + release_notes?: string; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 1d6e0d9..00d2494 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -3,7 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { get } from "svelte/store"; import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri"; - import { configStore, applyTheme } from "$lib/stores/config"; + import { configStore, applyTheme, applyFontSize } from "$lib/stores/config"; import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications"; import { conversationsStore } from "$lib/stores/conversations"; import { claudeStore, isClaudeProcessing } from "$lib/stores/claude"; @@ -18,10 +18,41 @@ import ConfigSidebar from "$lib/components/ConfigSidebar.svelte"; import AchievementNotification from "$lib/components/AchievementNotification.svelte"; import AchievementsPanel from "$lib/components/AchievementsPanel.svelte"; + import UpdateNotification from "$lib/components/UpdateNotification.svelte"; let initialized = false; + let updateNotification: UpdateNotification; let achievementPanelOpen = $state(false); + // Resizable panel state + let panelWidth = $state(320); // Default width in pixels + let isResizing = $state(false); + const MIN_PANEL_WIDTH = 200; + const MAX_PANEL_WIDTH = 600; + + function startResize(event: MouseEvent) { + isResizing = true; + event.preventDefault(); + document.addEventListener("mousemove", handleResize); + document.addEventListener("mouseup", stopResize); + } + + function handleResize(event: MouseEvent) { + if (!isResizing) return; + const newWidth = event.clientX; + panelWidth = Math.max(MIN_PANEL_WIDTH, Math.min(MAX_PANEL_WIDTH, newWidth)); + } + + function stopResize() { + if (isResizing) { + isResizing = false; + document.removeEventListener("mousemove", handleResize); + document.removeEventListener("mouseup", stopResize); + // Save the panel width to config + configStore.updateConfig({ character_panel_width: panelWidth }); + } + } + // Global keyboard shortcuts function handleGlobalKeydown(event: KeyboardEvent) { // Don't trigger shortcuts when typing in inputs (except for specific ones) @@ -69,6 +100,27 @@ return; } } + + // Ctrl++ or Ctrl+= - Increase font size + if (event.ctrlKey && (event.key === "+" || event.key === "=")) { + event.preventDefault(); + configStore.increaseFontSize(); + return; + } + + // Ctrl+- - Decrease font size + if (event.ctrlKey && event.key === "-") { + event.preventDefault(); + configStore.decreaseFontSize(); + return; + } + + // Ctrl+0 - Reset font size + if (event.ctrlKey && event.key === "0") { + event.preventDefault(); + configStore.resetFontSize(); + return; + } } async function handleInterrupt() { @@ -96,6 +148,7 @@ // Apply saved settings on startup const config = configStore.getConfig(); applyTheme(config.theme); + applyFontSize(config.font_size); // Apply always-on-top setting if (config.always_on_top) { @@ -103,11 +156,21 @@ await window.setAlwaysOnTop(true); } + // Load saved panel width + if (config.character_panel_width) { + panelWidth = config.character_panel_width; + } + // Initialize notification settings sync initNotificationSync(); // Add global keyboard shortcut listener window.addEventListener("keydown", handleGlobalKeydown); + + // Check for updates on startup + if (config.update_checks_enabled) { + updateNotification?.checkForUpdates(); + } } }); @@ -127,13 +190,22 @@
+ + +
+ -
+
@@ -147,6 +219,7 @@ bind:isOpen={achievementPanelOpen} onClose={() => (achievementPanelOpen = false)} /> +
diff --git a/static/sprites/coding.png b/static/sprites/coding.png index bf135d7..9f2cabd 100644 Binary files a/static/sprites/coding.png and b/static/sprites/coding.png differ diff --git a/static/sprites/error.png b/static/sprites/error.png index 5bf74fa..34b5885 100644 Binary files a/static/sprites/error.png and b/static/sprites/error.png differ diff --git a/static/sprites/idle.png b/static/sprites/idle.png index f423c8f..fd3c3e7 100644 Binary files a/static/sprites/idle.png and b/static/sprites/idle.png differ diff --git a/static/sprites/mcp.png b/static/sprites/mcp.png index 8aa0ff5..2acc2ff 100644 Binary files a/static/sprites/mcp.png and b/static/sprites/mcp.png differ diff --git a/static/sprites/permission.png b/static/sprites/permission.png index 2496263..3377aa1 100644 Binary files a/static/sprites/permission.png and b/static/sprites/permission.png differ diff --git a/static/sprites/searching.png b/static/sprites/searching.png index 33b0d5f..8562073 100644 Binary files a/static/sprites/searching.png and b/static/sprites/searching.png differ diff --git a/static/sprites/success.png b/static/sprites/success.png index c09c2af..c00db94 100644 Binary files a/static/sprites/success.png and b/static/sprites/success.png differ diff --git a/static/sprites/thinking.png b/static/sprites/thinking.png index 9a0e2ed..9eedc2f 100644 Binary files a/static/sprites/thinking.png and b/static/sprites/thinking.png differ diff --git a/static/sprites/typing.png b/static/sprites/typing.png index 0bb1db8..5b0c9ac 100644 Binary files a/static/sprites/typing.png and b/static/sprites/typing.png differ