generated from nhcarrigan/template
feat: another wave of features (#61)
## Explanation This PR bundles several user-facing improvements and feature additions for the v0.3.0 release, including quality-of-life improvements to the UI, new slash commands, better state persistence, and auto-update checking. ## Included Changes - **Resizable chat input** with drag handle (#58 partial) - **Arrow key navigation fix** - cursor keys now navigate text when user has typed input (#58) - **Scroll position persistence** per conversation tab - **/skill command** for invoking Claude Code skills (#57) - **Stats persistence fix** - stats now persist across session changes, only reset on disconnect (#59) - **Auto-update checker** on startup (#17) - **Resizable character panel** with full-height sprites (#10) - **Font size and zoom settings** with keyboard shortcuts (Ctrl++/Ctrl+-/Ctrl+0) (#19) ## Closes Closes #10, #17, #19, #57, #58, #59 ## Attestations - [x] I have read and agree to the Code of Conduct - [x] I have read and agree to the Community Guidelines - [x] My contribution complies with the Contributor Covenant - [x] I have run the linter and resolved any errors - [x] My pull request uses an appropriate title, matching the conventional commit standards - [x] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request - [x] All new and existing tests pass locally with my changes - [x] Code coverage remains at or above the configured threshold ## Documentation N/A - Internal app features ## Versioning Minor - My pull request introduces new non-breaking features. --- ✨ This PR was created with help from Hikari~ 🌸 Co-authored-by: Hikari <hikari@nhcarrigan.com> Reviewed-on: #61 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #61.
This commit is contained in:
Generated
+386
-3
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<AchievementId> {
|
||||
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<AchievementId> = 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::<Vec<AchievementId>>(unlocked_value.clone()) {
|
||||
if let Ok(unlocked_list) =
|
||||
serde_json::from_value::<Vec<AchievementId>>(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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, String> {
|
||||
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<UsageStats, String> {
|
||||
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<String> {
|
||||
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<Mutex<BridgeManager>>;
|
||||
|
||||
pub fn create_shared_bridge_manager() -> SharedBridgeManager {
|
||||
Arc::new(Mutex::new(BridgeManager::new()))
|
||||
}
|
||||
}
|
||||
|
||||
+136
-18
@@ -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<String, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_config(app: AppHandle) -> Result<HikariConfig, 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())?;
|
||||
|
||||
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<String>) -> Result<String, String> {
|
||||
pub async fn validate_directory(
|
||||
path: String,
|
||||
current_dir: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
use std::path::Path;
|
||||
|
||||
let path = Path::new(&path);
|
||||
@@ -136,11 +134,17 @@ pub async fn validate_directory(path: String, current_dir: Option<String>) -> 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<String>) -> Re
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_saved_achievements(app: AppHandle) -> Result<Vec<AchievementUnlockedEvent>, String> {
|
||||
pub async fn load_saved_achievements(
|
||||
app: AppHandle,
|
||||
) -> Result<Vec<AchievementUnlockedEvent>, String> {
|
||||
use chrono::Utc;
|
||||
|
||||
// Load achievements from persistent store
|
||||
@@ -162,9 +168,7 @@ pub async fn load_saved_achievements(app: AppHandle) -> Result<Vec<AchievementUn
|
||||
for achievement_id in &progress.unlocked {
|
||||
let mut info = get_achievement_info(achievement_id);
|
||||
info.unlocked_at = Some(Utc::now()); // We don't store timestamps, so just use now
|
||||
events.push(AchievementUnlockedEvent {
|
||||
achievement: info,
|
||||
});
|
||||
events.push(AchievementUnlockedEvent { achievement: info });
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
@@ -180,3 +184,117 @@ pub async fn answer_question(
|
||||
let mut manager = bridge_manager.lock();
|
||||
manager.send_tool_result(&conversation_id, &tool_use_id, answers)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_skills() -> Result<Vec<String>, 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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct GiteaRelease {
|
||||
tag_name: String,
|
||||
html_url: String,
|
||||
body: Option<String>,
|
||||
prerelease: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_updates() -> Result<UpdateInfo, String> {
|
||||
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<GiteaRelease> =
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
+30
-1
@@ -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<u32>,
|
||||
|
||||
#[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]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+273
-119
@@ -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<RwLock<UsageStats>>, conversation_id: Option<String>) {
|
||||
fn handle_stdout(
|
||||
stdout: std::process::ChildStdout,
|
||||
app: AppHandle,
|
||||
stats: Arc<RwLock<UsageStats>>,
|
||||
conversation_id: Option<String>,
|
||||
) {
|
||||
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<R
|
||||
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
|
||||
}
|
||||
|
||||
fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle, conversation_id: Option<String>) {
|
||||
fn handle_stderr(
|
||||
stderr: std::process::ChildStderr,
|
||||
app: AppHandle,
|
||||
conversation_id: Option<String>,
|
||||
) {
|
||||
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<RwLock<UsageStats>>, conversation_id: &Option<String>) -> Result<(), String> {
|
||||
fn process_json_line(
|
||||
line: &str,
|
||||
app: &AppHandle,
|
||||
stats: &Arc<RwLock<UsageStats>>,
|
||||
conversation_id: &Option<String>,
|
||||
) -> 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<RwLock<UsageStats>
|
||||
}
|
||||
|
||||
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<RwLock<UsageStats>
|
||||
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<RwLock<UsageStats>
|
||||
}
|
||||
}
|
||||
|
||||
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<RwLock<UsageStats>
|
||||
// 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<RwLock<UsageStats>
|
||||
// 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<RwLock<UsageStats>
|
||||
// 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<RwLock<UsageStats>
|
||||
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<QuestionOption> = first_question.get("options")
|
||||
let options: Vec<QuestionOption> = 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<RwLock<UsageStats>
|
||||
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<RwLock<UsageStats>
|
||||
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<RwLock<UsageStats>
|
||||
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<String>, conversation_id: Option<String>) {
|
||||
let _ = app.emit("claude:state", StateChangeEvent { state, tool_name, conversation_id });
|
||||
fn emit_state_change(
|
||||
app: &AppHandle,
|
||||
state: CharacterState,
|
||||
tool_name: Option<String>,
|
||||
conversation_id: Option<String>,
|
||||
) {
|
||||
let _ = app.emit(
|
||||
"claude:state",
|
||||
StateChangeEvent {
|
||||
state,
|
||||
tool_name,
|
||||
conversation_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_connection_status(app: &AppHandle, status: ConnectionStatus, conversation_id: Option<String>) {
|
||||
let _ = app.emit("claude:connection", ConnectionEvent { status, conversation_id });
|
||||
fn emit_connection_status(
|
||||
app: &AppHandle,
|
||||
status: ConnectionStatus,
|
||||
conversation_id: Option<String>,
|
||||
) {
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -81,4 +81,4 @@ $notifier.Show($toast)
|
||||
|
||||
// If all methods fail, return an error
|
||||
Err("All WSL notification methods failed".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user