generated from nhcarrigan/template
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
6a12a7a34d
|
|||
| 479652b69e | |||
| a72f2afaff | |||
|
e4288248b1
|
|||
| 1c45507cdf |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hikari-desktop",
|
"name": "hikari-desktop",
|
||||||
"version": "1.1.1",
|
"version": "1.3.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+135
-13
@@ -437,7 +437,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"fnv",
|
"fnv",
|
||||||
"uuid",
|
"uuid 1.19.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -767,13 +767,34 @@ dependencies = [
|
|||||||
"crypto-common",
|
"crypto-common",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs"
|
||||||
|
version = "5.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||||
|
dependencies = [
|
||||||
|
"dirs-sys 0.4.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs"
|
name = "dirs"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs-sys",
|
"dirs-sys 0.5.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs-sys"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"option-ext",
|
||||||
|
"redox_users 0.4.6",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -784,10 +805,23 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users",
|
"redox_users 0.5.2",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "discord-rich-presence"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a75db747ecd252c01bfecaf709b07fcb4c634adf0edb5fed47bc9c3052e7076b"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"uuid 0.8.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dispatch"
|
name = "dispatch"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1602,9 +1636,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hikari-desktop"
|
name = "hikari-desktop"
|
||||||
version = "1.1.1"
|
version = "1.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"dirs 5.0.1",
|
||||||
|
"discord-rich-presence",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1622,7 +1658,7 @@ dependencies = [
|
|||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid 1.19.0",
|
||||||
"windows 0.62.2",
|
"windows 0.62.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3338,6 +3374,17 @@ dependencies = [
|
|||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_users"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libredox",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_users"
|
name = "redox_users"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -3578,7 +3625,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid 1.19.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4173,7 +4220,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie",
|
||||||
"dirs",
|
"dirs 6.0.0",
|
||||||
"dunce",
|
"dunce",
|
||||||
"embed_plist",
|
"embed_plist",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
@@ -4224,7 +4271,7 @@ checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"cargo_toml",
|
"cargo_toml",
|
||||||
"dirs",
|
"dirs 6.0.0",
|
||||||
"glob",
|
"glob",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"json-patch",
|
"json-patch",
|
||||||
@@ -4261,7 +4308,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"time",
|
"time",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid 1.19.0",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4557,7 +4604,7 @@ dependencies = [
|
|||||||
"toml 0.9.11+spec-1.1.0",
|
"toml 0.9.11+spec-1.1.0",
|
||||||
"url",
|
"url",
|
||||||
"urlpattern",
|
"urlpattern",
|
||||||
"uuid",
|
"uuid 1.19.0",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4948,7 +4995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
|
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dirs",
|
"dirs 6.0.0",
|
||||||
"libappindicator",
|
"libappindicator",
|
||||||
"muda",
|
"muda",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -5099,6 +5146,15 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uuid"
|
||||||
|
version = "0.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.19.0"
|
version = "1.19.0"
|
||||||
@@ -5687,6 +5743,15 @@ dependencies = [
|
|||||||
"windows-targets 0.42.2",
|
"windows-targets 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.48.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.48.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
@@ -5738,6 +5803,21 @@ dependencies = [
|
|||||||
"windows_x86_64_msvc 0.42.2",
|
"windows_x86_64_msvc 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm 0.48.5",
|
||||||
|
"windows_aarch64_msvc 0.48.5",
|
||||||
|
"windows_i686_gnu 0.48.5",
|
||||||
|
"windows_i686_msvc 0.48.5",
|
||||||
|
"windows_x86_64_gnu 0.48.5",
|
||||||
|
"windows_x86_64_gnullvm 0.48.5",
|
||||||
|
"windows_x86_64_msvc 0.48.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5804,6 +5884,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5822,6 +5908,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5840,6 +5932,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5870,6 +5968,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5888,6 +5992,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5906,6 +6016,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5924,6 +6040,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -6004,7 +6126,7 @@ dependencies = [
|
|||||||
"block2",
|
"block2",
|
||||||
"cookie",
|
"cookie",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dirs",
|
"dirs 6.0.0",
|
||||||
"dpi",
|
"dpi",
|
||||||
"dunce",
|
"dunce",
|
||||||
"gdkx11",
|
"gdkx11",
|
||||||
@@ -6127,7 +6249,7 @@ dependencies = [
|
|||||||
"serde_repr",
|
"serde_repr",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uds_windows",
|
"uds_windows",
|
||||||
"uuid",
|
"uuid 1.19.0",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
"winnow 0.7.14",
|
"winnow 0.7.14",
|
||||||
"zbus_macros",
|
"zbus_macros",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "hikari-desktop"
|
name = "hikari-desktop"
|
||||||
version = "1.1.1"
|
version = "1.3.0"
|
||||||
description = "Hikari - Claude Code Visual Assistant"
|
description = "Hikari - Claude Code Visual Assistant"
|
||||||
authors = ["Naomi Carrigan"]
|
authors = ["Naomi Carrigan"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -31,6 +31,8 @@ tauri-plugin-fs = "2"
|
|||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
semver = "1"
|
semver = "1"
|
||||||
chrono = { version = "0.4.43", features = ["serde"] }
|
chrono = { version = "0.4.43", features = ["serde"] }
|
||||||
|
discord-rich-presence = "0.2"
|
||||||
|
dirs = "5"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows = { version = "0.62", features = [
|
windows = { version = "0.62", features = [
|
||||||
|
|||||||
@@ -1935,6 +1935,7 @@ pub fn check_achievements(
|
|||||||
let search_count: u64 = search_tools
|
let search_count: u64 = search_tools
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|tool| stats.tools_usage.get(*tool))
|
.filter_map(|tool| stats.tools_usage.get(*tool))
|
||||||
|
.map(|t| t.call_count)
|
||||||
.sum();
|
.sum();
|
||||||
if search_count >= 50 && progress.unlock(AchievementId::Explorer) {
|
if search_count >= 50 && progress.unlock(AchievementId::Explorer) {
|
||||||
newly_unlocked.push(AchievementId::Explorer);
|
newly_unlocked.push(AchievementId::Explorer);
|
||||||
@@ -1988,25 +1989,25 @@ pub fn check_achievements(
|
|||||||
// TODO: Track different Claude models used
|
// TODO: Track different Claude models used
|
||||||
|
|
||||||
// Tool mastery achievements
|
// Tool mastery achievements
|
||||||
if let Some(bash_count) = stats.tools_usage.get("Bash") {
|
if let Some(bash_stats) = stats.tools_usage.get("Bash") {
|
||||||
if *bash_count >= 50 && progress.unlock(AchievementId::BashMaster) {
|
if bash_stats.call_count >= 50 && progress.unlock(AchievementId::BashMaster) {
|
||||||
newly_unlocked.push(AchievementId::BashMaster);
|
newly_unlocked.push(AchievementId::BashMaster);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(read_count) = stats.tools_usage.get("Read") {
|
if let Some(read_stats) = stats.tools_usage.get("Read") {
|
||||||
if *read_count >= 100 && progress.unlock(AchievementId::FileExplorer) {
|
if read_stats.call_count >= 100 && progress.unlock(AchievementId::FileExplorer) {
|
||||||
newly_unlocked.push(AchievementId::FileExplorer);
|
newly_unlocked.push(AchievementId::FileExplorer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(grep_count) = stats.tools_usage.get("Grep") {
|
if let Some(grep_stats) = stats.tools_usage.get("Grep") {
|
||||||
if *grep_count >= 50 && progress.unlock(AchievementId::SearchExpert) {
|
if grep_stats.call_count >= 50 && progress.unlock(AchievementId::SearchExpert) {
|
||||||
newly_unlocked.push(AchievementId::SearchExpert);
|
newly_unlocked.push(AchievementId::SearchExpert);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Git Guru - check git command usage in Bash
|
// Git Guru - check git command usage in Bash
|
||||||
if let Some(bash_count) = stats.tools_usage.get("Bash") {
|
if let Some(bash_stats) = stats.tools_usage.get("Bash") {
|
||||||
if *bash_count >= 10 && progress.unlock(AchievementId::GitGuru) {
|
if bash_stats.call_count >= 10 && progress.unlock(AchievementId::GitGuru) {
|
||||||
// TODO: More specific git command tracking
|
// TODO: More specific git command tracking
|
||||||
newly_unlocked.push(AchievementId::GitGuru);
|
newly_unlocked.push(AchievementId::GitGuru);
|
||||||
}
|
}
|
||||||
@@ -2055,28 +2056,28 @@ pub fn check_achievements(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// More tool mastery achievements
|
// More tool mastery achievements
|
||||||
if let Some(edit_count) = stats.tools_usage.get("Edit") {
|
if let Some(edit_stats) = stats.tools_usage.get("Edit") {
|
||||||
if *edit_count >= 100 && progress.unlock(AchievementId::EditMaster) {
|
if edit_stats.call_count >= 100 && progress.unlock(AchievementId::EditMaster) {
|
||||||
newly_unlocked.push(AchievementId::EditMaster);
|
newly_unlocked.push(AchievementId::EditMaster);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(write_count) = stats.tools_usage.get("Write") {
|
if let Some(write_stats) = stats.tools_usage.get("Write") {
|
||||||
if *write_count >= 50 && progress.unlock(AchievementId::WriteMaster) {
|
if write_stats.call_count >= 50 && progress.unlock(AchievementId::WriteMaster) {
|
||||||
newly_unlocked.push(AchievementId::WriteMaster);
|
newly_unlocked.push(AchievementId::WriteMaster);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(glob_count) = stats.tools_usage.get("Glob") {
|
if let Some(glob_stats) = stats.tools_usage.get("Glob") {
|
||||||
if *glob_count >= 100 && progress.unlock(AchievementId::GlobMaster) {
|
if glob_stats.call_count >= 100 && progress.unlock(AchievementId::GlobMaster) {
|
||||||
newly_unlocked.push(AchievementId::GlobMaster);
|
newly_unlocked.push(AchievementId::GlobMaster);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(task_count) = stats.tools_usage.get("Task") {
|
if let Some(task_stats) = stats.tools_usage.get("Task") {
|
||||||
if *task_count >= 50 && progress.unlock(AchievementId::TaskMaster) {
|
if task_stats.call_count >= 50 && progress.unlock(AchievementId::TaskMaster) {
|
||||||
newly_unlocked.push(AchievementId::TaskMaster);
|
newly_unlocked.push(AchievementId::TaskMaster);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(web_count) = stats.tools_usage.get("WebFetch") {
|
if let Some(web_stats) = stats.tools_usage.get("WebFetch") {
|
||||||
if *web_count >= 20 && progress.unlock(AchievementId::WebFetcher) {
|
if web_stats.call_count >= 20 && progress.unlock(AchievementId::WebFetcher) {
|
||||||
newly_unlocked.push(AchievementId::WebFetcher);
|
newly_unlocked.push(AchievementId::WebFetcher);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2085,7 +2086,7 @@ pub fn check_achievements(
|
|||||||
.tools_usage
|
.tools_usage
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(name, _)| name.starts_with("mcp__"))
|
.filter(|(name, _)| name.starts_with("mcp__"))
|
||||||
.map(|(_, count)| count)
|
.map(|(_, tool_stats)| tool_stats.call_count)
|
||||||
.sum();
|
.sum();
|
||||||
if mcp_count >= 50 && progress.unlock(AchievementId::McpExplorer) {
|
if mcp_count >= 50 && progress.unlock(AchievementId::McpExplorer) {
|
||||||
newly_unlocked.push(AchievementId::McpExplorer);
|
newly_unlocked.push(AchievementId::McpExplorer);
|
||||||
@@ -2323,6 +2324,11 @@ mod tests {
|
|||||||
morning_sessions: 0,
|
morning_sessions: 0,
|
||||||
night_sessions: 0,
|
night_sessions: 0,
|
||||||
last_session_date: None,
|
last_session_date: None,
|
||||||
|
context_tokens_used: 0,
|
||||||
|
context_window_limit: 200_000,
|
||||||
|
context_utilisation_percent: 0.0,
|
||||||
|
potential_cache_hits: 0,
|
||||||
|
potential_cache_savings_tokens: 0,
|
||||||
achievements: AchievementProgress::new(),
|
achievements: AchievementProgress::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2733,12 +2739,21 @@ mod tests {
|
|||||||
// check_achievements tests - Tool Usage
|
// check_achievements tests - Tool Usage
|
||||||
// =====================
|
// =====================
|
||||||
|
|
||||||
|
// Helper function to create a ToolTokenStats with just call count for tests
|
||||||
|
fn tool_stats(call_count: u64) -> crate::stats::ToolTokenStats {
|
||||||
|
crate::stats::ToolTokenStats {
|
||||||
|
call_count,
|
||||||
|
estimated_input_tokens: 0,
|
||||||
|
estimated_output_tokens: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_check_achievements_first_tool() {
|
fn test_check_achievements_first_tool() {
|
||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Read".to_string(), 1);
|
stats.tools_usage.insert("Read".to_string(), tool_stats(1));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::FirstTool));
|
assert!(newly.contains(&AchievementId::FirstTool));
|
||||||
@@ -2749,11 +2764,11 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Read".to_string(), 1);
|
stats.tools_usage.insert("Read".to_string(), tool_stats(1));
|
||||||
stats.tools_usage.insert("Write".to_string(), 1);
|
stats.tools_usage.insert("Write".to_string(), tool_stats(1));
|
||||||
stats.tools_usage.insert("Edit".to_string(), 1);
|
stats.tools_usage.insert("Edit".to_string(), tool_stats(1));
|
||||||
stats.tools_usage.insert("Bash".to_string(), 1);
|
stats.tools_usage.insert("Bash".to_string(), tool_stats(1));
|
||||||
stats.tools_usage.insert("Grep".to_string(), 1);
|
stats.tools_usage.insert("Grep".to_string(), tool_stats(1));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::Toolsmith));
|
assert!(newly.contains(&AchievementId::Toolsmith));
|
||||||
@@ -2765,7 +2780,7 @@ mod tests {
|
|||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
for i in 0..10 {
|
for i in 0..10 {
|
||||||
stats.tools_usage.insert(format!("Tool{}", i), 1);
|
stats.tools_usage.insert(format!("Tool{}", i), tool_stats(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
@@ -2777,7 +2792,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Bash".to_string(), 50);
|
stats.tools_usage.insert("Bash".to_string(), tool_stats(50));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::BashMaster));
|
assert!(newly.contains(&AchievementId::BashMaster));
|
||||||
@@ -2788,7 +2803,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Read".to_string(), 100);
|
stats.tools_usage.insert("Read".to_string(), tool_stats(100));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::FileExplorer));
|
assert!(newly.contains(&AchievementId::FileExplorer));
|
||||||
@@ -2799,7 +2814,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Grep".to_string(), 50);
|
stats.tools_usage.insert("Grep".to_string(), tool_stats(50));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::SearchExpert));
|
assert!(newly.contains(&AchievementId::SearchExpert));
|
||||||
@@ -2810,7 +2825,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Edit".to_string(), 100);
|
stats.tools_usage.insert("Edit".to_string(), tool_stats(100));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::EditMaster));
|
assert!(newly.contains(&AchievementId::EditMaster));
|
||||||
@@ -2821,7 +2836,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Write".to_string(), 50);
|
stats.tools_usage.insert("Write".to_string(), tool_stats(50));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::WriteMaster));
|
assert!(newly.contains(&AchievementId::WriteMaster));
|
||||||
@@ -2832,7 +2847,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Glob".to_string(), 100);
|
stats.tools_usage.insert("Glob".to_string(), tool_stats(100));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::GlobMaster));
|
assert!(newly.contains(&AchievementId::GlobMaster));
|
||||||
@@ -2843,7 +2858,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Task".to_string(), 50);
|
stats.tools_usage.insert("Task".to_string(), tool_stats(50));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::TaskMaster));
|
assert!(newly.contains(&AchievementId::TaskMaster));
|
||||||
@@ -2854,7 +2869,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("WebFetch".to_string(), 20);
|
stats.tools_usage.insert("WebFetch".to_string(), tool_stats(20));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::WebFetcher));
|
assert!(newly.contains(&AchievementId::WebFetcher));
|
||||||
@@ -2865,8 +2880,8 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("mcp__github__create_issue".to_string(), 25);
|
stats.tools_usage.insert("mcp__github__create_issue".to_string(), tool_stats(25));
|
||||||
stats.tools_usage.insert("mcp__notion__search".to_string(), 25);
|
stats.tools_usage.insert("mcp__notion__search".to_string(), tool_stats(25));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::McpExplorer));
|
assert!(newly.contains(&AchievementId::McpExplorer));
|
||||||
@@ -2881,8 +2896,8 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Grep".to_string(), 30);
|
stats.tools_usage.insert("Grep".to_string(), tool_stats(30));
|
||||||
stats.tools_usage.insert("Glob".to_string(), 20);
|
stats.tools_usage.insert("Glob".to_string(), tool_stats(20));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::Explorer));
|
assert!(newly.contains(&AchievementId::Explorer));
|
||||||
@@ -2893,9 +2908,9 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Grep".to_string(), 200);
|
stats.tools_usage.insert("Grep".to_string(), tool_stats(200));
|
||||||
stats.tools_usage.insert("Glob".to_string(), 200);
|
stats.tools_usage.insert("Glob".to_string(), tool_stats(200));
|
||||||
stats.tools_usage.insert("Task".to_string(), 100);
|
stats.tools_usage.insert("Task".to_string(), tool_stats(100));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::MasterSearcher));
|
assert!(newly.contains(&AchievementId::MasterSearcher));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::collections::HashMap;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
use crate::commands::record_session;
|
||||||
use crate::config::ClaudeStartOptions;
|
use crate::config::ClaudeStartOptions;
|
||||||
use crate::stats::UsageStats;
|
use crate::stats::UsageStats;
|
||||||
use crate::wsl_bridge::WslBridge;
|
use crate::wsl_bridge::WslBridge;
|
||||||
@@ -53,7 +54,12 @@ impl BridgeManager {
|
|||||||
.or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string()));
|
.or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string()));
|
||||||
|
|
||||||
// Start the Claude process
|
// Start the Claude process
|
||||||
bridge.start(app, options)?;
|
bridge.start(app.clone(), options)?;
|
||||||
|
|
||||||
|
// Record session start for cost tracking
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
record_session(&app).await;
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+663
-62
@@ -1,5 +1,5 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, Manager, State};
|
||||||
use tauri_plugin_http::reqwest;
|
use tauri_plugin_http::reqwest;
|
||||||
use tauri_plugin_store::StoreExt;
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
@@ -11,6 +11,43 @@ use crate::temp_manager::SharedTempFileManager;
|
|||||||
|
|
||||||
const CONFIG_STORE_KEY: &str = "config";
|
const CONFIG_STORE_KEY: &str = "config";
|
||||||
|
|
||||||
|
/// Convert a Windows path to a WSL path
|
||||||
|
/// Example: C:\Users\accou\Documents\item.txt -> /mnt/c/Users/accou/Documents/item.txt
|
||||||
|
fn windows_path_to_wsl(windows_path: &str) -> Option<String> {
|
||||||
|
// Check if it's a Windows path (has drive letter like C:\)
|
||||||
|
if windows_path.len() >= 3 && windows_path.chars().nth(1) == Some(':') {
|
||||||
|
let drive_letter = windows_path.chars().next()?.to_lowercase().to_string();
|
||||||
|
let path_without_drive = &windows_path[2..]; // Remove "C:"
|
||||||
|
|
||||||
|
// Replace backslashes with forward slashes and convert to WSL mount point
|
||||||
|
let wsl_path = path_without_drive.replace('\\', "/");
|
||||||
|
Some(format!("/mnt/{}{}", drive_letter, wsl_path))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a WSL path to a Windows path
|
||||||
|
/// Example: /mnt/c/Users/accou/Documents/item.txt -> C:\Users\accou\Documents\item.txt
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn wsl_path_to_windows(wsl_path: &str) -> Option<String> {
|
||||||
|
// Check if it's a WSL mount point path
|
||||||
|
if wsl_path.starts_with("/mnt/") && wsl_path.len() > 6 {
|
||||||
|
let rest = &wsl_path[5..]; // Remove "/mnt/"
|
||||||
|
if let Some(drive_letter) = rest.chars().next() {
|
||||||
|
let path_after_drive = &rest[1..]; // Remove drive letter
|
||||||
|
|
||||||
|
// Convert to Windows path with backslashes
|
||||||
|
let windows_path = path_after_drive.replace('/', "\\");
|
||||||
|
Some(format!("{}:{}", drive_letter.to_uppercase(), windows_path))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn start_claude(
|
pub async fn start_claude(
|
||||||
bridge_manager: State<'_, SharedBridgeManager>,
|
bridge_manager: State<'_, SharedBridgeManager>,
|
||||||
@@ -69,7 +106,10 @@ pub async fn get_working_directory(
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn select_wsl_directory() -> Result<String, String> {
|
pub async fn select_wsl_directory() -> Result<String, String> {
|
||||||
Ok("/home".to_string())
|
// Return the user's home directory cross-platform
|
||||||
|
dirs::home_dir()
|
||||||
|
.ok_or_else(|| "Could not determine home directory".to_string())
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -120,53 +160,93 @@ pub async fn validate_directory(
|
|||||||
path: String,
|
path: String,
|
||||||
current_dir: Option<String>,
|
current_dir: Option<String>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
let path = Path::new(&path);
|
// Detect if we're dealing with a WSL path (starts with / on Windows, or current_dir is a WSL path)
|
||||||
|
let is_wsl_path = cfg!(windows) && (path.starts_with('/') || current_dir.as_ref().is_some_and(|p| p.starts_with('/')));
|
||||||
|
|
||||||
// Expand ~ to home directory
|
if is_wsl_path {
|
||||||
let expanded_path = if path.starts_with("~") {
|
// WSL path - handle as Unix-style path without filesystem validation
|
||||||
if let Some(home) = std::env::var_os("HOME") {
|
// since the Windows binary can't validate WSL filesystem paths
|
||||||
let home_path = Path::new(&home);
|
let resolved = if path.starts_with('/') {
|
||||||
if path == Path::new("~") {
|
// Absolute WSL path - use as-is
|
||||||
home_path.to_path_buf()
|
path
|
||||||
|
} else if let Some(ref cwd) = current_dir {
|
||||||
|
// Relative path - resolve manually using Unix path logic
|
||||||
|
if path == "." {
|
||||||
|
cwd.clone()
|
||||||
|
} else if path == ".." {
|
||||||
|
// Go up one directory
|
||||||
|
cwd.rsplit_once('/').map(|x| x.0).unwrap_or("/").to_string()
|
||||||
|
} else if path.starts_with("../") {
|
||||||
|
// Handle ../ prefix
|
||||||
|
let parent = cwd.rsplit_once('/').map(|x| x.0).unwrap_or("/");
|
||||||
|
let remainder = path.strip_prefix("../").unwrap();
|
||||||
|
if remainder.is_empty() {
|
||||||
|
parent.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", parent, remainder)
|
||||||
|
}
|
||||||
|
} else if path.starts_with("./") {
|
||||||
|
// Handle ./ prefix
|
||||||
|
format!("{}/{}", cwd, path.strip_prefix("./").unwrap())
|
||||||
} else {
|
} else {
|
||||||
home_path.join(path.strip_prefix("~").unwrap())
|
// Regular relative path
|
||||||
|
format!("{}/{}", cwd, path)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return Err("Could not determine home directory".to_string());
|
return Err("Cannot resolve relative WSL path without current directory".to_string());
|
||||||
}
|
};
|
||||||
} else if path.is_relative() {
|
|
||||||
// Handle relative paths (., .., or any relative path) by resolving against current_dir
|
// Normalize the path (remove duplicate slashes, etc.)
|
||||||
if let Some(ref cwd) = current_dir {
|
let normalized = resolved.split('/').filter(|s| !s.is_empty()).collect::<Vec<_>>().join("/");
|
||||||
Path::new(cwd).join(path)
|
Ok(if normalized.is_empty() { "/".to_string() } else { format!("/{}", normalized) })
|
||||||
|
} else {
|
||||||
|
// Native path (Windows on Windows, Unix on Unix) - validate normally
|
||||||
|
let path = Path::new(&path);
|
||||||
|
|
||||||
|
let expanded_path = if path.starts_with("~") {
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
if path == Path::new("~") {
|
||||||
|
home
|
||||||
|
} else {
|
||||||
|
home.join(path.strip_prefix("~").unwrap())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err("Could not determine home directory".to_string());
|
||||||
|
}
|
||||||
|
} else if path.is_relative() {
|
||||||
|
if let Some(ref cwd) = current_dir {
|
||||||
|
let cwd_path = PathBuf::from(cwd);
|
||||||
|
cwd_path.join(path)
|
||||||
|
} else {
|
||||||
|
path.to_path_buf()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
path.to_path_buf()
|
path.to_path_buf()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if the path exists and is a directory
|
||||||
|
if !expanded_path.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"Directory does not exist: {}",
|
||||||
|
expanded_path.display()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
path.to_path_buf()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if the path exists and is a directory
|
if !expanded_path.is_dir() {
|
||||||
if !expanded_path.exists() {
|
return Err(format!(
|
||||||
return Err(format!(
|
"Path is not a directory: {}",
|
||||||
"Directory does not exist: {}",
|
expanded_path.display()
|
||||||
expanded_path.display()
|
));
|
||||||
));
|
}
|
||||||
|
|
||||||
|
// Return the canonicalized (absolute) path
|
||||||
|
expanded_path
|
||||||
|
.canonicalize()
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.map_err(|e| format!("Failed to resolve path: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !expanded_path.is_dir() {
|
|
||||||
return Err(format!(
|
|
||||||
"Path is not a directory: {}",
|
|
||||||
expanded_path.display()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the canonicalized (absolute) path
|
|
||||||
expanded_path
|
|
||||||
.canonicalize()
|
|
||||||
.map(|p| p.to_string_lossy().to_string())
|
|
||||||
.map_err(|e| format!("Failed to resolve path: {}", e))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -202,21 +282,22 @@ pub async fn answer_question(
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_skills() -> Result<Vec<String>, String> {
|
pub async fn list_skills() -> Result<Vec<String>, String> {
|
||||||
|
// On Windows, we need to use WSL to access the skills directory
|
||||||
|
// since skills are stored in the WSL home directory
|
||||||
|
if cfg!(windows) {
|
||||||
|
return list_skills_via_wsl().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On Unix systems, use the native filesystem
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
// Get the home directory
|
let home = dirs::home_dir().ok_or_else(|| "Could not determine home directory".to_string())?;
|
||||||
let home =
|
let skills_dir = home.join(".claude").join("skills");
|
||||||
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() {
|
if !skills_dir.exists() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the directory and collect skill names
|
|
||||||
let mut skills = Vec::new();
|
let mut skills = Vec::new();
|
||||||
let entries =
|
let entries =
|
||||||
fs::read_dir(&skills_dir).map_err(|e| format!("Failed to read skills directory: {}", e))?;
|
fs::read_dir(&skills_dir).map_err(|e| format!("Failed to read skills directory: {}", e))?;
|
||||||
@@ -225,7 +306,6 @@ pub async fn list_skills() -> Result<Vec<String>, String> {
|
|||||||
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
|
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
|
|
||||||
// Only include directories that contain a SKILL.md file
|
|
||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
let skill_file = path.join("SKILL.md");
|
let skill_file = path.join("SKILL.md");
|
||||||
if skill_file.exists() {
|
if skill_file.exists() {
|
||||||
@@ -236,9 +316,42 @@ pub async fn list_skills() -> Result<Vec<String>, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort alphabetically
|
|
||||||
skills.sort();
|
skills.sort();
|
||||||
|
Ok(skills)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List skills by executing commands through WSL (for Windows)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
async fn list_skills_via_wsl() -> Result<Vec<String>, String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
// Use WSL to list directories in ~/.claude/skills that contain SKILL.md
|
||||||
|
let output = Command::new("wsl")
|
||||||
|
.args([
|
||||||
|
"-e",
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
"if [ -d ~/.claude/skills ]; then for d in ~/.claude/skills/*/; do [ -f \"${d}SKILL.md\" ] && basename \"$d\"; done; fi",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
if stderr.contains("not found") || stderr.contains("No such file") {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
return Err(format!("WSL command failed: {}", stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let mut skills: Vec<String> = stdout
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.is_empty())
|
||||||
|
.map(|line| line.to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
skills.sort();
|
||||||
Ok(skills)
|
Ok(skills)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,8 +448,18 @@ pub async fn save_temp_file(
|
|||||||
.map(|n| n.to_string_lossy().to_string())
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
let path_string = path.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// On Windows, convert the path to WSL format if needed
|
||||||
|
// so Claude Code (running in WSL) can access it via /mnt/c/...
|
||||||
|
let final_path = if cfg!(windows) {
|
||||||
|
windows_path_to_wsl(&path_string).unwrap_or(path_string)
|
||||||
|
} else {
|
||||||
|
path_string
|
||||||
|
};
|
||||||
|
|
||||||
Ok(SavedFileInfo {
|
Ok(SavedFileInfo {
|
||||||
path: path.to_string_lossy().to_string(),
|
path: final_path,
|
||||||
filename,
|
filename,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -405,42 +528,142 @@ pub struct FileEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_directory(path: String) -> Result<Vec<FileEntry>, String> {
|
pub async fn list_directory(app: AppHandle, path: String) -> Result<Vec<FileEntry>, String> {
|
||||||
|
// Set up logging
|
||||||
|
let log_path = if let Ok(app_data_dir) = app.path().app_data_dir() {
|
||||||
|
let _ = std::fs::create_dir_all(&app_data_dir);
|
||||||
|
app_data_dir.join("hikari_editor_debug.log")
|
||||||
|
} else {
|
||||||
|
PathBuf::from("hikari_editor_debug.log")
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut log_file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&log_path)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let mut log = |msg: String| {
|
||||||
|
if let Some(ref mut file) = log_file {
|
||||||
|
use std::io::Write;
|
||||||
|
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||||
|
let _ = writeln!(file, "[{}] {}", timestamp, msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log(format!("list_directory called with path: {}", path));
|
||||||
|
log(format!("cfg!(windows) = {}", cfg!(windows)));
|
||||||
|
log(format!("path.starts_with('/') = {}", path.starts_with('/')));
|
||||||
|
|
||||||
|
// On Windows with a WSL path (starts with /), use WSL to list the directory
|
||||||
|
if cfg!(windows) && path.starts_with('/') {
|
||||||
|
log("Using WSL path".to_string());
|
||||||
|
return list_directory_via_wsl(&path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
log("Using native filesystem access".to_string());
|
||||||
|
|
||||||
|
// Native filesystem access
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
let dir_path = Path::new(&path);
|
let dir_path = Path::new(&path);
|
||||||
|
|
||||||
if !dir_path.exists() {
|
if !dir_path.exists() {
|
||||||
return Err(format!("Directory does not exist: {}", path));
|
let err = format!("Directory does not exist: {}", path);
|
||||||
|
log(format!("ERROR: {}", err));
|
||||||
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !dir_path.is_dir() {
|
if !dir_path.is_dir() {
|
||||||
return Err(format!("Path is not a directory: {}", path));
|
let err = format!("Path is not a directory: {}", path);
|
||||||
|
log(format!("ERROR: {}", err));
|
||||||
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
let entries = fs::read_dir(dir_path)
|
let entries = fs::read_dir(dir_path)
|
||||||
.map_err(|e| format!("Failed to read directory: {}", e))?;
|
.map_err(|e| {
|
||||||
|
let err = format!("Failed to read directory: {}", e);
|
||||||
|
log(format!("ERROR: {}", err));
|
||||||
|
err
|
||||||
|
})?;
|
||||||
|
|
||||||
let mut file_entries = Vec::new();
|
let mut file_entries = Vec::new();
|
||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
|
let entry = entry.map_err(|e| {
|
||||||
|
let err = format!("Failed to read entry: {}", e);
|
||||||
|
log(format!("ERROR: {}", err));
|
||||||
|
err
|
||||||
|
})?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
let name = entry
|
let name = entry
|
||||||
.file_name()
|
.file_name()
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// Skip hidden files by default (can be made configurable later)
|
file_entries.push(FileEntry {
|
||||||
if name.starts_with('.') {
|
name: name.clone(),
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
is_directory: path.is_dir(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log(format!("Successfully listed {} entries", file_entries.len()));
|
||||||
|
Ok(file_entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List directory contents via WSL (for Windows with WSL paths)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
async fn list_directory_via_wsl(path: &str) -> Result<Vec<FileEntry>, String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
// Use WSL to list directory contents
|
||||||
|
// Output format: type<tab>name (d for directory, f for file)
|
||||||
|
let script = format!(
|
||||||
|
r#"if [ -d '{}' ]; then for f in '{}'/* '{}'/.* ; do [ -e "$f" ] || continue; name=$(basename "$f"); if [ "$name" = "." ] || [ "$name" = ".." ]; then continue; fi; if [ -d "$f" ]; then echo "d $name"; else echo "f $name"; fi; done; else echo "ERROR: Directory does not exist"; exit 1; fi"#,
|
||||||
|
path, path, path
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = Command::new("wsl")
|
||||||
|
.args(["-e", "sh", "-c", &script])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
||||||
|
if !output.status.success() || stdout.starts_with("ERROR:") {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
if stdout.starts_with("ERROR:") {
|
||||||
|
return Err(stdout.trim().to_string());
|
||||||
|
}
|
||||||
|
return Err(format!("WSL command failed: {}", stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file_entries = Vec::new();
|
||||||
|
|
||||||
|
for line in stdout.lines() {
|
||||||
|
if line.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let parts: Vec<&str> = line.splitn(2, '\t').collect();
|
||||||
|
if parts.len() != 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_directory = parts[0] == "d";
|
||||||
|
let name = parts[1].to_string();
|
||||||
|
let entry_path = if path == "/" {
|
||||||
|
format!("/{}", name)
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", path, name)
|
||||||
|
};
|
||||||
|
|
||||||
file_entries.push(FileEntry {
|
file_entries.push(FileEntry {
|
||||||
name,
|
name,
|
||||||
path: path.to_string_lossy().to_string(),
|
path: entry_path,
|
||||||
is_directory: path.is_dir(),
|
is_directory,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,22 +672,80 @@ pub async fn list_directory(path: String) -> Result<Vec<FileEntry>, String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn read_file_content(path: String) -> Result<String, String> {
|
pub async fn read_file_content(path: String) -> Result<String, String> {
|
||||||
use std::fs;
|
// On Windows with a WSL path, use WSL to read the file
|
||||||
|
if cfg!(windows) && path.starts_with('/') {
|
||||||
|
return read_file_via_wsl(&path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
fs::read_to_string(&path)
|
fs::read_to_string(&path)
|
||||||
.map_err(|e| format!("Failed to read file: {}", e))
|
.map_err(|e| format!("Failed to read file: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read file content via WSL (for Windows with WSL paths)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
async fn read_file_via_wsl(path: &str) -> Result<String, String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
let output = Command::new("wsl")
|
||||||
|
.args(["-e", "cat", path])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!("Failed to read file: {}", stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn write_file_content(path: String, content: String) -> Result<(), String> {
|
pub async fn write_file_content(path: String, content: String) -> Result<(), String> {
|
||||||
use std::fs;
|
// On Windows with a WSL path, use WSL to write the file
|
||||||
|
if cfg!(windows) && path.starts_with('/') {
|
||||||
|
return write_file_via_wsl(&path, &content).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
fs::write(&path, content)
|
fs::write(&path, content)
|
||||||
.map_err(|e| format!("Failed to write file: {}", e))
|
.map_err(|e| format!("Failed to write file: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write file content via WSL (for Windows with WSL paths)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
async fn write_file_via_wsl(path: &str, content: &str) -> Result<(), String> {
|
||||||
|
use std::io::Write;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
let mut child = Command::new("wsl")
|
||||||
|
.args(["-e", "sh", "-c", &format!("cat > '{}'", path)])
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||||
|
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
stdin.write_all(content.as_bytes())
|
||||||
|
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = child.wait()
|
||||||
|
.map_err(|e| format!("Failed to wait for WSL command: {}", e))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
return Err("Failed to write file via WSL".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn create_file(path: String) -> Result<(), String> {
|
pub async fn create_file(path: String) -> Result<(), String> {
|
||||||
|
// On Windows with a WSL path, use WSL to create the file
|
||||||
|
if cfg!(windows) && path.starts_with('/') {
|
||||||
|
return create_file_via_wsl(&path).await;
|
||||||
|
}
|
||||||
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -479,8 +760,41 @@ pub async fn create_file(path: String) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create file via WSL (for Windows with WSL paths)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
async fn create_file_via_wsl(path: &str) -> Result<(), String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
// Check if file exists first
|
||||||
|
let check = Command::new("wsl")
|
||||||
|
.args(["-e", "test", "-e", path])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||||
|
|
||||||
|
if check.success() {
|
||||||
|
return Err("File already exists".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = Command::new("wsl")
|
||||||
|
.args(["-e", "touch", path])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!("Failed to create file: {}", stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn create_directory(path: String) -> Result<(), String> {
|
pub async fn create_directory(path: String) -> Result<(), String> {
|
||||||
|
// On Windows with a WSL path, use WSL to create the directory
|
||||||
|
if cfg!(windows) && path.starts_with('/') {
|
||||||
|
return create_directory_via_wsl(&path).await;
|
||||||
|
}
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -495,8 +809,41 @@ pub async fn create_directory(path: String) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create directory via WSL (for Windows with WSL paths)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
async fn create_directory_via_wsl(path: &str) -> Result<(), String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
// Check if directory exists first
|
||||||
|
let check = Command::new("wsl")
|
||||||
|
.args(["-e", "test", "-e", path])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||||
|
|
||||||
|
if check.success() {
|
||||||
|
return Err("Directory already exists".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = Command::new("wsl")
|
||||||
|
.args(["-e", "mkdir", "-p", path])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!("Failed to create directory: {}", stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn delete_file(path: String) -> Result<(), String> {
|
pub async fn delete_file(path: String) -> Result<(), String> {
|
||||||
|
// On Windows with a WSL path, use WSL to delete the file
|
||||||
|
if cfg!(windows) && path.starts_with('/') {
|
||||||
|
return delete_file_via_wsl(&path).await;
|
||||||
|
}
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -515,8 +862,51 @@ pub async fn delete_file(path: String) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete file via WSL (for Windows with WSL paths)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
async fn delete_file_via_wsl(path: &str) -> Result<(), String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
// Check if path exists
|
||||||
|
let check_exists = Command::new("wsl")
|
||||||
|
.args(["-e", "test", "-e", path])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||||
|
|
||||||
|
if !check_exists.success() {
|
||||||
|
return Err("File does not exist".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if path is a directory
|
||||||
|
let check_dir = Command::new("wsl")
|
||||||
|
.args(["-e", "test", "-d", path])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||||
|
|
||||||
|
if check_dir.success() {
|
||||||
|
return Err("Path is a directory, use delete_directory instead".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = Command::new("wsl")
|
||||||
|
.args(["-e", "rm", path])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!("Failed to delete file: {}", stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn delete_directory(path: String) -> Result<(), String> {
|
pub async fn delete_directory(path: String) -> Result<(), String> {
|
||||||
|
// On Windows with a WSL path, use WSL to delete the directory
|
||||||
|
if cfg!(windows) && path.starts_with('/') {
|
||||||
|
return delete_directory_via_wsl(&path).await;
|
||||||
|
}
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -535,8 +925,51 @@ pub async fn delete_directory(path: String) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete directory via WSL (for Windows with WSL paths)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
async fn delete_directory_via_wsl(path: &str) -> Result<(), String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
// Check if path exists
|
||||||
|
let check_exists = Command::new("wsl")
|
||||||
|
.args(["-e", "test", "-e", path])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||||
|
|
||||||
|
if !check_exists.success() {
|
||||||
|
return Err("Directory does not exist".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if path is a directory
|
||||||
|
let check_dir = Command::new("wsl")
|
||||||
|
.args(["-e", "test", "-d", path])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||||
|
|
||||||
|
if !check_dir.success() {
|
||||||
|
return Err("Path is not a directory".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = Command::new("wsl")
|
||||||
|
.args(["-e", "rm", "-rf", path])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!("Failed to delete directory: {}", stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn rename_path(old_path: String, new_path: String) -> Result<(), String> {
|
pub async fn rename_path(old_path: String, new_path: String) -> Result<(), String> {
|
||||||
|
// On Windows with WSL paths, use WSL to rename
|
||||||
|
if cfg!(windows) && old_path.starts_with('/') {
|
||||||
|
return rename_path_via_wsl(&old_path, &new_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -556,6 +989,171 @@ pub async fn rename_path(old_path: String, new_path: String) -> Result<(), Strin
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rename path via WSL (for Windows with WSL paths)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
// Check if old path exists
|
||||||
|
let check_old = Command::new("wsl")
|
||||||
|
.args(["-e", "test", "-e", old_path])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||||
|
|
||||||
|
if !check_old.success() {
|
||||||
|
return Err("Path does not exist".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if new path already exists
|
||||||
|
let check_new = Command::new("wsl")
|
||||||
|
.args(["-e", "test", "-e", new_path])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||||
|
|
||||||
|
if check_new.success() {
|
||||||
|
return Err("Destination already exists".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = Command::new("wsl")
|
||||||
|
.args(["-e", "mv", old_path, new_path])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!("Failed to rename: {}", stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Cost Tracking Commands ====================
|
||||||
|
|
||||||
|
const COST_HISTORY_STORE_KEY: &str = "cost_history";
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_cost_summary(app: AppHandle, days: u32) -> Result<crate::cost_tracking::CostSummary, String> {
|
||||||
|
let history = load_cost_history(&app).await;
|
||||||
|
Ok(history.get_summary(days))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_cost_alerts(app: AppHandle) -> Result<Vec<crate::cost_tracking::CostAlert>, String> {
|
||||||
|
let mut history = load_cost_history(&app).await;
|
||||||
|
let alerts = history.check_alerts();
|
||||||
|
|
||||||
|
// Save updated alert state
|
||||||
|
save_cost_history(&app, &history).await?;
|
||||||
|
|
||||||
|
Ok(alerts)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_cost_alert_thresholds(
|
||||||
|
app: AppHandle,
|
||||||
|
daily: Option<f64>,
|
||||||
|
weekly: Option<f64>,
|
||||||
|
monthly: Option<f64>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut history = load_cost_history(&app).await;
|
||||||
|
history.set_alert_thresholds(daily, weekly, monthly);
|
||||||
|
save_cost_history(&app, &history).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn export_cost_csv(app: AppHandle, days: u32) -> Result<String, String> {
|
||||||
|
let history = load_cost_history(&app).await;
|
||||||
|
Ok(history.export_csv(days))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_today_cost(app: AppHandle) -> Result<f64, String> {
|
||||||
|
let history = load_cost_history(&app).await;
|
||||||
|
Ok(history.get_today_cost())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_week_cost(app: AppHandle) -> Result<f64, String> {
|
||||||
|
let history = load_cost_history(&app).await;
|
||||||
|
Ok(history.get_week_cost())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_month_cost(app: AppHandle) -> Result<f64, String> {
|
||||||
|
let history = load_cost_history(&app).await;
|
||||||
|
Ok(history.get_month_cost())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add cost to history (called internally when stats are updated)
|
||||||
|
pub async fn record_cost(app: &AppHandle, input_tokens: u64, output_tokens: u64, cost_usd: f64) {
|
||||||
|
let mut history = load_cost_history(app).await;
|
||||||
|
history.add_cost(input_tokens, output_tokens, cost_usd);
|
||||||
|
let _ = save_cost_history(app, &history).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a new session
|
||||||
|
pub async fn record_session(app: &AppHandle) {
|
||||||
|
let mut history = load_cost_history(app).await;
|
||||||
|
history.increment_sessions();
|
||||||
|
let _ = save_cost_history(app, &history).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_cost_history(app: &AppHandle) -> crate::cost_tracking::CostHistory {
|
||||||
|
let store = match app.store("hikari-cost-history.json") {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return crate::cost_tracking::CostHistory::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match store.get(COST_HISTORY_STORE_KEY) {
|
||||||
|
Some(value) => serde_json::from_value(value.clone()).unwrap_or_default(),
|
||||||
|
None => crate::cost_tracking::CostHistory::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_cost_history(app: &AppHandle, history: &crate::cost_tracking::CostHistory) -> Result<(), String> {
|
||||||
|
let store = app.store("hikari-cost-history.json").map_err(|e| e.to_string())?;
|
||||||
|
let value = serde_json::to_value(history).map_err(|e| e.to_string())?;
|
||||||
|
store.set(COST_HISTORY_STORE_KEY, value);
|
||||||
|
store.save().map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn init_discord_rpc(
|
||||||
|
discord_rpc: State<'_, std::sync::Arc<crate::discord_rpc::DiscordRpcManager>>,
|
||||||
|
session_name: String,
|
||||||
|
model: String,
|
||||||
|
started_at: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
discord_rpc.init(session_name, model, started_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_discord_rpc(
|
||||||
|
discord_rpc: State<'_, std::sync::Arc<crate::discord_rpc::DiscordRpcManager>>,
|
||||||
|
session_name: String,
|
||||||
|
model: String,
|
||||||
|
started_at: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
discord_rpc.update(session_name, model, started_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stop_discord_rpc(
|
||||||
|
discord_rpc: State<'_, std::sync::Arc<crate::discord_rpc::DiscordRpcManager>>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
discord_rpc.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn log_discord_rpc(
|
||||||
|
discord_rpc: State<'_, std::sync::Arc<crate::discord_rpc::DiscordRpcManager>>,
|
||||||
|
message: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
discord_rpc.log(&message);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -765,7 +1363,10 @@ mod tests {
|
|||||||
fn test_select_wsl_directory_returns_home() {
|
fn test_select_wsl_directory_returns_home() {
|
||||||
let result = run_async(select_wsl_directory());
|
let result = run_async(select_wsl_directory());
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
assert_eq!(result.unwrap(), "/home");
|
|
||||||
|
// Should return the user's home directory
|
||||||
|
let home_dir = result.unwrap();
|
||||||
|
assert!(home_dir.starts_with("/home/") || home_dir == "/root");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== UpdateInfo struct tests ====================
|
// ==================== UpdateInfo struct tests ====================
|
||||||
|
|||||||
@@ -96,6 +96,25 @@ pub struct HikariConfig {
|
|||||||
// Custom theme colors
|
// Custom theme colors
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub custom_theme_colors: CustomThemeColors,
|
pub custom_theme_colors: CustomThemeColors,
|
||||||
|
|
||||||
|
// Token budget settings
|
||||||
|
#[serde(default)]
|
||||||
|
pub budget_enabled: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub session_token_budget: Option<u64>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub session_cost_budget: Option<f64>,
|
||||||
|
|
||||||
|
#[serde(default = "default_budget_action")]
|
||||||
|
pub budget_action: BudgetAction,
|
||||||
|
|
||||||
|
#[serde(default = "default_budget_warning_threshold")]
|
||||||
|
pub budget_warning_threshold: f32,
|
||||||
|
|
||||||
|
#[serde(default = "default_discord_rpc_enabled")]
|
||||||
|
pub discord_rpc_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HikariConfig {
|
impl Default for HikariConfig {
|
||||||
@@ -123,6 +142,12 @@ impl Default for HikariConfig {
|
|||||||
profile_avatar_path: None,
|
profile_avatar_path: None,
|
||||||
profile_bio: None,
|
profile_bio: None,
|
||||||
custom_theme_colors: CustomThemeColors::default(),
|
custom_theme_colors: CustomThemeColors::default(),
|
||||||
|
budget_enabled: false,
|
||||||
|
session_token_budget: None,
|
||||||
|
session_cost_budget: None,
|
||||||
|
budget_action: BudgetAction::Warn,
|
||||||
|
budget_warning_threshold: 0.8,
|
||||||
|
discord_rpc_enabled: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,6 +172,26 @@ fn default_font_size() -> u32 {
|
|||||||
14
|
14
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_budget_action() -> BudgetAction {
|
||||||
|
BudgetAction::Warn
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_budget_warning_threshold() -> f32 {
|
||||||
|
0.8
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_discord_rpc_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum BudgetAction {
|
||||||
|
#[default]
|
||||||
|
Warn,
|
||||||
|
Block,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum Theme {
|
pub enum Theme {
|
||||||
@@ -205,6 +250,12 @@ mod tests {
|
|||||||
assert!(config.profile_avatar_path.is_none());
|
assert!(config.profile_avatar_path.is_none());
|
||||||
assert!(config.profile_bio.is_none());
|
assert!(config.profile_bio.is_none());
|
||||||
assert_eq!(config.custom_theme_colors, CustomThemeColors::default());
|
assert_eq!(config.custom_theme_colors, CustomThemeColors::default());
|
||||||
|
assert!(!config.budget_enabled);
|
||||||
|
assert!(config.session_token_budget.is_none());
|
||||||
|
assert!(config.session_cost_budget.is_none());
|
||||||
|
assert_eq!(config.budget_action, BudgetAction::Warn);
|
||||||
|
assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON);
|
||||||
|
assert!(config.discord_rpc_enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -232,6 +283,12 @@ mod tests {
|
|||||||
profile_avatar_path: None,
|
profile_avatar_path: None,
|
||||||
profile_bio: Some("A test bio".to_string()),
|
profile_bio: Some("A test bio".to_string()),
|
||||||
custom_theme_colors: CustomThemeColors::default(),
|
custom_theme_colors: CustomThemeColors::default(),
|
||||||
|
budget_enabled: true,
|
||||||
|
session_token_budget: Some(100000),
|
||||||
|
session_cost_budget: Some(1.50),
|
||||||
|
budget_action: BudgetAction::Block,
|
||||||
|
budget_warning_threshold: 0.75,
|
||||||
|
discord_rpc_enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
use chrono::{Datelike, Local, NaiveDate, Weekday};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Represents a single day's cost data
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct DailyCost {
|
||||||
|
pub date: String, // ISO date string (YYYY-MM-DD)
|
||||||
|
pub input_tokens: u64,
|
||||||
|
pub output_tokens: u64,
|
||||||
|
pub cost_usd: f64,
|
||||||
|
pub messages_sent: u64,
|
||||||
|
pub sessions_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Historical cost tracking data
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct CostHistory {
|
||||||
|
/// Daily costs indexed by date string (YYYY-MM-DD)
|
||||||
|
pub daily_costs: HashMap<String, DailyCost>,
|
||||||
|
/// Cost alert thresholds
|
||||||
|
pub daily_alert_threshold: Option<f64>,
|
||||||
|
pub weekly_alert_threshold: Option<f64>,
|
||||||
|
pub monthly_alert_threshold: Option<f64>,
|
||||||
|
/// Whether alerts have been triggered today
|
||||||
|
pub daily_alert_triggered: bool,
|
||||||
|
pub weekly_alert_triggered: bool,
|
||||||
|
pub monthly_alert_triggered: bool,
|
||||||
|
pub last_alert_reset_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CostHistory {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get today's date as a string
|
||||||
|
fn today_str() -> String {
|
||||||
|
Local::now().format("%Y-%m-%d").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the start of the current week (Monday)
|
||||||
|
fn week_start() -> NaiveDate {
|
||||||
|
let today = Local::now().date_naive();
|
||||||
|
let days_since_monday = today.weekday().num_days_from_monday();
|
||||||
|
today - chrono::Duration::days(days_since_monday as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the start of the current month
|
||||||
|
fn month_start() -> NaiveDate {
|
||||||
|
let today = Local::now().date_naive();
|
||||||
|
NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add cost for today
|
||||||
|
pub fn add_cost(&mut self, input_tokens: u64, output_tokens: u64, cost_usd: f64) {
|
||||||
|
let today = Self::today_str();
|
||||||
|
|
||||||
|
// Reset alert flags if it's a new day
|
||||||
|
if self.last_alert_reset_date.as_ref() != Some(&today) {
|
||||||
|
self.daily_alert_triggered = false;
|
||||||
|
// Reset weekly on Monday
|
||||||
|
if Local::now().weekday() == Weekday::Mon {
|
||||||
|
self.weekly_alert_triggered = false;
|
||||||
|
}
|
||||||
|
// Reset monthly on the 1st
|
||||||
|
if Local::now().day() == 1 {
|
||||||
|
self.monthly_alert_triggered = false;
|
||||||
|
}
|
||||||
|
self.last_alert_reset_date = Some(today.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let daily = self.daily_costs.entry(today).or_default();
|
||||||
|
daily.input_tokens += input_tokens;
|
||||||
|
daily.output_tokens += output_tokens;
|
||||||
|
daily.cost_usd += cost_usd;
|
||||||
|
daily.messages_sent += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Increment session count for today
|
||||||
|
pub fn increment_sessions(&mut self) {
|
||||||
|
let today = Self::today_str();
|
||||||
|
let daily = self.daily_costs.entry(today.clone()).or_insert_with(|| DailyCost {
|
||||||
|
date: today,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
daily.sessions_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get today's cost
|
||||||
|
pub fn get_today_cost(&self) -> f64 {
|
||||||
|
self.daily_costs
|
||||||
|
.get(&Self::today_str())
|
||||||
|
.map(|d| d.cost_usd)
|
||||||
|
.unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get this week's cost (Monday to Sunday)
|
||||||
|
pub fn get_week_cost(&self) -> f64 {
|
||||||
|
let week_start = Self::week_start();
|
||||||
|
self.daily_costs
|
||||||
|
.values()
|
||||||
|
.filter(|d| {
|
||||||
|
NaiveDate::parse_from_str(&d.date, "%Y-%m-%d")
|
||||||
|
.map(|date| date >= week_start)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.map(|d| d.cost_usd)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get this month's cost
|
||||||
|
pub fn get_month_cost(&self) -> f64 {
|
||||||
|
let month_start = Self::month_start();
|
||||||
|
self.daily_costs
|
||||||
|
.values()
|
||||||
|
.filter(|d| {
|
||||||
|
NaiveDate::parse_from_str(&d.date, "%Y-%m-%d")
|
||||||
|
.map(|date| date >= month_start)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.map(|d| d.cost_usd)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cost summary for a date range
|
||||||
|
pub fn get_summary(&self, days: u32) -> CostSummary {
|
||||||
|
let today = Local::now().date_naive();
|
||||||
|
let start_date = today - chrono::Duration::days(days as i64 - 1);
|
||||||
|
|
||||||
|
let mut total_input_tokens = 0u64;
|
||||||
|
let mut total_output_tokens = 0u64;
|
||||||
|
let mut total_cost = 0.0f64;
|
||||||
|
let mut total_messages = 0u64;
|
||||||
|
let mut total_sessions = 0u64;
|
||||||
|
let mut daily_breakdown = Vec::new();
|
||||||
|
|
||||||
|
for i in 0..days {
|
||||||
|
let date = start_date + chrono::Duration::days(i as i64);
|
||||||
|
let date_str = date.format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
|
if let Some(daily) = self.daily_costs.get(&date_str) {
|
||||||
|
total_input_tokens += daily.input_tokens;
|
||||||
|
total_output_tokens += daily.output_tokens;
|
||||||
|
total_cost += daily.cost_usd;
|
||||||
|
total_messages += daily.messages_sent;
|
||||||
|
total_sessions += daily.sessions_count;
|
||||||
|
daily_breakdown.push(daily.clone());
|
||||||
|
} else {
|
||||||
|
daily_breakdown.push(DailyCost {
|
||||||
|
date: date_str,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CostSummary {
|
||||||
|
period_days: days,
|
||||||
|
total_input_tokens,
|
||||||
|
total_output_tokens,
|
||||||
|
total_cost,
|
||||||
|
total_messages,
|
||||||
|
total_sessions,
|
||||||
|
average_daily_cost: if days > 0 { total_cost / days as f64 } else { 0.0 },
|
||||||
|
daily_breakdown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if any alert thresholds are exceeded and return which ones
|
||||||
|
pub fn check_alerts(&mut self) -> Vec<CostAlert> {
|
||||||
|
let mut alerts = Vec::new();
|
||||||
|
|
||||||
|
if let Some(threshold) = self.daily_alert_threshold {
|
||||||
|
let today_cost = self.get_today_cost();
|
||||||
|
if today_cost >= threshold && !self.daily_alert_triggered {
|
||||||
|
self.daily_alert_triggered = true;
|
||||||
|
alerts.push(CostAlert {
|
||||||
|
alert_type: AlertType::Daily,
|
||||||
|
threshold,
|
||||||
|
current_cost: today_cost,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(threshold) = self.weekly_alert_threshold {
|
||||||
|
let week_cost = self.get_week_cost();
|
||||||
|
if week_cost >= threshold && !self.weekly_alert_triggered {
|
||||||
|
self.weekly_alert_triggered = true;
|
||||||
|
alerts.push(CostAlert {
|
||||||
|
alert_type: AlertType::Weekly,
|
||||||
|
threshold,
|
||||||
|
current_cost: week_cost,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(threshold) = self.monthly_alert_threshold {
|
||||||
|
let month_cost = self.get_month_cost();
|
||||||
|
if month_cost >= threshold && !self.monthly_alert_triggered {
|
||||||
|
self.monthly_alert_triggered = true;
|
||||||
|
alerts.push(CostAlert {
|
||||||
|
alert_type: AlertType::Monthly,
|
||||||
|
threshold,
|
||||||
|
current_cost: month_cost,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alerts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set alert thresholds
|
||||||
|
pub fn set_alert_thresholds(
|
||||||
|
&mut self,
|
||||||
|
daily: Option<f64>,
|
||||||
|
weekly: Option<f64>,
|
||||||
|
monthly: Option<f64>,
|
||||||
|
) {
|
||||||
|
self.daily_alert_threshold = daily;
|
||||||
|
self.weekly_alert_threshold = weekly;
|
||||||
|
self.monthly_alert_threshold = monthly;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up old data (keep last N days)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn cleanup_old_data(&mut self, keep_days: u32) {
|
||||||
|
let cutoff = Local::now().date_naive() - chrono::Duration::days(keep_days as i64);
|
||||||
|
self.daily_costs.retain(|date_str, _| {
|
||||||
|
NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
||||||
|
.map(|date| date >= cutoff)
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export to CSV format
|
||||||
|
pub fn export_csv(&self, days: u32) -> String {
|
||||||
|
let summary = self.get_summary(days);
|
||||||
|
let mut csv = String::from("Date,Input Tokens,Output Tokens,Cost (USD),Messages,Sessions\n");
|
||||||
|
|
||||||
|
for daily in &summary.daily_breakdown {
|
||||||
|
csv.push_str(&format!(
|
||||||
|
"{},{},{},{:.4},{},{}\n",
|
||||||
|
daily.date,
|
||||||
|
daily.input_tokens,
|
||||||
|
daily.output_tokens,
|
||||||
|
daily.cost_usd,
|
||||||
|
daily.messages_sent,
|
||||||
|
daily.sessions_count
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add totals row
|
||||||
|
csv.push_str(&format!(
|
||||||
|
"TOTAL,{},{},{:.4},{},{}\n",
|
||||||
|
summary.total_input_tokens,
|
||||||
|
summary.total_output_tokens,
|
||||||
|
summary.total_cost,
|
||||||
|
summary.total_messages,
|
||||||
|
summary.total_sessions
|
||||||
|
));
|
||||||
|
|
||||||
|
csv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cost summary for a period
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CostSummary {
|
||||||
|
pub period_days: u32,
|
||||||
|
pub total_input_tokens: u64,
|
||||||
|
pub total_output_tokens: u64,
|
||||||
|
pub total_cost: f64,
|
||||||
|
pub total_messages: u64,
|
||||||
|
pub total_sessions: u64,
|
||||||
|
pub average_daily_cost: f64,
|
||||||
|
pub daily_breakdown: Vec<DailyCost>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alert types
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum AlertType {
|
||||||
|
Daily,
|
||||||
|
Weekly,
|
||||||
|
Monthly,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cost alert notification
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CostAlert {
|
||||||
|
pub alert_type: AlertType,
|
||||||
|
pub threshold: f64,
|
||||||
|
pub current_cost: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_cost() {
|
||||||
|
let mut history = CostHistory::new();
|
||||||
|
history.add_cost(1000, 500, 0.05);
|
||||||
|
|
||||||
|
let today_cost = history.get_today_cost();
|
||||||
|
assert!((today_cost - 0.05).abs() < 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_accumulate_daily_cost() {
|
||||||
|
let mut history = CostHistory::new();
|
||||||
|
history.add_cost(1000, 500, 0.05);
|
||||||
|
history.add_cost(2000, 1000, 0.10);
|
||||||
|
|
||||||
|
let today_cost = history.get_today_cost();
|
||||||
|
assert!((today_cost - 0.15).abs() < 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_summary() {
|
||||||
|
let mut history = CostHistory::new();
|
||||||
|
history.add_cost(1000, 500, 0.05);
|
||||||
|
|
||||||
|
let summary = history.get_summary(7);
|
||||||
|
assert_eq!(summary.period_days, 7);
|
||||||
|
assert!((summary.total_cost - 0.05).abs() < 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_daily_alert() {
|
||||||
|
let mut history = CostHistory::new();
|
||||||
|
history.set_alert_thresholds(Some(0.10), None, None);
|
||||||
|
|
||||||
|
history.add_cost(1000, 500, 0.05);
|
||||||
|
let alerts = history.check_alerts();
|
||||||
|
assert!(alerts.is_empty());
|
||||||
|
|
||||||
|
history.add_cost(1000, 500, 0.06);
|
||||||
|
let alerts = history.check_alerts();
|
||||||
|
assert_eq!(alerts.len(), 1);
|
||||||
|
assert_eq!(alerts[0].alert_type, AlertType::Daily);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_alert_only_triggers_once() {
|
||||||
|
let mut history = CostHistory::new();
|
||||||
|
history.set_alert_thresholds(Some(0.10), None, None);
|
||||||
|
|
||||||
|
history.add_cost(1000, 500, 0.15);
|
||||||
|
let alerts = history.check_alerts();
|
||||||
|
assert_eq!(alerts.len(), 1);
|
||||||
|
|
||||||
|
// Second check should not trigger again
|
||||||
|
let alerts = history.check_alerts();
|
||||||
|
assert!(alerts.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_export_csv() {
|
||||||
|
let mut history = CostHistory::new();
|
||||||
|
history.add_cost(1000, 500, 0.05);
|
||||||
|
|
||||||
|
let csv = history.export_csv(1);
|
||||||
|
assert!(csv.contains("Date,Input Tokens"));
|
||||||
|
assert!(csv.contains("TOTAL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_increment_sessions() {
|
||||||
|
let mut history = CostHistory::new();
|
||||||
|
history.increment_sessions();
|
||||||
|
history.increment_sessions();
|
||||||
|
|
||||||
|
let summary = history.get_summary(1);
|
||||||
|
assert_eq!(summary.total_sessions, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
use discord_rich_presence::activity::{Activity, Assets, Timestamps};
|
||||||
|
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
pub struct DiscordRpcManager {
|
||||||
|
client: Arc<RwLock<Option<DiscordIpcClient>>>,
|
||||||
|
session_name: Arc<RwLock<String>>,
|
||||||
|
model: Arc<RwLock<String>>,
|
||||||
|
started_at: Arc<RwLock<i64>>,
|
||||||
|
log_path: Arc<RwLock<Option<PathBuf>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiscordRpcManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
client: Arc::new(RwLock::new(None)),
|
||||||
|
session_name: Arc::new(RwLock::new(String::new())),
|
||||||
|
model: Arc::new(RwLock::new(String::new())),
|
||||||
|
started_at: Arc::new(RwLock::new(0)),
|
||||||
|
log_path: Arc::new(RwLock::new(None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_app_handle(&self, app_handle: &AppHandle) {
|
||||||
|
if let Ok(app_data_dir) = app_handle.path().app_data_dir() {
|
||||||
|
// Ensure the directory exists
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&app_data_dir) {
|
||||||
|
eprintln!("Failed to create app data directory: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let log_path = app_data_dir.join("hikari_discord_rpc.log");
|
||||||
|
*self.log_path.write() = Some(log_path.clone());
|
||||||
|
self.log(&format!(
|
||||||
|
"Log file initialised at: {}",
|
||||||
|
log_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn log(&self, message: &str) {
|
||||||
|
let log_path_guard = self.log_path.read();
|
||||||
|
let path = match log_path_guard.as_ref() {
|
||||||
|
Some(p) => p.clone(),
|
||||||
|
None => PathBuf::from("hikari_discord_rpc.log"),
|
||||||
|
};
|
||||||
|
drop(log_path_guard);
|
||||||
|
|
||||||
|
if let Ok(mut file) = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&path)
|
||||||
|
{
|
||||||
|
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||||
|
let _ = writeln!(file, "[{}] {}", timestamp, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init(&self, initial_session_name: String, initial_model: String, started_at: i64) -> Result<(), String> {
|
||||||
|
self.log("Attempting to initialize Discord RPC...");
|
||||||
|
self.log("DEBUG: Application ID: 1391117878182281316");
|
||||||
|
self.log(&format!("DEBUG: Initial session: '{}', model: '{}', timestamp: {}",
|
||||||
|
initial_session_name, initial_model, started_at));
|
||||||
|
|
||||||
|
let mut client = DiscordIpcClient::new("1391117878182281316")
|
||||||
|
.map_err(|e| {
|
||||||
|
let error_msg = format!("Failed to create Discord RPC client: {} (is Discord running?)", e);
|
||||||
|
self.log(&format!("ERROR: {}", error_msg));
|
||||||
|
error_msg
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.log("DEBUG: DiscordIpcClient created successfully");
|
||||||
|
|
||||||
|
client
|
||||||
|
.connect()
|
||||||
|
.map_err(|e| {
|
||||||
|
let error_msg = format!("Failed to connect to Discord RPC: {} (ensure Discord is running)", e);
|
||||||
|
self.log(&format!("ERROR: {}", error_msg));
|
||||||
|
error_msg
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.log("DEBUG: Connected to Discord IPC socket");
|
||||||
|
|
||||||
|
// Set initial activity immediately after connecting
|
||||||
|
self.log("DEBUG: Building initial activity...");
|
||||||
|
let state_text = format!("Model: {}", initial_model);
|
||||||
|
let assets = Assets::new()
|
||||||
|
.large_image("hikari")
|
||||||
|
.large_text("Hikari - Claude Code Assistant");
|
||||||
|
|
||||||
|
self.log("DEBUG: Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
|
||||||
|
|
||||||
|
let timestamps = Timestamps::new()
|
||||||
|
.start(started_at);
|
||||||
|
|
||||||
|
self.log(&format!("DEBUG: Timestamps created - start: {}", started_at));
|
||||||
|
|
||||||
|
let activity = Activity::new()
|
||||||
|
.details(initial_session_name.as_str())
|
||||||
|
.state(state_text.as_str())
|
||||||
|
.assets(assets)
|
||||||
|
.timestamps(timestamps);
|
||||||
|
|
||||||
|
self.log(&format!("DEBUG: Activity created - details: '{}', state: '{}'",
|
||||||
|
initial_session_name, state_text));
|
||||||
|
|
||||||
|
self.log("DEBUG: Attempting to set initial activity...");
|
||||||
|
client
|
||||||
|
.set_activity(activity)
|
||||||
|
.map_err(|e| {
|
||||||
|
let error_msg = format!("Failed to set initial Discord RPC activity: {}", e);
|
||||||
|
self.log(&format!("ERROR: {}", error_msg));
|
||||||
|
error_msg
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.log("DEBUG: Initial activity set successfully!");
|
||||||
|
|
||||||
|
// Store the client and initial state
|
||||||
|
*self.client.write() = Some(client);
|
||||||
|
*self.session_name.write() = initial_session_name.clone();
|
||||||
|
*self.model.write() = initial_model.clone();
|
||||||
|
*self.started_at.write() = started_at;
|
||||||
|
|
||||||
|
self.log(&format!("Discord RPC connected successfully with initial activity: session='{}', model='{}'",
|
||||||
|
initial_session_name, initial_model));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(
|
||||||
|
&self,
|
||||||
|
session_name: String,
|
||||||
|
model: String,
|
||||||
|
started_at: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
self.log(&format!("DEBUG: update() called with session='{}', model='{}', timestamp={}",
|
||||||
|
session_name, model, started_at));
|
||||||
|
|
||||||
|
*self.session_name.write() = session_name.clone();
|
||||||
|
*self.model.write() = model.clone();
|
||||||
|
*self.started_at.write() = started_at;
|
||||||
|
|
||||||
|
self.log("DEBUG: State variables updated");
|
||||||
|
|
||||||
|
let mut client_guard = self.client.write();
|
||||||
|
let client = client_guard
|
||||||
|
.as_mut()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
let error_msg = "Discord RPC client not initialized".to_string();
|
||||||
|
self.log(&format!("ERROR: {}", error_msg));
|
||||||
|
error_msg
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.log("DEBUG: Client lock acquired");
|
||||||
|
|
||||||
|
let state_text = format!("Model: {}", model);
|
||||||
|
let assets = Assets::new()
|
||||||
|
.large_image("hikari")
|
||||||
|
.large_text("Hikari - Claude Code Assistant");
|
||||||
|
|
||||||
|
self.log("DEBUG: Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
|
||||||
|
|
||||||
|
let timestamps = Timestamps::new()
|
||||||
|
.start(started_at);
|
||||||
|
|
||||||
|
self.log(&format!("DEBUG: Timestamps created - start: {}", started_at));
|
||||||
|
|
||||||
|
let activity = Activity::new()
|
||||||
|
.details(session_name.as_str())
|
||||||
|
.state(state_text.as_str())
|
||||||
|
.assets(assets)
|
||||||
|
.timestamps(timestamps);
|
||||||
|
|
||||||
|
self.log(&format!("DEBUG: Activity created - details: '{}', state: '{}'",
|
||||||
|
session_name, state_text));
|
||||||
|
|
||||||
|
self.log("DEBUG: Attempting to set activity...");
|
||||||
|
client
|
||||||
|
.set_activity(activity)
|
||||||
|
.map_err(|e| {
|
||||||
|
let error_msg = format!("Failed to update Discord RPC: {}", e);
|
||||||
|
self.log(&format!("ERROR: {}", error_msg));
|
||||||
|
error_msg
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.log(&format!("Updated Discord RPC: session='{}', model='{}'", session_name, model));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&self) -> Result<(), String> {
|
||||||
|
self.log("DEBUG: stop() called");
|
||||||
|
|
||||||
|
let mut client_guard = self.client.write();
|
||||||
|
if let Some(mut client) = client_guard.take() {
|
||||||
|
self.log("DEBUG: Client found, attempting to close...");
|
||||||
|
client
|
||||||
|
.close()
|
||||||
|
.map_err(|e| {
|
||||||
|
let error_msg = format!("Failed to close Discord RPC: {}", e);
|
||||||
|
self.log(&format!("ERROR: {}", error_msg));
|
||||||
|
error_msg
|
||||||
|
})?;
|
||||||
|
self.log("Discord RPC stopped successfully");
|
||||||
|
} else {
|
||||||
|
self.log("DEBUG: No client to stop (already stopped or never initialized)");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DiscordRpcManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ mod bridge_manager;
|
|||||||
mod clipboard;
|
mod clipboard;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod cost_tracking;
|
||||||
|
mod discord_rpc;
|
||||||
mod git;
|
mod git;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
mod quick_actions;
|
mod quick_actions;
|
||||||
@@ -10,6 +12,7 @@ mod sessions;
|
|||||||
mod snippets;
|
mod snippets;
|
||||||
mod stats;
|
mod stats;
|
||||||
mod temp_manager;
|
mod temp_manager;
|
||||||
|
mod tool_cache;
|
||||||
mod tray;
|
mod tray;
|
||||||
mod types;
|
mod types;
|
||||||
mod vbs_notification;
|
mod vbs_notification;
|
||||||
@@ -21,11 +24,13 @@ use bridge_manager::create_shared_bridge_manager;
|
|||||||
use clipboard::*;
|
use clipboard::*;
|
||||||
use commands::load_saved_achievements;
|
use commands::load_saved_achievements;
|
||||||
use commands::*;
|
use commands::*;
|
||||||
|
use discord_rpc::DiscordRpcManager;
|
||||||
use git::*;
|
use git::*;
|
||||||
use notifications::*;
|
use notifications::*;
|
||||||
use quick_actions::*;
|
use quick_actions::*;
|
||||||
use sessions::*;
|
use sessions::*;
|
||||||
use snippets::*;
|
use snippets::*;
|
||||||
|
use std::sync::Arc;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
use temp_manager::create_shared_temp_manager;
|
use temp_manager::create_shared_temp_manager;
|
||||||
use tray::{setup_tray, should_minimize_to_tray};
|
use tray::{setup_tray, should_minimize_to_tray};
|
||||||
@@ -37,6 +42,7 @@ use wsl_notifications::*;
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
let bridge_manager = create_shared_bridge_manager();
|
let bridge_manager = create_shared_bridge_manager();
|
||||||
let temp_manager = create_shared_temp_manager().expect("Failed to create temp file manager");
|
let temp_manager = create_shared_temp_manager().expect("Failed to create temp file manager");
|
||||||
|
let discord_rpc = Arc::new(DiscordRpcManager::new());
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
@@ -50,10 +56,14 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.manage(bridge_manager.clone())
|
.manage(bridge_manager.clone())
|
||||||
.manage(temp_manager.clone())
|
.manage(temp_manager.clone())
|
||||||
|
.manage(discord_rpc.clone())
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
// Initialize the app handle in the bridge manager
|
// Initialize the app handle in the bridge manager
|
||||||
bridge_manager.lock().set_app_handle(app.handle().clone());
|
bridge_manager.lock().set_app_handle(app.handle().clone());
|
||||||
|
|
||||||
|
// Initialize the app handle in the Discord RPC manager for logging
|
||||||
|
discord_rpc.set_app_handle(app.handle());
|
||||||
|
|
||||||
// Clean up any orphaned temp files from previous sessions
|
// Clean up any orphaned temp files from previous sessions
|
||||||
if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() {
|
if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() {
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
@@ -159,6 +169,18 @@ pub fn run() {
|
|||||||
delete_file,
|
delete_file,
|
||||||
delete_directory,
|
delete_directory,
|
||||||
rename_path,
|
rename_path,
|
||||||
|
// Cost tracking commands
|
||||||
|
get_cost_summary,
|
||||||
|
get_cost_alerts,
|
||||||
|
set_cost_alert_thresholds,
|
||||||
|
export_cost_csv,
|
||||||
|
get_today_cost,
|
||||||
|
get_week_cost,
|
||||||
|
get_month_cost,
|
||||||
|
init_discord_rpc,
|
||||||
|
update_discord_rpc,
|
||||||
|
stop_discord_rpc,
|
||||||
|
log_discord_rpc,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
+657
-24
@@ -5,6 +5,110 @@ use std::collections::HashMap;
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tauri_plugin_store::StoreExt;
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
|
/// Per-tool token usage statistics
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ToolTokenStats {
|
||||||
|
pub call_count: u64,
|
||||||
|
pub estimated_input_tokens: u64,
|
||||||
|
pub estimated_output_tokens: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolTokenStats {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn increment_call(&mut self) {
|
||||||
|
self.call_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_tokens(&mut self, input: u64, output: u64) {
|
||||||
|
self.estimated_input_tokens += input;
|
||||||
|
self.estimated_output_tokens += output;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn total_tokens(&self) -> u64 {
|
||||||
|
self.estimated_input_tokens + self.estimated_output_tokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warning levels for context window utilisation
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ContextWarning {
|
||||||
|
/// 50-74% utilisation - conversation is getting long
|
||||||
|
Moderate,
|
||||||
|
/// 75-89% utilisation - consider summarising
|
||||||
|
High,
|
||||||
|
/// 90%+ utilisation - approaching limit
|
||||||
|
Critical,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Budget status indicating whether user is within their limits
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum BudgetStatus {
|
||||||
|
/// Within budget, no concerns
|
||||||
|
Ok,
|
||||||
|
/// Approaching budget limit (warning threshold reached)
|
||||||
|
Warning {
|
||||||
|
budget_type: BudgetType,
|
||||||
|
percent_used: f32,
|
||||||
|
},
|
||||||
|
/// Budget exceeded
|
||||||
|
Exceeded { budget_type: BudgetType },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type of budget limit
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum BudgetType {
|
||||||
|
Token,
|
||||||
|
Cost,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContextWarning {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn message(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ContextWarning::Moderate => "Context window is 50%+ full. Consider starting a new conversation for better performance.",
|
||||||
|
ContextWarning::High => "Context window is 75%+ full. Responses may degrade. Consider summarising or starting fresh.",
|
||||||
|
ContextWarning::Critical => "Context window is nearly full (90%+)! Start a new conversation to avoid errors.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the context window limit (in tokens) for a given model
|
||||||
|
fn get_context_window_limit(model: &str) -> u64 {
|
||||||
|
match model {
|
||||||
|
// Claude 4.5 family - 200K standard context
|
||||||
|
"claude-opus-4-5-20251101"
|
||||||
|
| "claude-sonnet-4-5-20250929"
|
||||||
|
| "claude-haiku-4-5-20251001" => 200_000,
|
||||||
|
// Claude 4.x family - 200K standard context
|
||||||
|
"claude-opus-4-1-20250805"
|
||||||
|
| "claude-opus-4-20250514"
|
||||||
|
| "claude-sonnet-4-20250514" => 200_000,
|
||||||
|
// Claude 3.x family
|
||||||
|
"claude-3-7-sonnet-20250219"
|
||||||
|
| "claude-3-5-sonnet-20241022"
|
||||||
|
| "claude-3-5-sonnet-20240620"
|
||||||
|
| "claude-3-5-haiku-20241022"
|
||||||
|
| "claude-3-opus-20240229"
|
||||||
|
| "claude-3-sonnet-20240229"
|
||||||
|
| "claude-3-haiku-20240307" => 200_000,
|
||||||
|
// Default to 200K for unknown Claude models
|
||||||
|
_ if model.starts_with("claude") => 200_000,
|
||||||
|
// For non-Claude models (Ollama, etc.), use a conservative default
|
||||||
|
_ => 128_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct UsageStats {
|
pub struct UsageStats {
|
||||||
pub total_input_tokens: u64,
|
pub total_input_tokens: u64,
|
||||||
@@ -24,8 +128,8 @@ pub struct UsageStats {
|
|||||||
pub session_files_edited: u64,
|
pub session_files_edited: u64,
|
||||||
pub files_created: u64,
|
pub files_created: u64,
|
||||||
pub session_files_created: u64,
|
pub session_files_created: u64,
|
||||||
pub tools_usage: HashMap<String, u64>,
|
pub tools_usage: HashMap<String, ToolTokenStats>,
|
||||||
pub session_tools_usage: HashMap<String, u64>,
|
pub session_tools_usage: HashMap<String, ToolTokenStats>,
|
||||||
pub session_duration_seconds: u64,
|
pub session_duration_seconds: u64,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub session_start: Option<Instant>,
|
pub session_start: Option<Instant>,
|
||||||
@@ -38,6 +142,15 @@ pub struct UsageStats {
|
|||||||
pub night_sessions: u64, // Sessions started after 10 PM
|
pub night_sessions: u64, // Sessions started after 10 PM
|
||||||
pub last_session_date: Option<String>, // ISO date string for streak tracking
|
pub last_session_date: Option<String>, // ISO date string for streak tracking
|
||||||
|
|
||||||
|
// Context window tracking
|
||||||
|
pub context_tokens_used: u64,
|
||||||
|
pub context_window_limit: u64,
|
||||||
|
pub context_utilisation_percent: f32,
|
||||||
|
|
||||||
|
// Cache analytics (tracks potential savings from repeated tool calls)
|
||||||
|
pub potential_cache_hits: u64,
|
||||||
|
pub potential_cache_savings_tokens: u64,
|
||||||
|
|
||||||
// Achievement tracking
|
// Achievement tracking
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub achievements: AchievementProgress,
|
pub achievements: AchievementProgress,
|
||||||
@@ -61,6 +174,114 @@ impl UsageStats {
|
|||||||
self.session_cost_usd += cost;
|
self.session_cost_usd += cost;
|
||||||
|
|
||||||
self.model = Some(model.to_string());
|
self.model = Some(model.to_string());
|
||||||
|
|
||||||
|
// Update context window tracking
|
||||||
|
self.update_context_tracking(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_context_tracking(&mut self, model: &str) {
|
||||||
|
// Get context window limit for the current model
|
||||||
|
self.context_window_limit = get_context_window_limit(model);
|
||||||
|
|
||||||
|
// Context tokens = input tokens (the prompt/context sent to the model)
|
||||||
|
// We track cumulative session input as a proxy for context growth
|
||||||
|
self.context_tokens_used = self.session_input_tokens;
|
||||||
|
|
||||||
|
// Calculate utilisation percentage
|
||||||
|
if self.context_window_limit > 0 {
|
||||||
|
self.context_utilisation_percent =
|
||||||
|
(self.context_tokens_used as f32 / self.context_window_limit as f32) * 100.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn get_context_warning(&self) -> Option<ContextWarning> {
|
||||||
|
if self.context_utilisation_percent >= 90.0 {
|
||||||
|
Some(ContextWarning::Critical)
|
||||||
|
} else if self.context_utilisation_percent >= 75.0 {
|
||||||
|
Some(ContextWarning::High)
|
||||||
|
} else if self.context_utilisation_percent >= 50.0 {
|
||||||
|
Some(ContextWarning::Moderate)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn estimate_remaining_tokens(&self) -> u64 {
|
||||||
|
self.context_window_limit
|
||||||
|
.saturating_sub(self.context_tokens_used)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check budget status given current usage and budget settings
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn check_budget(
|
||||||
|
&self,
|
||||||
|
budget_enabled: bool,
|
||||||
|
token_budget: Option<u64>,
|
||||||
|
cost_budget: Option<f64>,
|
||||||
|
warning_threshold: f32,
|
||||||
|
) -> BudgetStatus {
|
||||||
|
if !budget_enabled {
|
||||||
|
return BudgetStatus::Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_tokens = self.session_input_tokens + self.session_output_tokens;
|
||||||
|
|
||||||
|
// Check token budget
|
||||||
|
if let Some(limit) = token_budget {
|
||||||
|
if session_tokens >= limit {
|
||||||
|
return BudgetStatus::Exceeded {
|
||||||
|
budget_type: BudgetType::Token,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let percent_used = session_tokens as f32 / limit as f32;
|
||||||
|
if percent_used >= warning_threshold {
|
||||||
|
return BudgetStatus::Warning {
|
||||||
|
budget_type: BudgetType::Token,
|
||||||
|
percent_used: percent_used * 100.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cost budget
|
||||||
|
if let Some(limit) = cost_budget {
|
||||||
|
if self.session_cost_usd >= limit {
|
||||||
|
return BudgetStatus::Exceeded {
|
||||||
|
budget_type: BudgetType::Cost,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let percent_used = (self.session_cost_usd / limit) as f32;
|
||||||
|
if percent_used >= warning_threshold {
|
||||||
|
return BudgetStatus::Warning {
|
||||||
|
budget_type: BudgetType::Cost,
|
||||||
|
percent_used: percent_used * 100.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BudgetStatus::Ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get remaining token budget (None if no budget set)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn get_remaining_token_budget(&self, token_budget: Option<u64>) -> Option<u64> {
|
||||||
|
token_budget.map(|limit| {
|
||||||
|
let used = self.session_input_tokens + self.session_output_tokens;
|
||||||
|
limit.saturating_sub(used)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get remaining cost budget (None if no budget set)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn get_remaining_cost_budget(&self, cost_budget: Option<f64>) -> Option<f64> {
|
||||||
|
cost_budget.map(|limit| {
|
||||||
|
if limit > self.session_cost_usd {
|
||||||
|
limit - self.session_cost_usd
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset_session(&mut self) {
|
pub fn reset_session(&mut self) {
|
||||||
@@ -76,6 +297,13 @@ impl UsageStats {
|
|||||||
self.session_start = Some(Instant::now());
|
self.session_start = Some(Instant::now());
|
||||||
self.achievements.start_session();
|
self.achievements.start_session();
|
||||||
|
|
||||||
|
// Reset context window tracking
|
||||||
|
self.context_tokens_used = 0;
|
||||||
|
self.context_utilisation_percent = 0.0;
|
||||||
|
|
||||||
|
// Note: Cache analytics are NOT reset here - they're cumulative across sessions
|
||||||
|
// to show total potential savings over time
|
||||||
|
|
||||||
// Track session start for achievements
|
// Track session start for achievements
|
||||||
self.track_session_start();
|
self.track_session_start();
|
||||||
}
|
}
|
||||||
@@ -139,11 +367,32 @@ impl UsageStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn increment_tool_usage(&mut self, tool_name: &str) {
|
pub fn increment_tool_usage(&mut self, tool_name: &str) {
|
||||||
*self.tools_usage.entry(tool_name.to_string()).or_insert(0) += 1;
|
self.tools_usage
|
||||||
*self
|
|
||||||
.session_tools_usage
|
|
||||||
.entry(tool_name.to_string())
|
.entry(tool_name.to_string())
|
||||||
.or_insert(0) += 1;
|
.or_default()
|
||||||
|
.increment_call();
|
||||||
|
self.session_tools_usage
|
||||||
|
.entry(tool_name.to_string())
|
||||||
|
.or_default()
|
||||||
|
.increment_call();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_tool_tokens(&mut self, tool_name: &str, input_tokens: u64, output_tokens: u64) {
|
||||||
|
self.tools_usage
|
||||||
|
.entry(tool_name.to_string())
|
||||||
|
.or_default()
|
||||||
|
.add_tokens(input_tokens, output_tokens);
|
||||||
|
self.session_tools_usage
|
||||||
|
.entry(tool_name.to_string())
|
||||||
|
.or_default()
|
||||||
|
.add_tokens(input_tokens, output_tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a potential cache hit (when the same tool call is made twice)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn add_potential_cache_hit(&mut self, tokens_saved: u64) {
|
||||||
|
self.potential_cache_hits += 1;
|
||||||
|
self.potential_cache_savings_tokens += tokens_saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_session_duration(&mut self) -> u64 {
|
pub fn get_session_duration(&mut self) -> u64 {
|
||||||
@@ -184,6 +433,11 @@ impl UsageStats {
|
|||||||
morning_sessions: self.morning_sessions,
|
morning_sessions: self.morning_sessions,
|
||||||
night_sessions: self.night_sessions,
|
night_sessions: self.night_sessions,
|
||||||
last_session_date: self.last_session_date.clone(),
|
last_session_date: self.last_session_date.clone(),
|
||||||
|
context_tokens_used: self.context_tokens_used,
|
||||||
|
context_window_limit: self.context_window_limit,
|
||||||
|
context_utilisation_percent: self.context_utilisation_percent,
|
||||||
|
potential_cache_hits: self.potential_cache_hits,
|
||||||
|
potential_cache_savings_tokens: self.potential_cache_savings_tokens,
|
||||||
achievements: AchievementProgress::new(), // Dummy for copy
|
achievements: AchievementProgress::new(), // Dummy for copy
|
||||||
};
|
};
|
||||||
check_achievements(&stats_copy, &mut self.achievements)
|
check_achievements(&stats_copy, &mut self.achievements)
|
||||||
@@ -206,20 +460,22 @@ fn is_consecutive_day(prev_date: &str, current_date: &str) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pricing as of January 2025
|
// Pricing as of February 2026
|
||||||
// https://www.anthropic.com/pricing
|
// https://platform.claude.com/docs/en/about-claude/models/overview
|
||||||
fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
|
pub fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
|
||||||
let (input_price_per_million, output_price_per_million) = match model {
|
let (input_price_per_million, output_price_per_million) = match model {
|
||||||
// Opus 4.5
|
// Current generation (Claude 4.5)
|
||||||
"claude-opus-4-5-20251101" => (15.0, 75.0),
|
"claude-opus-4-5-20251101" => (5.0, 25.0),
|
||||||
|
"claude-sonnet-4-5-20250929" => (3.0, 15.0),
|
||||||
|
"claude-haiku-4-5-20251001" => (1.0, 5.0),
|
||||||
|
|
||||||
// Opus 4
|
// Previous generation (Claude 4.x)
|
||||||
|
"claude-opus-4-1-20250805" => (15.0, 75.0),
|
||||||
"claude-opus-4-20250514" => (15.0, 75.0),
|
"claude-opus-4-20250514" => (15.0, 75.0),
|
||||||
|
|
||||||
// Sonnet 4
|
|
||||||
"claude-sonnet-4-20250514" => (3.0, 15.0),
|
"claude-sonnet-4-20250514" => (3.0, 15.0),
|
||||||
|
|
||||||
// Previous generation models
|
// Legacy (Claude 3.x)
|
||||||
|
"claude-3-7-sonnet-20250219" => (3.0, 15.0),
|
||||||
"claude-3-5-sonnet-20241022" => (3.0, 15.0),
|
"claude-3-5-sonnet-20241022" => (3.0, 15.0),
|
||||||
"claude-3-5-sonnet-20240620" => (3.0, 15.0),
|
"claude-3-5-sonnet-20240620" => (3.0, 15.0),
|
||||||
"claude-3-5-haiku-20241022" => (1.0, 5.0),
|
"claude-3-5-haiku-20241022" => (1.0, 5.0),
|
||||||
@@ -252,7 +508,7 @@ pub struct PersistedStats {
|
|||||||
pub code_blocks_generated: u64,
|
pub code_blocks_generated: u64,
|
||||||
pub files_edited: u64,
|
pub files_edited: u64,
|
||||||
pub files_created: u64,
|
pub files_created: u64,
|
||||||
pub tools_usage: HashMap<String, u64>,
|
pub tools_usage: HashMap<String, ToolTokenStats>,
|
||||||
pub sessions_started: u64,
|
pub sessions_started: u64,
|
||||||
pub consecutive_days: u64,
|
pub consecutive_days: u64,
|
||||||
pub total_days_used: u64,
|
pub total_days_used: u64,
|
||||||
@@ -372,8 +628,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_cost_calculation_opus_45() {
|
fn test_cost_calculation_opus_45() {
|
||||||
let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101");
|
let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101");
|
||||||
// Same pricing as Opus 4
|
// Opus 4.5 pricing: $5/MTok input, $25/MTok output
|
||||||
assert!((cost - 0.165).abs() < 0.0001);
|
// 1000 input tokens = $0.005, 2000 output tokens = $0.05
|
||||||
|
// Total = $0.055
|
||||||
|
assert!((cost - 0.055).abs() < 0.0001);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -512,10 +770,33 @@ mod tests {
|
|||||||
stats.increment_tool_usage("Read");
|
stats.increment_tool_usage("Read");
|
||||||
stats.increment_tool_usage("Write");
|
stats.increment_tool_usage("Write");
|
||||||
|
|
||||||
assert_eq!(stats.tools_usage.get("Read"), Some(&2));
|
assert_eq!(stats.tools_usage.get("Read").map(|t| t.call_count), Some(2));
|
||||||
assert_eq!(stats.tools_usage.get("Write"), Some(&1));
|
assert_eq!(stats.tools_usage.get("Write").map(|t| t.call_count), Some(1));
|
||||||
assert_eq!(stats.session_tools_usage.get("Read"), Some(&2));
|
assert_eq!(stats.session_tools_usage.get("Read").map(|t| t.call_count), Some(2));
|
||||||
assert_eq!(stats.session_tools_usage.get("Write"), Some(&1));
|
assert_eq!(stats.session_tools_usage.get("Write").map(|t| t.call_count), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_tool_tokens() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.increment_tool_usage("Read");
|
||||||
|
stats.add_tool_tokens("Read", 100, 50);
|
||||||
|
stats.add_tool_tokens("Read", 200, 100);
|
||||||
|
|
||||||
|
let read_stats = stats.tools_usage.get("Read").unwrap();
|
||||||
|
assert_eq!(read_stats.call_count, 1);
|
||||||
|
assert_eq!(read_stats.estimated_input_tokens, 300);
|
||||||
|
assert_eq!(read_stats.estimated_output_tokens, 150);
|
||||||
|
assert_eq!(read_stats.total_tokens(), 450);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tool_token_stats_default() {
|
||||||
|
let tool_stats = ToolTokenStats::new();
|
||||||
|
assert_eq!(tool_stats.call_count, 0);
|
||||||
|
assert_eq!(tool_stats.estimated_input_tokens, 0);
|
||||||
|
assert_eq!(tool_stats.estimated_output_tokens, 0);
|
||||||
|
assert_eq!(tool_stats.total_tokens(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -590,7 +871,11 @@ mod tests {
|
|||||||
files_created: 5,
|
files_created: 5,
|
||||||
tools_usage: {
|
tools_usage: {
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
map.insert("Read".to_string(), 50);
|
map.insert("Read".to_string(), ToolTokenStats {
|
||||||
|
call_count: 50,
|
||||||
|
estimated_input_tokens: 5000,
|
||||||
|
estimated_output_tokens: 2500,
|
||||||
|
});
|
||||||
map
|
map
|
||||||
},
|
},
|
||||||
sessions_started: 10,
|
sessions_started: 10,
|
||||||
@@ -608,7 +893,8 @@ mod tests {
|
|||||||
assert_eq!(stats.total_output_tokens, 20000);
|
assert_eq!(stats.total_output_tokens, 20000);
|
||||||
assert_eq!(stats.total_cost_usd, 5.50);
|
assert_eq!(stats.total_cost_usd, 5.50);
|
||||||
assert_eq!(stats.messages_exchanged, 100);
|
assert_eq!(stats.messages_exchanged, 100);
|
||||||
assert_eq!(stats.tools_usage.get("Read"), Some(&50));
|
assert_eq!(stats.tools_usage.get("Read").map(|t| t.call_count), Some(50));
|
||||||
|
assert_eq!(stats.tools_usage.get("Read").map(|t| t.estimated_input_tokens), Some(5000));
|
||||||
assert_eq!(stats.consecutive_days, 7);
|
assert_eq!(stats.consecutive_days, 7);
|
||||||
assert_eq!(stats.morning_sessions, 3);
|
assert_eq!(stats.morning_sessions, 3);
|
||||||
assert_eq!(stats.last_session_date, Some("2024-06-15".to_string()));
|
assert_eq!(stats.last_session_date, Some("2024-06-15".to_string()));
|
||||||
@@ -672,4 +958,351 @@ mod tests {
|
|||||||
assert!(json.contains("stats"));
|
assert!(json.contains("stats"));
|
||||||
assert!(json.contains("total_input_tokens"));
|
assert!(json.contains("total_input_tokens"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Context Window Tracking tests
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_window_limit_claude_4() {
|
||||||
|
assert_eq!(get_context_window_limit("claude-opus-4-5-20251101"), 200_000);
|
||||||
|
assert_eq!(get_context_window_limit("claude-opus-4-20250514"), 200_000);
|
||||||
|
assert_eq!(get_context_window_limit("claude-sonnet-4-20250514"), 200_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_window_limit_claude_35() {
|
||||||
|
assert_eq!(
|
||||||
|
get_context_window_limit("claude-3-5-sonnet-20241022"),
|
||||||
|
200_000
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_context_window_limit("claude-3-5-sonnet-20240620"),
|
||||||
|
200_000
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_context_window_limit("claude-3-5-haiku-20241022"),
|
||||||
|
200_000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_window_limit_unknown_claude() {
|
||||||
|
assert_eq!(
|
||||||
|
get_context_window_limit("claude-some-future-model"),
|
||||||
|
200_000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_window_limit_non_claude() {
|
||||||
|
assert_eq!(get_context_window_limit("gpt-4"), 128_000);
|
||||||
|
assert_eq!(get_context_window_limit("llama-3"), 128_000);
|
||||||
|
assert_eq!(get_context_window_limit("unknown-model"), 128_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_tracking_update() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514");
|
||||||
|
|
||||||
|
assert_eq!(stats.context_tokens_used, 50_000);
|
||||||
|
assert_eq!(stats.context_window_limit, 200_000);
|
||||||
|
assert!((stats.context_utilisation_percent - 25.0).abs() < 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_tracking_accumulates() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514");
|
||||||
|
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514");
|
||||||
|
|
||||||
|
assert_eq!(stats.context_tokens_used, 100_000);
|
||||||
|
assert!((stats.context_utilisation_percent - 50.0).abs() < 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_warning_none() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.context_utilisation_percent = 40.0;
|
||||||
|
assert!(stats.get_context_warning().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_warning_moderate() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.context_utilisation_percent = 55.0;
|
||||||
|
assert_eq!(stats.get_context_warning(), Some(ContextWarning::Moderate));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_warning_high() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.context_utilisation_percent = 80.0;
|
||||||
|
assert_eq!(stats.get_context_warning(), Some(ContextWarning::High));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_warning_critical() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.context_utilisation_percent = 95.0;
|
||||||
|
assert_eq!(stats.get_context_warning(), Some(ContextWarning::Critical));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_estimate_remaining_tokens() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.context_tokens_used = 50_000;
|
||||||
|
stats.context_window_limit = 200_000;
|
||||||
|
|
||||||
|
assert_eq!(stats.estimate_remaining_tokens(), 150_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_estimate_remaining_tokens_at_limit() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.context_tokens_used = 200_000;
|
||||||
|
stats.context_window_limit = 200_000;
|
||||||
|
|
||||||
|
assert_eq!(stats.estimate_remaining_tokens(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_estimate_remaining_tokens_over_limit() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.context_tokens_used = 250_000;
|
||||||
|
stats.context_window_limit = 200_000;
|
||||||
|
|
||||||
|
assert_eq!(stats.estimate_remaining_tokens(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_reset_on_session_reset() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.add_usage(100_000, 20_000, "claude-sonnet-4-20250514");
|
||||||
|
|
||||||
|
assert!(stats.context_tokens_used > 0);
|
||||||
|
assert!(stats.context_utilisation_percent > 0.0);
|
||||||
|
|
||||||
|
stats.reset_session();
|
||||||
|
|
||||||
|
assert_eq!(stats.context_tokens_used, 0);
|
||||||
|
assert_eq!(stats.context_utilisation_percent, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_warning_message() {
|
||||||
|
assert_eq!(
|
||||||
|
ContextWarning::Moderate.message(),
|
||||||
|
"Context window is 50%+ full. Consider starting a new conversation for better performance."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ContextWarning::High.message(),
|
||||||
|
"Context window is 75%+ full. Responses may degrade. Consider summarising or starting fresh."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ContextWarning::Critical.message(),
|
||||||
|
"Context window is nearly full (90%+)! Start a new conversation to avoid errors."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_warning_serialization() {
|
||||||
|
let warning = ContextWarning::Critical;
|
||||||
|
let json = serde_json::to_string(&warning).expect("Failed to serialize");
|
||||||
|
assert_eq!(json, "\"critical\"");
|
||||||
|
|
||||||
|
let warning = ContextWarning::Moderate;
|
||||||
|
let json = serde_json::to_string(&warning).expect("Failed to serialize");
|
||||||
|
assert_eq!(json, "\"moderate\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Budget Tracking tests
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_budget_disabled_returns_ok() {
|
||||||
|
let stats = UsageStats::new();
|
||||||
|
let status = stats.check_budget(false, Some(1000), Some(1.0), 0.8);
|
||||||
|
assert_eq!(status, BudgetStatus::Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_budget_no_limits_returns_ok() {
|
||||||
|
let stats = UsageStats::new();
|
||||||
|
let status = stats.check_budget(true, None, None, 0.8);
|
||||||
|
assert_eq!(status, BudgetStatus::Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_token_budget_within_limit() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_input_tokens = 500;
|
||||||
|
stats.session_output_tokens = 300;
|
||||||
|
|
||||||
|
let status = stats.check_budget(true, Some(10000), None, 0.8);
|
||||||
|
assert_eq!(status, BudgetStatus::Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_token_budget_warning() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_input_tokens = 4500;
|
||||||
|
stats.session_output_tokens = 4000;
|
||||||
|
|
||||||
|
let status = stats.check_budget(true, Some(10000), None, 0.8);
|
||||||
|
match status {
|
||||||
|
BudgetStatus::Warning {
|
||||||
|
budget_type,
|
||||||
|
percent_used,
|
||||||
|
} => {
|
||||||
|
assert_eq!(budget_type, BudgetType::Token);
|
||||||
|
assert!(percent_used >= 80.0);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Warning status"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_token_budget_exceeded() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_input_tokens = 6000;
|
||||||
|
stats.session_output_tokens = 5000;
|
||||||
|
|
||||||
|
let status = stats.check_budget(true, Some(10000), None, 0.8);
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
BudgetStatus::Exceeded {
|
||||||
|
budget_type: BudgetType::Token
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cost_budget_within_limit() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_cost_usd = 0.50;
|
||||||
|
|
||||||
|
let status = stats.check_budget(true, None, Some(5.0), 0.8);
|
||||||
|
assert_eq!(status, BudgetStatus::Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cost_budget_warning() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_cost_usd = 4.25;
|
||||||
|
|
||||||
|
let status = stats.check_budget(true, None, Some(5.0), 0.8);
|
||||||
|
match status {
|
||||||
|
BudgetStatus::Warning {
|
||||||
|
budget_type,
|
||||||
|
percent_used,
|
||||||
|
} => {
|
||||||
|
assert_eq!(budget_type, BudgetType::Cost);
|
||||||
|
assert!(percent_used >= 80.0);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Warning status"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cost_budget_exceeded() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_cost_usd = 5.50;
|
||||||
|
|
||||||
|
let status = stats.check_budget(true, None, Some(5.0), 0.8);
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
BudgetStatus::Exceeded {
|
||||||
|
budget_type: BudgetType::Cost
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_token_budget_takes_priority() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_input_tokens = 12000;
|
||||||
|
stats.session_output_tokens = 0;
|
||||||
|
stats.session_cost_usd = 0.01;
|
||||||
|
|
||||||
|
// Token budget exceeded, cost budget OK
|
||||||
|
let status = stats.check_budget(true, Some(10000), Some(5.0), 0.8);
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
BudgetStatus::Exceeded {
|
||||||
|
budget_type: BudgetType::Token
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remaining_token_budget() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_input_tokens = 3000;
|
||||||
|
stats.session_output_tokens = 2000;
|
||||||
|
|
||||||
|
assert_eq!(stats.get_remaining_token_budget(Some(10000)), Some(5000));
|
||||||
|
assert_eq!(stats.get_remaining_token_budget(None), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remaining_token_budget_exceeded() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_input_tokens = 8000;
|
||||||
|
stats.session_output_tokens = 5000;
|
||||||
|
|
||||||
|
assert_eq!(stats.get_remaining_token_budget(Some(10000)), Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remaining_cost_budget() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_cost_usd = 2.50;
|
||||||
|
|
||||||
|
let remaining = stats.get_remaining_cost_budget(Some(5.0));
|
||||||
|
assert!(remaining.is_some());
|
||||||
|
assert!((remaining.unwrap() - 2.50).abs() < 0.001);
|
||||||
|
assert_eq!(stats.get_remaining_cost_budget(None), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remaining_cost_budget_exceeded() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_cost_usd = 6.0;
|
||||||
|
|
||||||
|
let remaining = stats.get_remaining_cost_budget(Some(5.0));
|
||||||
|
assert!(remaining.is_some());
|
||||||
|
assert!((remaining.unwrap() - 0.0).abs() < 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_budget_status_serialization() {
|
||||||
|
let status = BudgetStatus::Warning {
|
||||||
|
budget_type: BudgetType::Token,
|
||||||
|
percent_used: 85.5,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&status).expect("Failed to serialize");
|
||||||
|
assert!(json.contains("warning"));
|
||||||
|
assert!(json.contains("token"));
|
||||||
|
|
||||||
|
let status = BudgetStatus::Exceeded {
|
||||||
|
budget_type: BudgetType::Cost,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&status).expect("Failed to serialize");
|
||||||
|
assert!(json.contains("exceeded"));
|
||||||
|
assert!(json.contains("cost"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_budget_type_serialization() {
|
||||||
|
let token = BudgetType::Token;
|
||||||
|
let json = serde_json::to_string(&token).expect("Failed to serialize");
|
||||||
|
assert_eq!(json, "\"token\"");
|
||||||
|
|
||||||
|
let cost = BudgetType::Cost;
|
||||||
|
let json = serde_json::to_string(&cost).expect("Failed to serialize");
|
||||||
|
assert_eq!(json, "\"cost\"");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
|
/// Tools that could benefit from caching
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CacheableTool {
|
||||||
|
Read,
|
||||||
|
Glob,
|
||||||
|
Grep,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CacheableTool {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn from_name(name: &str) -> Option<Self> {
|
||||||
|
match name {
|
||||||
|
"Read" => Some(Self::Read),
|
||||||
|
"Glob" => Some(Self::Glob),
|
||||||
|
"Grep" => Some(Self::Grep),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistics about potential cache savings
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CacheAnalytics {
|
||||||
|
/// Number of tool calls that could have been cache hits
|
||||||
|
pub potential_cache_hits: u64,
|
||||||
|
/// Estimated tokens that could have been saved
|
||||||
|
pub potential_savings_tokens: u64,
|
||||||
|
/// Tracks unique tool invocations: hash -> (tool_name, call_count)
|
||||||
|
#[serde(skip)]
|
||||||
|
recent_invocations: HashMap<u64, (String, u64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl CacheAnalytics {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a hash key from tool name and input
|
||||||
|
fn compute_key(tool_name: &str, input: &serde_json::Value) -> u64 {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
tool_name.hash(&mut hasher);
|
||||||
|
input.to_string().hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track a tool invocation for analytics
|
||||||
|
/// Returns true if this was a repeated invocation (potential cache hit)
|
||||||
|
pub fn track_invocation(
|
||||||
|
&mut self,
|
||||||
|
tool_name: &str,
|
||||||
|
input: &serde_json::Value,
|
||||||
|
estimated_tokens: u64,
|
||||||
|
) -> bool {
|
||||||
|
// Only track cacheable tools
|
||||||
|
if CacheableTool::from_name(tool_name).is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = Self::compute_key(tool_name, input);
|
||||||
|
|
||||||
|
if let Some((_, count)) = self.recent_invocations.get_mut(&key) {
|
||||||
|
*count += 1;
|
||||||
|
// This is a repeat - could have been a cache hit
|
||||||
|
self.potential_cache_hits += 1;
|
||||||
|
self.potential_savings_tokens += estimated_tokens;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
self.recent_invocations
|
||||||
|
.insert(key, (tool_name.to_string(), 1));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of unique tool invocations being tracked
|
||||||
|
pub fn unique_invocations(&self) -> usize {
|
||||||
|
self.recent_invocations.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get invocations that were called more than once
|
||||||
|
pub fn repeated_invocations(&self) -> Vec<(&str, u64)> {
|
||||||
|
self.recent_invocations
|
||||||
|
.values()
|
||||||
|
.filter(|(_, count)| *count > 1)
|
||||||
|
.map(|(name, count)| (name.as_str(), *count))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear session analytics (keep totals)
|
||||||
|
pub fn clear_session(&mut self) {
|
||||||
|
self.recent_invocations.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fully reset all analytics
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.potential_cache_hits = 0;
|
||||||
|
self.potential_savings_tokens = 0;
|
||||||
|
self.recent_invocations.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cacheable_tool_from_name() {
|
||||||
|
assert_eq!(CacheableTool::from_name("Read"), Some(CacheableTool::Read));
|
||||||
|
assert_eq!(CacheableTool::from_name("Glob"), Some(CacheableTool::Glob));
|
||||||
|
assert_eq!(CacheableTool::from_name("Grep"), Some(CacheableTool::Grep));
|
||||||
|
assert_eq!(CacheableTool::from_name("Bash"), None);
|
||||||
|
assert_eq!(CacheableTool::from_name("Edit"), None);
|
||||||
|
assert_eq!(CacheableTool::from_name("Write"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_first_invocation_not_cache_hit() {
|
||||||
|
let mut analytics = CacheAnalytics::new();
|
||||||
|
let input = json!({"file_path": "/home/test/file.txt"});
|
||||||
|
|
||||||
|
let is_repeat = analytics.track_invocation("Read", &input, 100);
|
||||||
|
|
||||||
|
assert!(!is_repeat);
|
||||||
|
assert_eq!(analytics.potential_cache_hits, 0);
|
||||||
|
assert_eq!(analytics.potential_savings_tokens, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_second_invocation_is_cache_hit() {
|
||||||
|
let mut analytics = CacheAnalytics::new();
|
||||||
|
let input = json!({"file_path": "/home/test/file.txt"});
|
||||||
|
|
||||||
|
analytics.track_invocation("Read", &input, 100);
|
||||||
|
let is_repeat = analytics.track_invocation("Read", &input, 100);
|
||||||
|
|
||||||
|
assert!(is_repeat);
|
||||||
|
assert_eq!(analytics.potential_cache_hits, 1);
|
||||||
|
assert_eq!(analytics.potential_savings_tokens, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_different_inputs_not_cache_hit() {
|
||||||
|
let mut analytics = CacheAnalytics::new();
|
||||||
|
let input1 = json!({"file_path": "/home/test/file1.txt"});
|
||||||
|
let input2 = json!({"file_path": "/home/test/file2.txt"});
|
||||||
|
|
||||||
|
analytics.track_invocation("Read", &input1, 100);
|
||||||
|
let is_repeat = analytics.track_invocation("Read", &input2, 100);
|
||||||
|
|
||||||
|
assert!(!is_repeat);
|
||||||
|
assert_eq!(analytics.potential_cache_hits, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_non_cacheable_tool_ignored() {
|
||||||
|
let mut analytics = CacheAnalytics::new();
|
||||||
|
let input = json!({"command": "ls -la"});
|
||||||
|
|
||||||
|
let is_repeat = analytics.track_invocation("Bash", &input, 100);
|
||||||
|
analytics.track_invocation("Bash", &input, 100);
|
||||||
|
|
||||||
|
assert!(!is_repeat);
|
||||||
|
assert_eq!(analytics.potential_cache_hits, 0);
|
||||||
|
assert_eq!(analytics.unique_invocations(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_repeated_invocations() {
|
||||||
|
let mut analytics = CacheAnalytics::new();
|
||||||
|
let input = json!({"file_path": "/home/test/file.txt"});
|
||||||
|
|
||||||
|
analytics.track_invocation("Read", &input, 100);
|
||||||
|
analytics.track_invocation("Read", &input, 100);
|
||||||
|
analytics.track_invocation("Read", &input, 100);
|
||||||
|
|
||||||
|
assert_eq!(analytics.potential_cache_hits, 2);
|
||||||
|
assert_eq!(analytics.potential_savings_tokens, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unique_invocations_count() {
|
||||||
|
let mut analytics = CacheAnalytics::new();
|
||||||
|
|
||||||
|
analytics.track_invocation("Read", &json!({"file_path": "/file1.txt"}), 100);
|
||||||
|
analytics.track_invocation("Read", &json!({"file_path": "/file2.txt"}), 100);
|
||||||
|
analytics.track_invocation("Glob", &json!({"pattern": "*.rs"}), 50);
|
||||||
|
|
||||||
|
assert_eq!(analytics.unique_invocations(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_repeated_invocations_list() {
|
||||||
|
let mut analytics = CacheAnalytics::new();
|
||||||
|
|
||||||
|
// file1 read twice
|
||||||
|
analytics.track_invocation("Read", &json!({"file_path": "/file1.txt"}), 100);
|
||||||
|
analytics.track_invocation("Read", &json!({"file_path": "/file1.txt"}), 100);
|
||||||
|
|
||||||
|
// file2 read once
|
||||||
|
analytics.track_invocation("Read", &json!({"file_path": "/file2.txt"}), 100);
|
||||||
|
|
||||||
|
// glob run 3 times
|
||||||
|
analytics.track_invocation("Glob", &json!({"pattern": "*.rs"}), 50);
|
||||||
|
analytics.track_invocation("Glob", &json!({"pattern": "*.rs"}), 50);
|
||||||
|
analytics.track_invocation("Glob", &json!({"pattern": "*.rs"}), 50);
|
||||||
|
|
||||||
|
let repeated = analytics.repeated_invocations();
|
||||||
|
assert_eq!(repeated.len(), 2); // file1 and glob pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clear_session() {
|
||||||
|
let mut analytics = CacheAnalytics::new();
|
||||||
|
let input = json!({"file_path": "/file.txt"});
|
||||||
|
|
||||||
|
analytics.track_invocation("Read", &input, 100);
|
||||||
|
analytics.track_invocation("Read", &input, 100);
|
||||||
|
|
||||||
|
assert_eq!(analytics.potential_cache_hits, 1);
|
||||||
|
assert_eq!(analytics.unique_invocations(), 1);
|
||||||
|
|
||||||
|
analytics.clear_session();
|
||||||
|
|
||||||
|
assert_eq!(analytics.potential_cache_hits, 1); // Preserved
|
||||||
|
assert_eq!(analytics.unique_invocations(), 0); // Cleared
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reset() {
|
||||||
|
let mut analytics = CacheAnalytics::new();
|
||||||
|
let input = json!({"file_path": "/file.txt"});
|
||||||
|
|
||||||
|
analytics.track_invocation("Read", &input, 100);
|
||||||
|
analytics.track_invocation("Read", &input, 100);
|
||||||
|
|
||||||
|
analytics.reset();
|
||||||
|
|
||||||
|
assert_eq!(analytics.potential_cache_hits, 0);
|
||||||
|
assert_eq!(analytics.potential_savings_tokens, 0);
|
||||||
|
assert_eq!(analytics.unique_invocations(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialization() {
|
||||||
|
let mut analytics = CacheAnalytics::new();
|
||||||
|
analytics.potential_cache_hits = 10;
|
||||||
|
analytics.potential_savings_tokens = 500;
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&analytics).expect("Failed to serialize");
|
||||||
|
let deserialized: CacheAnalytics =
|
||||||
|
serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(deserialized.potential_cache_hits, 10);
|
||||||
|
assert_eq!(deserialized.potential_savings_tokens, 500);
|
||||||
|
// recent_invocations is skipped in serialization
|
||||||
|
assert_eq!(deserialized.unique_invocations(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,6 +176,14 @@ pub struct StateChangeEvent {
|
|||||||
pub conversation_id: Option<String>,
|
pub conversation_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cost information for a message
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MessageCost {
|
||||||
|
pub input_tokens: u64,
|
||||||
|
pub output_tokens: u64,
|
||||||
|
pub cost_usd: f64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct OutputEvent {
|
pub struct OutputEvent {
|
||||||
pub line_type: String,
|
pub line_type: String,
|
||||||
@@ -183,6 +191,8 @@ pub struct OutputEvent {
|
|||||||
pub tool_name: Option<String>,
|
pub tool_name: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub conversation_id: Option<String>,
|
pub conversation_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cost: Option<MessageCost>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -354,10 +364,31 @@ mod tests {
|
|||||||
content: "Test output".to_string(),
|
content: "Test output".to_string(),
|
||||||
tool_name: None,
|
tool_name: None,
|
||||||
conversation_id: None,
|
conversation_id: None,
|
||||||
|
cost: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let serialized = serde_json::to_string(&event).unwrap();
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
assert!(serialized.contains("\"line_type\":\"assistant\""));
|
assert!(serialized.contains("\"line_type\":\"assistant\""));
|
||||||
assert!(serialized.contains("\"content\":\"Test output\""));
|
assert!(serialized.contains("\"content\":\"Test output\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_output_event_with_cost() {
|
||||||
|
let event = OutputEvent {
|
||||||
|
line_type: "assistant".to_string(),
|
||||||
|
content: "Test output".to_string(),
|
||||||
|
tool_name: None,
|
||||||
|
conversation_id: Some("conv-123".to_string()),
|
||||||
|
cost: Some(MessageCost {
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 50,
|
||||||
|
cost_usd: 0.005,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(serialized.contains("\"cost\":"));
|
||||||
|
assert!(serialized.contains("\"input_tokens\":100"));
|
||||||
|
assert!(serialized.contains("\"output_tokens\":50"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ use tempfile::NamedTempFile;
|
|||||||
use std::os::windows::process::CommandExt;
|
use std::os::windows::process::CommandExt;
|
||||||
|
|
||||||
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
|
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
|
||||||
|
use crate::commands::record_cost;
|
||||||
use crate::config::ClaudeStartOptions;
|
use crate::config::ClaudeStartOptions;
|
||||||
use crate::stats::{StatsUpdateEvent, UsageStats};
|
use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, OutputEvent,
|
CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, MessageCost,
|
||||||
PermissionPromptEvent, QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent,
|
OutputEvent, PermissionPromptEvent, QuestionOption, SessionEvent, StateChangeEvent,
|
||||||
WorkingDirectoryEvent,
|
UserQuestionEvent, WorkingDirectoryEvent,
|
||||||
};
|
};
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
|
|
||||||
@@ -534,6 +535,7 @@ fn handle_stderr(
|
|||||||
content: line,
|
content: line,
|
||||||
tool_name: None,
|
tool_name: None,
|
||||||
conversation_id: conversation_id.clone(),
|
conversation_id: conversation_id.clone(),
|
||||||
|
cost: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -586,17 +588,57 @@ fn process_json_line(
|
|||||||
let mut state = CharacterState::Typing;
|
let mut state = CharacterState::Typing;
|
||||||
let mut tool_name = None;
|
let mut tool_name = None;
|
||||||
|
|
||||||
|
// Collect all tool names in this message for token attribution
|
||||||
|
let tools_in_message: Vec<String> = message
|
||||||
|
.content
|
||||||
|
.iter()
|
||||||
|
.filter_map(|block| match block {
|
||||||
|
ContentBlock::ToolUse { name, .. } => Some(name.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Track message cost for display
|
||||||
|
let mut message_cost: Option<MessageCost> = None;
|
||||||
|
|
||||||
// Only update stats if we have usage information
|
// Only update stats if we have usage information
|
||||||
if let Some(usage) = &message.usage {
|
if let Some(usage) = &message.usage {
|
||||||
if let Some(model) = &message.model {
|
if let Some(model) = &message.model {
|
||||||
|
// Calculate cost for historical tracking
|
||||||
|
let cost_usd = calculate_cost(usage.input_tokens, usage.output_tokens, model);
|
||||||
|
|
||||||
|
// Store cost for later use in output events
|
||||||
|
message_cost = Some(MessageCost {
|
||||||
|
input_tokens: usage.input_tokens,
|
||||||
|
output_tokens: usage.output_tokens,
|
||||||
|
cost_usd,
|
||||||
|
});
|
||||||
|
|
||||||
// Batch all stats updates in a single write lock
|
// Batch all stats updates in a single write lock
|
||||||
{
|
{
|
||||||
let mut stats_guard = stats.write();
|
let mut stats_guard = stats.write();
|
||||||
stats_guard.increment_messages();
|
stats_guard.increment_messages();
|
||||||
stats_guard.add_usage(usage.input_tokens, usage.output_tokens, model);
|
stats_guard.add_usage(usage.input_tokens, usage.output_tokens, model);
|
||||||
stats_guard.get_session_duration();
|
stats_guard.get_session_duration();
|
||||||
|
|
||||||
|
// Attribute tokens to tools if any tools were used in this message
|
||||||
|
if !tools_in_message.is_empty() {
|
||||||
|
let per_tool_input = usage.input_tokens / tools_in_message.len() as u64;
|
||||||
|
let per_tool_output = usage.output_tokens / tools_in_message.len() as u64;
|
||||||
|
for tool in &tools_in_message {
|
||||||
|
stats_guard.add_tool_tokens(tool, per_tool_input, per_tool_output);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record to historical cost tracking
|
||||||
|
let app_clone = app.clone();
|
||||||
|
let input = usage.input_tokens;
|
||||||
|
let output = usage.output_tokens;
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
record_cost(&app_clone, input, output, cost_usd).await;
|
||||||
|
});
|
||||||
|
|
||||||
// Don't emit here - we'll emit on Result message instead
|
// Don't emit here - we'll emit on Result message instead
|
||||||
// This reduces the frequency of updates
|
// This reduces the frequency of updates
|
||||||
} else {
|
} else {
|
||||||
@@ -635,6 +677,7 @@ fn process_json_line(
|
|||||||
content: desc,
|
content: desc,
|
||||||
tool_name: Some(name.clone()),
|
tool_name: Some(name.clone()),
|
||||||
conversation_id: conversation_id.clone(),
|
conversation_id: conversation_id.clone(),
|
||||||
|
cost: None, // Tool use doesn't have separate cost
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -652,6 +695,7 @@ fn process_json_line(
|
|||||||
content: text.clone(),
|
content: text.clone(),
|
||||||
tool_name: None,
|
tool_name: None,
|
||||||
conversation_id: conversation_id.clone(),
|
conversation_id: conversation_id.clone(),
|
||||||
|
cost: message_cost.clone(), // Include cost with assistant text
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -664,6 +708,7 @@ fn process_json_line(
|
|||||||
content: format!("[Thinking] {}", thinking),
|
content: format!("[Thinking] {}", thinking),
|
||||||
tool_name: None,
|
tool_name: None,
|
||||||
conversation_id: conversation_id.clone(),
|
conversation_id: conversation_id.clone(),
|
||||||
|
cost: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -723,9 +768,20 @@ fn process_json_line(
|
|||||||
stats_guard.model.clone().unwrap_or_else(|| "claude-opus-4-20250514".to_string())
|
stats_guard.model.clone().unwrap_or_else(|| "claude-opus-4-20250514".to_string())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Calculate cost for historical tracking
|
||||||
|
let cost_usd = calculate_cost(usage_info.input_tokens, usage_info.output_tokens, &model);
|
||||||
|
|
||||||
let mut stats_guard = stats.write();
|
let mut stats_guard = stats.write();
|
||||||
stats_guard.add_usage(usage_info.input_tokens, usage_info.output_tokens, &model);
|
stats_guard.add_usage(usage_info.input_tokens, usage_info.output_tokens, &model);
|
||||||
println!("Result message tokens - input: {}, output: {}", usage_info.input_tokens, usage_info.output_tokens);
|
println!("Result message tokens - input: {}, output: {}", usage_info.input_tokens, usage_info.output_tokens);
|
||||||
|
|
||||||
|
// Record to historical cost tracking
|
||||||
|
let app_clone = app.clone();
|
||||||
|
let input = usage_info.input_tokens;
|
||||||
|
let output = usage_info.output_tokens;
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
record_cost(&app_clone, input, output, cost_usd).await;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always emit updated stats on result message (less frequent)
|
// Always emit updated stats on result message (less frequent)
|
||||||
@@ -797,6 +853,7 @@ fn process_json_line(
|
|||||||
content: text.clone(),
|
content: text.clone(),
|
||||||
tool_name: None,
|
tool_name: None,
|
||||||
conversation_id: conversation_id.clone(),
|
conversation_id: conversation_id.clone(),
|
||||||
|
cost: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "hikari-desktop",
|
"productName": "hikari-desktop",
|
||||||
"version": "1.1.1",
|
"version": "1.3.0",
|
||||||
"identifier": "com.naomi.hikari-desktop",
|
"identifier": "com.naomi.hikari-desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ import {
|
|||||||
} from "./slashCommands";
|
} from "./slashCommands";
|
||||||
|
|
||||||
// Mock all external dependencies
|
// Mock all external dependencies
|
||||||
vi.mock("svelte/store", () => ({
|
vi.mock("svelte/store", async (importOriginal) => {
|
||||||
get: vi.fn(),
|
const actual = await importOriginal<typeof import("svelte/store")>();
|
||||||
}));
|
return {
|
||||||
|
...actual,
|
||||||
|
get: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/core", () => ({
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { get } from "svelte/store";
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { claudeStore } from "$lib/stores/claude";
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
import { setSkipNextGreeting } from "$lib/tauri";
|
import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri";
|
||||||
import { searchState } from "$lib/stores/search";
|
import { searchState } from "$lib/stores/search";
|
||||||
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
|
import { configStore } from "$lib/stores/config";
|
||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -51,6 +53,17 @@ async function changeDirectory(path: string): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update Discord RPC when reconnecting after directory change
|
||||||
|
const config = configStore.getConfig();
|
||||||
|
const activeConversation = get(conversationsStore.activeConversation);
|
||||||
|
if (activeConversation) {
|
||||||
|
await updateDiscordRpc(
|
||||||
|
activeConversation.name,
|
||||||
|
config.model || "claude",
|
||||||
|
activeConversation.startedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for connection to establish
|
// Wait for connection to establish
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
@@ -105,6 +118,17 @@ async function startNewConversation(): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update Discord RPC when starting new conversation
|
||||||
|
const config = configStore.getConfig();
|
||||||
|
const activeConversation = get(conversationsStore.activeConversation);
|
||||||
|
if (activeConversation) {
|
||||||
|
await updateDiscordRpc(
|
||||||
|
activeConversation.name,
|
||||||
|
config.model || "claude",
|
||||||
|
activeConversation.startedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
claudeStore.addLine("system", "New conversation started!");
|
claudeStore.addLine("system", "New conversation started!");
|
||||||
characterState.setState("idle");
|
characterState.setState("idle");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -161,7 +161,7 @@
|
|||||||
|
|
||||||
<!-- Celebration confetti effect (CSS only) -->
|
<!-- Celebration confetti effect (CSS only) -->
|
||||||
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
||||||
{#each Array(10) as _ (_)}
|
{#each Array.from({ length: 10 }, (_, i) => i) as confettiIndex (confettiIndex)}
|
||||||
<div
|
<div
|
||||||
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
|
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
|
||||||
getAchievementRarity(currentAchievement.achievement.id)
|
getAchievementRarity(currentAchievement.achievement.id)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
} from "$lib/stores/config";
|
} from "$lib/stores/config";
|
||||||
import { claudeStore } from "$lib/stores/claude";
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import CostSummary from "./CostSummary.svelte";
|
||||||
|
|
||||||
let config: HikariConfig = $state({
|
let config: HikariConfig = $state({
|
||||||
model: null,
|
model: null,
|
||||||
@@ -45,6 +46,12 @@
|
|||||||
text_secondary: null,
|
text_secondary: null,
|
||||||
border_color: null,
|
border_color: null,
|
||||||
},
|
},
|
||||||
|
budget_enabled: false,
|
||||||
|
session_token_budget: null,
|
||||||
|
session_cost_budget: null,
|
||||||
|
budget_action: "warn",
|
||||||
|
budget_warning_threshold: 0.8,
|
||||||
|
discord_rpc_enabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
let showCustomThemeEditor = $state(false);
|
let showCustomThemeEditor = $state(false);
|
||||||
@@ -74,8 +81,17 @@
|
|||||||
|
|
||||||
const availableModels = [
|
const availableModels = [
|
||||||
{ value: "", label: "Default (from ~/.claude)" },
|
{ value: "", label: "Default (from ~/.claude)" },
|
||||||
|
// Current generation (Claude 4.5)
|
||||||
|
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5 (Recommended)" },
|
||||||
|
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" },
|
||||||
|
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5 (Most Capable)" },
|
||||||
|
// Previous generation (Claude 4)
|
||||||
|
{ value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" },
|
||||||
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
|
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
|
||||||
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
|
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
|
||||||
|
// Legacy (Claude 3.x)
|
||||||
|
{ value: "claude-3-7-sonnet-20250219", label: "Claude 3.7 Sonnet" },
|
||||||
|
{ value: "claude-3-haiku-20240307", label: "Claude 3 Haiku (Cheapest)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const commonTools = [
|
const commonTools = [
|
||||||
@@ -778,6 +794,135 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Budget Settings Section -->
|
||||||
|
<section class="mb-6">
|
||||||
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
|
Budget Settings
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Enable Budget Tracking -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.budget_enabled}
|
||||||
|
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Enable budget tracking</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
Set limits on token usage and costs per session
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if config.budget_enabled}
|
||||||
|
<!-- Token Budget -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="token-budget" class="block text-sm text-[var(--text-secondary)] mb-1">
|
||||||
|
Session Token Budget
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="token-budget"
|
||||||
|
type="number"
|
||||||
|
bind:value={config.session_token_budget}
|
||||||
|
min="0"
|
||||||
|
step="10000"
|
||||||
|
placeholder="e.g., 100000"
|
||||||
|
class="flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)]">tokens</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1">Leave empty for unlimited tokens</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cost Budget -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="cost-budget" class="block text-sm text-[var(--text-secondary)] mb-1">
|
||||||
|
Session Cost Budget
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-[var(--text-secondary)]">$</span>
|
||||||
|
<input
|
||||||
|
id="cost-budget"
|
||||||
|
type="number"
|
||||||
|
bind:value={config.session_cost_budget}
|
||||||
|
min="0"
|
||||||
|
step="0.50"
|
||||||
|
placeholder="e.g., 5.00"
|
||||||
|
class="flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)]">USD</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1">Leave empty for unlimited spending</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Warning Threshold -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="warning-threshold" class="block text-sm text-[var(--text-secondary)] mb-2">
|
||||||
|
Warning Threshold
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
id="warning-threshold"
|
||||||
|
type="range"
|
||||||
|
bind:value={config.budget_warning_threshold}
|
||||||
|
min="0.5"
|
||||||
|
max="0.95"
|
||||||
|
step="0.05"
|
||||||
|
class="flex-1 h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-secondary)] w-12 text-right">
|
||||||
|
{Math.round(config.budget_warning_threshold * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||||
|
Show warning when this percentage of budget is used
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Budget Action -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="block text-sm text-[var(--text-secondary)] mb-2"
|
||||||
|
>When budget is exceeded</span
|
||||||
|
>
|
||||||
|
<div class="flex gap-2" role="group" aria-label="Budget action">
|
||||||
|
<button
|
||||||
|
onclick={() => (config.budget_action = "warn")}
|
||||||
|
class="flex-1 px-3 py-2 rounded-lg border transition-colors text-sm {config.budget_action ===
|
||||||
|
'warn'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
>
|
||||||
|
Warn Only
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (config.budget_action = "block")}
|
||||||
|
class="flex-1 px-3 py-2 rounded-lg border transition-colors text-sm {config.budget_action ===
|
||||||
|
'block'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
>
|
||||||
|
Block Input
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-2">
|
||||||
|
{config.budget_action === "warn"
|
||||||
|
? "Show a warning but allow continued usage"
|
||||||
|
: "Prevent sending more messages until session is reset"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Cost History Section -->
|
||||||
|
<section class="mb-6">
|
||||||
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
|
Cost History
|
||||||
|
</h3>
|
||||||
|
<CostSummary />
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Notifications Section -->
|
<!-- Notifications Section -->
|
||||||
<section class="mb-6">
|
<section class="mb-6">
|
||||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
@@ -823,6 +968,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Discord Rich Presence Section -->
|
||||||
|
<section class="pt-6 pb-6 border-t border-[var(--border-color)]">
|
||||||
|
<h3 class="text-lg font-semibold text-[var(--accent-primary)] mb-4 flex items-center gap-2">
|
||||||
|
<span>🎮</span>
|
||||||
|
<span>Discord Rich Presence</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Enable/Disable Discord RPC -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.discord_rpc_enabled}
|
||||||
|
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Show activity in Discord</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-[var(--text-tertiary)]">
|
||||||
|
Display your current conversation session name and model in Discord when enabled.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Save Button -->
|
<!-- Save Button -->
|
||||||
<div class="sticky bottom-0 pt-4 pb-2 bg-[var(--bg-secondary)]">
|
<div class="sticky bottom-0 pt-4 pb-2 bg-[var(--bg-secondary)]">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,402 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
costTrackingStore,
|
||||||
|
formattedCosts,
|
||||||
|
formatCost,
|
||||||
|
type CostSummary,
|
||||||
|
type CostAlertThresholds,
|
||||||
|
} from "$lib/stores/costTracking";
|
||||||
|
|
||||||
|
let selectedPeriod = $state<7 | 30 | 90>(7);
|
||||||
|
let summary = $state<CostSummary | null>(null);
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let showThresholdSettings = $state(false);
|
||||||
|
let thresholds = $state<CostAlertThresholds>({
|
||||||
|
daily: null,
|
||||||
|
weekly: null,
|
||||||
|
monthly: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const costs = $derived($formattedCosts);
|
||||||
|
|
||||||
|
async function loadSummary() {
|
||||||
|
isLoading = true;
|
||||||
|
summary = await costTrackingStore.getSummary(selectedPeriod);
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
const csv = await costTrackingStore.exportCsv(selectedPeriod);
|
||||||
|
if (csv) {
|
||||||
|
const blob = new Blob([csv], { type: "text/csv" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `hikari-costs-${selectedPeriod}days.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveThresholds() {
|
||||||
|
await costTrackingStore.setAlertThresholds(thresholds);
|
||||||
|
showThresholdSettings = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadSummary();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="cost-summary">
|
||||||
|
<h3 class="summary-title">Cost Summary</h3>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="quick-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Today</span>
|
||||||
|
<span class="stat-value">{costs.today}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">This Week</span>
|
||||||
|
<span class="stat-value">{costs.week}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">This Month</span>
|
||||||
|
<span class="stat-value">{costs.month}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Period Selector -->
|
||||||
|
<div class="period-selector">
|
||||||
|
<button
|
||||||
|
class="period-btn"
|
||||||
|
class:active={selectedPeriod === 7}
|
||||||
|
onclick={() => (selectedPeriod = 7)}
|
||||||
|
>
|
||||||
|
7 Days
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="period-btn"
|
||||||
|
class:active={selectedPeriod === 30}
|
||||||
|
onclick={() => (selectedPeriod = 30)}
|
||||||
|
>
|
||||||
|
30 Days
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="period-btn"
|
||||||
|
class:active={selectedPeriod === 90}
|
||||||
|
onclick={() => (selectedPeriod = 90)}
|
||||||
|
>
|
||||||
|
90 Days
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Details -->
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading">Loading...</div>
|
||||||
|
{:else if summary}
|
||||||
|
<div class="summary-details">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Total Cost</span>
|
||||||
|
<span class="detail-value highlight">{formatCost(summary.total_cost)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Average Daily</span>
|
||||||
|
<span class="detail-value">{formatCost(summary.average_daily_cost)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Messages</span>
|
||||||
|
<span class="detail-value">{summary.total_messages.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Sessions</span>
|
||||||
|
<span class="detail-value">{summary.total_sessions.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Input Tokens</span>
|
||||||
|
<span class="detail-value">{summary.total_input_tokens.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Output Tokens</span>
|
||||||
|
<span class="detail-value">{summary.total_output_tokens.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Daily Breakdown (mini chart) -->
|
||||||
|
{#if summary.daily_breakdown.length > 0}
|
||||||
|
<div class="chart-section">
|
||||||
|
<h4 class="chart-title">Daily Spending</h4>
|
||||||
|
<div class="mini-chart">
|
||||||
|
{#each summary.daily_breakdown.slice(-14) as day (day.date)}
|
||||||
|
{@const maxCost = Math.max(...summary.daily_breakdown.map((d) => d.cost_usd), 0.01)}
|
||||||
|
{@const height = (day.cost_usd / maxCost) * 100}
|
||||||
|
<div class="chart-bar-container" title="{day.date}: {formatCost(day.cost_usd)}">
|
||||||
|
<div class="chart-bar" style="height: {height}%"></div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="actions">
|
||||||
|
<button class="action-btn" onclick={handleExport}> Export CSV </button>
|
||||||
|
<button class="action-btn" onclick={() => (showThresholdSettings = !showThresholdSettings)}>
|
||||||
|
Set Alerts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Threshold Settings -->
|
||||||
|
{#if showThresholdSettings}
|
||||||
|
<div class="threshold-settings">
|
||||||
|
<h4>Cost Alert Thresholds</h4>
|
||||||
|
<div class="threshold-row">
|
||||||
|
<label for="daily-threshold">Daily</label>
|
||||||
|
<input
|
||||||
|
id="daily-threshold"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="e.g., 1.00"
|
||||||
|
bind:value={thresholds.daily}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="threshold-row">
|
||||||
|
<label for="weekly-threshold">Weekly</label>
|
||||||
|
<input
|
||||||
|
id="weekly-threshold"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="e.g., 5.00"
|
||||||
|
bind:value={thresholds.weekly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="threshold-row">
|
||||||
|
<label for="monthly-threshold">Monthly</label>
|
||||||
|
<input
|
||||||
|
id="monthly-threshold"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="e.g., 20.00"
|
||||||
|
bind:value={thresholds.monthly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button class="save-btn" onclick={handleSaveThresholds}>Save Thresholds</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cost-summary {
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-title {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-btn:hover {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-btn.active {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-details {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value.highlight {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-chart {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
height: 60px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-bar-container {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-bar {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--accent-primary);
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
min-height: 2px;
|
||||||
|
transition: height 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-settings {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-settings h4 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-row label {
|
||||||
|
width: 60px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-row input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.4rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
import { handleNewUserMessage } from "$lib/notifications/rules";
|
import { handleNewUserMessage } from "$lib/notifications/rules";
|
||||||
import { setSkipNextGreeting } from "$lib/tauri";
|
import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri";
|
||||||
import { clipboardStore } from "$lib/stores/clipboard";
|
import { clipboardStore } from "$lib/stores/clipboard";
|
||||||
import {
|
import {
|
||||||
setShouldRestoreHistory,
|
setShouldRestoreHistory,
|
||||||
@@ -26,6 +26,8 @@
|
|||||||
type SlashCommand,
|
type SlashCommand,
|
||||||
} from "$lib/commands/slashCommands";
|
} from "$lib/commands/slashCommands";
|
||||||
import { configStore, isStreamerMode } from "$lib/stores/config";
|
import { configStore, isStreamerMode } from "$lib/stores/config";
|
||||||
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
|
import { stats, estimateMessageCost, formatTokenCount } from "$lib/stores/stats";
|
||||||
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
|
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
|
||||||
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
|
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
|
||||||
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
|
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
|
||||||
@@ -50,6 +52,13 @@
|
|||||||
let showClipboardHistory = $state(false);
|
let showClipboardHistory = $state(false);
|
||||||
let streamerModeActive = $state(false);
|
let streamerModeActive = $state(false);
|
||||||
|
|
||||||
|
// Cost estimation for pre-submission display
|
||||||
|
let costEstimate = $derived(
|
||||||
|
inputValue.trim()
|
||||||
|
? estimateMessageCost(inputValue, $stats.context_tokens_used, $stats.model)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
// Context menu state
|
// Context menu state
|
||||||
let textareaElement: HTMLTextAreaElement | undefined = $state();
|
let textareaElement: HTMLTextAreaElement | undefined = $state();
|
||||||
let contextMenuShow = $state(false);
|
let contextMenuShow = $state(false);
|
||||||
@@ -342,6 +351,17 @@ User: ${formattedMessage}`;
|
|||||||
working_dir: workingDir,
|
working_dir: workingDir,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update Discord RPC when reconnecting
|
||||||
|
const config = configStore.getConfig();
|
||||||
|
const activeConversation = get(conversationsStore.activeConversation);
|
||||||
|
if (activeConversation) {
|
||||||
|
await updateDiscordRpc(
|
||||||
|
activeConversation.name,
|
||||||
|
config.model || "claude",
|
||||||
|
activeConversation.startedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (reconnectError) {
|
} catch (reconnectError) {
|
||||||
console.error("Failed to auto-reconnect:", reconnectError);
|
console.error("Failed to auto-reconnect:", reconnectError);
|
||||||
claudeStore.addLine("error", `Failed to reconnect: ${reconnectError}`);
|
claudeStore.addLine("error", `Failed to reconnect: ${reconnectError}`);
|
||||||
@@ -427,11 +447,12 @@ User: ${formattedMessage}`;
|
|||||||
try {
|
try {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
||||||
savedPath = await invoke<string>("save_temp_file", {
|
const result = await invoke<{ path: string; filename: string }>("save_temp_file", {
|
||||||
conversationId,
|
conversationId,
|
||||||
filename,
|
filename,
|
||||||
data: bytes,
|
data: bytes,
|
||||||
});
|
});
|
||||||
|
savedPath = result.path;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save dropped file to temp:", error);
|
console.error("Failed to save dropped file to temp:", error);
|
||||||
savedPath = file.name;
|
savedPath = file.name;
|
||||||
@@ -565,11 +586,12 @@ User: ${formattedMessage}`;
|
|||||||
try {
|
try {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
||||||
savedPath = await invoke<string>("save_temp_file", {
|
const result = await invoke<{ path: string; filename: string }>("save_temp_file", {
|
||||||
conversationId,
|
conversationId,
|
||||||
filename,
|
filename,
|
||||||
data: bytes,
|
data: bytes,
|
||||||
});
|
});
|
||||||
|
savedPath = result.path;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save pasted file to temp:", error);
|
console.error("Failed to save pasted file to temp:", error);
|
||||||
}
|
}
|
||||||
@@ -627,11 +649,12 @@ User: ${formattedMessage}`;
|
|||||||
try {
|
try {
|
||||||
const arrayBuffer = await blob.arrayBuffer();
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
||||||
savedPath = await invoke<string>("save_temp_file", {
|
const result = await invoke<{ path: string; filename: string }>("save_temp_file", {
|
||||||
conversationId,
|
conversationId,
|
||||||
filename,
|
filename,
|
||||||
data: bytes,
|
data: bytes,
|
||||||
});
|
});
|
||||||
|
savedPath = result.path;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save clipboard image to temp:", error);
|
console.error("Failed to save clipboard image to temp:", error);
|
||||||
}
|
}
|
||||||
@@ -913,6 +936,13 @@ User: ${formattedMessage}`;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="button-wrapper">
|
<div class="button-wrapper">
|
||||||
|
{#if costEstimate && isConnected && !isProcessing}
|
||||||
|
<div class="cost-estimate" title="Estimated input cost for this message">
|
||||||
|
<span class="cost-tokens">+{formatTokenCount(costEstimate.messageTokens)}</span>
|
||||||
|
<span class="cost-value">${costEstimate.estimatedCost.toFixed(4)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files">
|
<button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files">
|
||||||
<svg
|
<svg
|
||||||
width="20"
|
width="20"
|
||||||
@@ -1138,6 +1168,28 @@ User: ${formattedMessage}`;
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-estimate {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
min-width: 60px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-tokens {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-value {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.attach-button {
|
.attach-button {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
import { claudeStore, hasPermissionPending } from "$lib/stores/claude";
|
import { claudeStore, hasPermissionPending } from "$lib/stores/claude";
|
||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
import type { PermissionRequest } from "$lib/types/messages";
|
import type { PermissionRequest } from "$lib/types/messages";
|
||||||
|
import { updateDiscordRpc } from "$lib/tauri";
|
||||||
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
|
import { configStore } from "$lib/stores/config";
|
||||||
|
|
||||||
let isVisible = $state(false);
|
let isVisible = $state(false);
|
||||||
let permission: PermissionRequest | null = $state(null);
|
let permission: PermissionRequest | null = $state(null);
|
||||||
@@ -64,6 +67,17 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update Discord RPC when reconnecting after permission grant
|
||||||
|
const config = configStore.getConfig();
|
||||||
|
const activeConversation = get(conversationsStore.activeConversation);
|
||||||
|
if (activeConversation) {
|
||||||
|
await updateDiscordRpc(
|
||||||
|
activeConversation.name,
|
||||||
|
config.model || "claude",
|
||||||
|
activeConversation.startedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for connection to establish
|
// Wait for connection to establish
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,84 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { formattedStats } from "$lib/stores/stats";
|
import {
|
||||||
|
formattedStats,
|
||||||
|
contextWarning,
|
||||||
|
getContextWarningMessage,
|
||||||
|
stats,
|
||||||
|
checkBudget,
|
||||||
|
getBudgetStatusMessage,
|
||||||
|
getRemainingTokenBudget,
|
||||||
|
getRemainingCostBudget,
|
||||||
|
} from "$lib/stores/stats";
|
||||||
|
import { configStore } from "$lib/stores/config";
|
||||||
|
import { costTrackingStore, formattedCosts } from "$lib/stores/costTracking";
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
let showToolsBreakdown = false;
|
interface Props {
|
||||||
|
onRequestSummary?: () => void;
|
||||||
|
onStartFreshWithContext?: () => void;
|
||||||
|
isSummarising?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onRequestSummary, onStartFreshWithContext, isSummarising = false }: Props = $props();
|
||||||
|
|
||||||
|
let showToolsBreakdown = $state(false);
|
||||||
|
let showHistoricalCosts = $state(false);
|
||||||
|
const historicalCosts = $derived($formattedCosts);
|
||||||
|
|
||||||
|
// Initialize cost tracking on mount
|
||||||
|
onMount(() => {
|
||||||
|
costTrackingStore.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to config store
|
||||||
|
const config = configStore.config;
|
||||||
|
|
||||||
|
const warning = $derived($contextWarning);
|
||||||
|
|
||||||
|
// Budget tracking - must be defined before showCompactionOptions
|
||||||
|
const budgetStatus = $derived(
|
||||||
|
checkBudget(
|
||||||
|
$stats,
|
||||||
|
$config.budget_enabled,
|
||||||
|
$config.session_token_budget,
|
||||||
|
$config.session_cost_budget,
|
||||||
|
$config.budget_warning_threshold
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const budgetMessage = $derived(getBudgetStatusMessage(budgetStatus));
|
||||||
|
|
||||||
|
// Show compaction options when context or budget is at warning/critical levels
|
||||||
|
const showCompactionOptions = $derived(
|
||||||
|
warning === "high" ||
|
||||||
|
warning === "critical" ||
|
||||||
|
budgetStatus.type === "warning" ||
|
||||||
|
budgetStatus.type === "exceeded"
|
||||||
|
);
|
||||||
|
|
||||||
|
const remainingTokens = $derived(getRemainingTokenBudget($stats, $config.session_token_budget));
|
||||||
|
const remainingCost = $derived(getRemainingCostBudget($stats, $config.session_cost_budget));
|
||||||
|
|
||||||
|
// Calculate budget usage percentages for progress bars
|
||||||
|
const tokenBudgetPercent = $derived(() => {
|
||||||
|
const budget = $config.session_token_budget;
|
||||||
|
if (budget === null || budget === 0) return 0;
|
||||||
|
const used = $stats.session_input_tokens + $stats.session_output_tokens;
|
||||||
|
return Math.min(100, (used / budget) * 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
const costBudgetPercent = $derived(() => {
|
||||||
|
const budget = $config.session_cost_budget;
|
||||||
|
if (budget === null || budget === 0) return 0;
|
||||||
|
return Math.min(100, ($stats.session_cost_usd / budget) * 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the appropriate colour class for the progress bar
|
||||||
|
function getBudgetBarClass(percent: number, warningThreshold: number): string {
|
||||||
|
if (percent >= 100) return "budget-bar-exceeded";
|
||||||
|
if (percent >= warningThreshold * 100) return "budget-bar-warning";
|
||||||
|
return "budget-bar-ok";
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="stats-display" transition:fade={{ duration: 200 }}>
|
<div class="stats-display" transition:fade={{ duration: 200 }}>
|
||||||
@@ -16,6 +92,120 @@
|
|||||||
<span class="stat-value">{$formattedStats.messagesSession}</span>
|
<span class="stat-value">{$formattedStats.messagesSession}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-section">
|
||||||
|
<h3>Context Window</h3>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Used:</span>
|
||||||
|
<span class="stat-value">{$formattedStats.contextUsed} / {$formattedStats.contextLimit}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Utilisation:</span>
|
||||||
|
<span class="stat-value context-util {warning ? `warning-${warning}` : ''}"
|
||||||
|
>{$formattedStats.contextUtilisation}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if warning}
|
||||||
|
<div class="context-warning warning-{warning}">
|
||||||
|
{getContextWarningMessage(warning)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if showCompactionOptions && (onRequestSummary || onStartFreshWithContext)}
|
||||||
|
<div class="compaction-actions">
|
||||||
|
{#if onRequestSummary}
|
||||||
|
<button
|
||||||
|
class="compaction-btn"
|
||||||
|
onclick={onRequestSummary}
|
||||||
|
disabled={isSummarising}
|
||||||
|
title="Compact conversation history to reduce context usage"
|
||||||
|
>
|
||||||
|
{#if isSummarising}
|
||||||
|
Compacting...
|
||||||
|
{:else}
|
||||||
|
Compact
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if onStartFreshWithContext}
|
||||||
|
<button
|
||||||
|
class="compaction-btn compaction-btn-primary"
|
||||||
|
onclick={onStartFreshWithContext}
|
||||||
|
disabled={isSummarising}
|
||||||
|
title="Start a new conversation with context from this one"
|
||||||
|
>
|
||||||
|
Fresh Start
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $config.budget_enabled}
|
||||||
|
<div class="stats-section">
|
||||||
|
<h3>Budget</h3>
|
||||||
|
{#if $config.session_token_budget !== null}
|
||||||
|
<div class="budget-item">
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Tokens:</span>
|
||||||
|
<span
|
||||||
|
class="stat-value {budgetStatus.type !== 'ok' && budgetStatus.budget_type === 'token'
|
||||||
|
? `budget-${budgetStatus.type}`
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{($stats.session_input_tokens + $stats.session_output_tokens).toLocaleString()} / {$config.session_token_budget.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="budget-bar-container">
|
||||||
|
<div
|
||||||
|
class="budget-bar {getBudgetBarClass(
|
||||||
|
tokenBudgetPercent(),
|
||||||
|
$config.budget_warning_threshold
|
||||||
|
)}"
|
||||||
|
style="width: {tokenBudgetPercent()}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="budget-remaining">
|
||||||
|
{remainingTokens?.toLocaleString() ?? 0} remaining ({(
|
||||||
|
100 - tokenBudgetPercent()
|
||||||
|
).toFixed(1)}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if $config.session_cost_budget !== null}
|
||||||
|
<div class="budget-item">
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Cost:</span>
|
||||||
|
<span
|
||||||
|
class="stat-value {budgetStatus.type !== 'ok' && budgetStatus.budget_type === 'cost'
|
||||||
|
? `budget-${budgetStatus.type}`
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
${$stats.session_cost_usd.toFixed(4)} / ${$config.session_cost_budget.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="budget-bar-container">
|
||||||
|
<div
|
||||||
|
class="budget-bar {getBudgetBarClass(
|
||||||
|
costBudgetPercent(),
|
||||||
|
$config.budget_warning_threshold
|
||||||
|
)}"
|
||||||
|
style="width: {costBudgetPercent()}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="budget-remaining">
|
||||||
|
${remainingCost?.toFixed(4) ?? "0.0000"} remaining ({(
|
||||||
|
100 - costBudgetPercent()
|
||||||
|
).toFixed(1)}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if budgetMessage}
|
||||||
|
<div class="budget-warning budget-{budgetStatus.type}">
|
||||||
|
{budgetMessage}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="stats-section">
|
<div class="stats-section">
|
||||||
<h3>Tokens & Cost</h3>
|
<h3>Tokens & Cost</h3>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
@@ -49,7 +239,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if Object.keys($formattedStats.sessionToolsUsage).length > 0}
|
{#if $formattedStats.sessionToolsFormatted.length > 0}
|
||||||
<div class="stats-section">
|
<div class="stats-section">
|
||||||
<h3 class="tools-header">
|
<h3 class="tools-header">
|
||||||
<button class="tools-toggle" onclick={() => (showToolsBreakdown = !showToolsBreakdown)}>
|
<button class="tools-toggle" onclick={() => (showToolsBreakdown = !showToolsBreakdown)}>
|
||||||
@@ -59,17 +249,57 @@
|
|||||||
</h3>
|
</h3>
|
||||||
{#if showToolsBreakdown}
|
{#if showToolsBreakdown}
|
||||||
<div class="tools-breakdown">
|
<div class="tools-breakdown">
|
||||||
{#each Object.entries($formattedStats.sessionToolsUsage).sort((a, b) => b[1] - a[1]) as [tool, count] (tool)}
|
{#each $formattedStats.sessionToolsFormatted.sort((a, b) => b.totalTokens - a.totalTokens) as tool (tool.name)}
|
||||||
<div class="stat-row stat-detail">
|
<div class="stat-row stat-detail tool-row">
|
||||||
<span class="stat-label">{tool}:</span>
|
<span class="stat-label">{tool.name}:</span>
|
||||||
<span class="stat-value">{count}</span>
|
<span class="stat-value tool-stats">
|
||||||
|
<span class="tool-calls">{tool.callCount} calls</span>
|
||||||
|
{#if tool.totalTokens > 0}
|
||||||
|
<span class="tool-tokens">(~{tool.formattedTokens})</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
<div class="tools-note">* Token estimates based on attribution</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Historical Costs Section -->
|
||||||
|
<div class="stats-section">
|
||||||
|
<h3 class="costs-header">
|
||||||
|
<button class="costs-toggle" onclick={() => (showHistoricalCosts = !showHistoricalCosts)}>
|
||||||
|
Historical Costs
|
||||||
|
<span class="toggle-icon">{showHistoricalCosts ? "â–Ľ" : "â–¶"}</span>
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
{#if !showHistoricalCosts}
|
||||||
|
<div class="costs-quick-stats">
|
||||||
|
<span class="cost-badge" title="Today's cost">Today: {historicalCosts.today}</span>
|
||||||
|
<span class="cost-badge" title="This week's cost">Week: {historicalCosts.week}</span>
|
||||||
|
<span class="cost-badge" title="This month's cost">Month: {historicalCosts.month}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if showHistoricalCosts}
|
||||||
|
<div class="historical-costs-expanded">
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Today:</span>
|
||||||
|
<span class="stat-value cost-value">{historicalCosts.today}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">This Week:</span>
|
||||||
|
<span class="stat-value cost-value">{historicalCosts.week}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">This Month:</span>
|
||||||
|
<span class="stat-value cost-value">{historicalCosts.month}</span>
|
||||||
|
</div>
|
||||||
|
<p class="costs-note">Open Settings to view detailed cost history and set alerts.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="model-info">
|
<div class="model-info">
|
||||||
<span class="model-label">Model:</span>
|
<span class="model-label">Model:</span>
|
||||||
<span class="model-value">{$formattedStats.model}</span>
|
<span class="model-value">{$formattedStats.model}</span>
|
||||||
@@ -128,6 +358,79 @@
|
|||||||
color: var(--text-primary, #e5e7eb);
|
color: var(--text-primary, #e5e7eb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-cost {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
color: var(--accent-primary, #10b981);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.125rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-header {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-toggle:hover {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-breakdown {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-calls {
|
||||||
|
color: var(--text-primary, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-tokens {
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-note {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
.model-info {
|
.model-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -148,4 +451,220 @@
|
|||||||
color: var(--text-primary, #e5e7eb);
|
color: var(--text-primary, #e5e7eb);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.context-util {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-util.warning-moderate {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-util.warning-high {
|
||||||
|
color: #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-util.warning-critical {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-warning {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-warning.warning-moderate {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-warning.warning-high {
|
||||||
|
background: rgba(249, 115, 22, 0.15);
|
||||||
|
border: 1px solid rgba(249, 115, 22, 0.3);
|
||||||
|
color: #fb923c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-warning.warning-critical {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Budget progress bar styles */
|
||||||
|
.budget-item {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-bar-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition:
|
||||||
|
width 0.3s ease,
|
||||||
|
background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-bar-ok {
|
||||||
|
background: linear-gradient(90deg, #10b981, #34d399);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-bar-warning {
|
||||||
|
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-bar-exceeded {
|
||||||
|
background: linear-gradient(90deg, #ef4444, #f87171);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-remaining {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Budget warning styles */
|
||||||
|
.budget-warning {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-warning.budget-warning {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-warning.budget-exceeded {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.budget-warning {
|
||||||
|
color: #f59e0b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.budget-exceeded {
|
||||||
|
color: #ef4444;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compaction action buttons */
|
||||||
|
.compaction-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compaction-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compaction-btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
background: rgba(233, 69, 96, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compaction-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compaction-btn-primary {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compaction-btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-secondary);
|
||||||
|
border-color: var(--accent-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Historical costs styles */
|
||||||
|
.costs-header {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costs-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costs-toggle:hover {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.costs-quick-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #10b981;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-costs-expanded {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-value {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costs-note {
|
||||||
|
margin: 0.5rem 0 0 0;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -24,6 +24,13 @@
|
|||||||
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
|
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
|
||||||
import GitPanel from "./GitPanel.svelte";
|
import GitPanel from "./GitPanel.svelte";
|
||||||
import ProfilePanel from "./ProfilePanel.svelte";
|
import ProfilePanel from "./ProfilePanel.svelte";
|
||||||
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
|
import {
|
||||||
|
generateContextInjection,
|
||||||
|
createSummary,
|
||||||
|
sanitizeForJson,
|
||||||
|
} from "$lib/utils/conversationUtils";
|
||||||
|
import { updateDiscordRpc } from "$lib/tauri";
|
||||||
|
|
||||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||||
const DONATE_URL = "https://donate.nhcarrigan.com";
|
const DONATE_URL = "https://donate.nhcarrigan.com";
|
||||||
@@ -41,6 +48,7 @@
|
|||||||
let showSessionHistory = $state(false);
|
let showSessionHistory = $state(false);
|
||||||
let showGitPanel = $state(false);
|
let showGitPanel = $state(false);
|
||||||
let showProfile = $state(false);
|
let showProfile = $state(false);
|
||||||
|
let isSummarising = $state(false);
|
||||||
const progress = $derived($achievementProgress);
|
const progress = $derived($achievementProgress);
|
||||||
let currentConfig: HikariConfig = $state({
|
let currentConfig: HikariConfig = $state({
|
||||||
model: null,
|
model: null,
|
||||||
@@ -74,6 +82,12 @@
|
|||||||
text_secondary: null,
|
text_secondary: null,
|
||||||
border_color: null,
|
border_color: null,
|
||||||
},
|
},
|
||||||
|
budget_enabled: false,
|
||||||
|
session_token_budget: null,
|
||||||
|
session_cost_budget: null,
|
||||||
|
budget_action: "warn",
|
||||||
|
budget_warning_threshold: 0.8,
|
||||||
|
discord_rpc_enabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
let streamerModeActive = $state(false);
|
let streamerModeActive = $state(false);
|
||||||
@@ -153,6 +167,16 @@
|
|||||||
allowed_tools: allAllowedTools,
|
allowed_tools: allAllowedTools,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update Discord RPC when a new session starts
|
||||||
|
const activeConversation = get(conversationsStore.activeConversation);
|
||||||
|
if (activeConversation) {
|
||||||
|
await updateDiscordRpc(
|
||||||
|
activeConversation.name,
|
||||||
|
currentConfig.model || "claude",
|
||||||
|
activeConversation.startedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start Claude:", error);
|
console.error("Failed to start Claude:", error);
|
||||||
claudeStore.addLine("error", `Connection failed: ${error}`);
|
claudeStore.addLine("error", `Connection failed: ${error}`);
|
||||||
@@ -200,6 +224,106 @@
|
|||||||
function toggleAchievements() {
|
function toggleAchievements() {
|
||||||
onToggleAchievements();
|
onToggleAchievements();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCompactConversation() {
|
||||||
|
const activeId = get(conversationsStore.activeConversationId);
|
||||||
|
if (!activeId) return;
|
||||||
|
|
||||||
|
isSummarising = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const conversationContent = conversationsStore.getConversationForSummary(activeId);
|
||||||
|
const messageCount =
|
||||||
|
get(conversationsStore.activeConversation)?.terminalLines.filter(
|
||||||
|
(l) => l.type === "user" || l.type === "assistant"
|
||||||
|
).length || 0;
|
||||||
|
const tokenEstimate = conversationsStore.estimateTokenCount(activeId);
|
||||||
|
|
||||||
|
// Create a summary from the conversation content (truncate if too long)
|
||||||
|
// Apply sanitization early to handle any problematic escape sequences
|
||||||
|
const sanitizedContent = sanitizeForJson(conversationContent);
|
||||||
|
const summaryContent =
|
||||||
|
sanitizedContent.length > 4000
|
||||||
|
? `${sanitizedContent.slice(0, 4000)}\n\n[Truncated for length - original had ${messageCount} messages]`
|
||||||
|
: sanitizedContent;
|
||||||
|
|
||||||
|
// Step 1: Disconnect from Claude to reset context
|
||||||
|
if (connectionStatus === "connected") {
|
||||||
|
await invoke("stop_claude", { conversationId: activeId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Clear messages and store summary
|
||||||
|
conversationsStore.compactWithSummary(activeId, summaryContent, messageCount, tokenEstimate);
|
||||||
|
|
||||||
|
// Step 3: Reconnect to Claude with fresh context
|
||||||
|
const allAllowedTools = [
|
||||||
|
...(currentConfig.auto_granted_tools || []),
|
||||||
|
...Array.from(get(claudeStore.grantedTools)),
|
||||||
|
];
|
||||||
|
|
||||||
|
await invoke("start_claude", {
|
||||||
|
conversationId: activeId,
|
||||||
|
options: {
|
||||||
|
working_dir: workingDirectory || selectedDirectory,
|
||||||
|
model: currentConfig.model || null,
|
||||||
|
api_key: currentConfig.api_key || null,
|
||||||
|
custom_instructions: currentConfig.custom_instructions || null,
|
||||||
|
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
||||||
|
allowed_tools: allAllowedTools,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 4: Send the context summary to Claude as the first message
|
||||||
|
const contextPrompt = generateContextInjection(
|
||||||
|
createSummary(summaryContent, messageCount, tokenEstimate)
|
||||||
|
);
|
||||||
|
|
||||||
|
await invoke("send_prompt", {
|
||||||
|
conversationId: activeId,
|
||||||
|
message: contextPrompt,
|
||||||
|
});
|
||||||
|
|
||||||
|
claudeStore.addLine(
|
||||||
|
"system",
|
||||||
|
"Conversation compacted. Context from previous session has been provided to Claude."
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to compact conversation:", error);
|
||||||
|
claudeStore.addLine("error", `Failed to compact conversation: ${error}`);
|
||||||
|
} finally {
|
||||||
|
isSummarising = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStartFreshWithContext() {
|
||||||
|
const activeId = get(conversationsStore.activeConversationId);
|
||||||
|
if (!activeId) return;
|
||||||
|
|
||||||
|
const conversationContent = conversationsStore.getConversationForSummary(activeId);
|
||||||
|
const messageCount =
|
||||||
|
get(conversationsStore.activeConversation)?.terminalLines.filter(
|
||||||
|
(l) => l.type === "user" || l.type === "assistant"
|
||||||
|
).length || 0;
|
||||||
|
const tokenEstimate = conversationsStore.estimateTokenCount(activeId);
|
||||||
|
|
||||||
|
const summary = createSummary(
|
||||||
|
`This is a continuation of a previous conversation. Here's what was discussed:\n\n${conversationContent.slice(0, 4000)}${conversationContent.length > 4000 ? "\n\n[Truncated for length...]" : ""}`,
|
||||||
|
messageCount,
|
||||||
|
tokenEstimate
|
||||||
|
);
|
||||||
|
|
||||||
|
const newConvId = conversationsStore.createConversation("Fresh Start");
|
||||||
|
|
||||||
|
conversationsStore.setSummary(newConvId, summary);
|
||||||
|
|
||||||
|
// Context injection is generated but the actual injection happens via the summary
|
||||||
|
generateContextInjection(summary);
|
||||||
|
claudeStore.addLine("system", "Started fresh conversation with context from previous session.");
|
||||||
|
claudeStore.addLine(
|
||||||
|
"system",
|
||||||
|
`Previous session had ${messageCount} messages (~${tokenEstimate.toLocaleString()} tokens).`
|
||||||
|
);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -446,7 +570,11 @@
|
|||||||
|
|
||||||
{#if showStats}
|
{#if showStats}
|
||||||
<div class="absolute top-full right-0 mt-2 mr-4 z-50">
|
<div class="absolute top-full right-0 mt-2 mr-4 z-50">
|
||||||
<StatsDisplay />
|
<StatsDisplay
|
||||||
|
onRequestSummary={handleCompactConversation}
|
||||||
|
onStartFreshWithContext={handleStartFreshWithContext}
|
||||||
|
{isSummarising}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if connectionStatus === "connected"}
|
{#if connectionStatus === "connected"}
|
||||||
@@ -473,7 +601,11 @@
|
|||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div>
|
<div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div>
|
||||||
<div class="fixed top-14 right-4 z-50">
|
<div class="fixed top-14 right-4 z-50">
|
||||||
<StatsDisplay />
|
<StatsDisplay
|
||||||
|
onRequestSummary={handleCompactConversation}
|
||||||
|
onStartFreshWithContext={handleStartFreshWithContext}
|
||||||
|
{isSummarising}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,16 @@
|
|||||||
{#each lines as line (line.id)}
|
{#each lines as line (line.id)}
|
||||||
<div class="terminal-line mb-2 {getLineClass(line.type)} relative group">
|
<div class="terminal-line mb-2 {getLineClass(line.type)} relative group">
|
||||||
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
|
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
|
||||||
|
{#if line.cost && line.cost.costUsd > 0}
|
||||||
|
<span
|
||||||
|
class="terminal-cost text-xs mr-2"
|
||||||
|
title="Input: {line.cost.inputTokens} | Output: {line.cost.outputTokens}"
|
||||||
|
>
|
||||||
|
${line.cost.costUsd < 0.01
|
||||||
|
? line.cost.costUsd.toFixed(4)
|
||||||
|
: line.cost.costUsd.toFixed(3)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
{#if getLinePrefix(line.type)}
|
{#if getLinePrefix(line.type)}
|
||||||
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -291,6 +301,14 @@
|
|||||||
color: var(--text-tertiary, #6b7280);
|
color: var(--text-tertiary, #6b7280);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-cost {
|
||||||
|
color: var(--terminal-cost, #10b981);
|
||||||
|
background: var(--terminal-cost-bg, rgba(16, 185, 129, 0.1));
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-prefix {
|
.terminal-prefix {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
import { claudeStore, hasQuestionPending } from "$lib/stores/claude";
|
import { claudeStore, hasQuestionPending } from "$lib/stores/claude";
|
||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
import type { UserQuestionEvent } from "$lib/types/messages";
|
import type { UserQuestionEvent } from "$lib/types/messages";
|
||||||
|
import { updateDiscordRpc } from "$lib/tauri";
|
||||||
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
|
import { configStore } from "$lib/stores/config";
|
||||||
|
|
||||||
let isVisible = $state(false);
|
let isVisible = $state(false);
|
||||||
let question: UserQuestionEvent | null = $state(null);
|
let question: UserQuestionEvent | null = $state(null);
|
||||||
@@ -98,6 +101,17 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update Discord RPC when reconnecting after answering question
|
||||||
|
const config = configStore.getConfig();
|
||||||
|
const activeConversation = get(conversationsStore.activeConversation);
|
||||||
|
if (activeConversation) {
|
||||||
|
await updateDiscordRpc(
|
||||||
|
activeConversation.name,
|
||||||
|
config.model || "claude",
|
||||||
|
activeConversation.startedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
if (conversationHistory) {
|
if (conversationHistory) {
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ class NotificationManager {
|
|||||||
return "Successfully connected to Claude Code";
|
return "Successfully connected to Claude Code";
|
||||||
case NotificationType.TASK_START:
|
case NotificationType.TASK_START:
|
||||||
return "Starting task...";
|
return "Starting task...";
|
||||||
|
case NotificationType.COST_ALERT:
|
||||||
|
return "You've exceeded your cost threshold!";
|
||||||
default:
|
default:
|
||||||
return "Notification";
|
return "Notification";
|
||||||
}
|
}
|
||||||
@@ -115,6 +117,10 @@ class NotificationManager {
|
|||||||
async notifyTaskStart(message?: string): Promise<void> {
|
async notifyTaskStart(message?: string): Promise<void> {
|
||||||
await this.notify(NotificationType.TASK_START, message);
|
await this.notify(NotificationType.TASK_START, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async notifyCostAlert(message?: string): Promise<void> {
|
||||||
|
await this.notify(NotificationType.COST_ALERT, message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
|
|||||||
@@ -51,9 +51,13 @@ describe("notifications", () => {
|
|||||||
expect(NotificationType.ACHIEVEMENT).toBe("achievement");
|
expect(NotificationType.ACHIEVEMENT).toBe("achievement");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has exactly 6 notification types", () => {
|
it("has exactly 7 notification types", () => {
|
||||||
const types = Object.values(NotificationType);
|
const types = Object.values(NotificationType);
|
||||||
expect(types.length).toBe(6);
|
expect(types.length).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has COST_ALERT type", () => {
|
||||||
|
expect(NotificationType.COST_ALERT).toBe("cost_alert");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -314,10 +318,11 @@ describe("notifications", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sound filenames are unique", () => {
|
it("sound filenames are mostly unique", () => {
|
||||||
const filenames = Object.values(NOTIFICATION_SOUNDS).map((s) => s.filename);
|
const filenames = Object.values(NOTIFICATION_SOUNDS).map((s) => s.filename);
|
||||||
const uniqueFilenames = new Set(filenames);
|
const uniqueFilenames = new Set(filenames);
|
||||||
expect(uniqueFilenames.size).toBe(filenames.length);
|
// Allow some sound reuse (e.g., COST_ALERT reuses ERROR sound)
|
||||||
|
expect(uniqueFilenames.size).toBeGreaterThanOrEqual(filenames.length - 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("phrases are unique", () => {
|
it("phrases are unique", () => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export enum NotificationType {
|
|||||||
CONNECTION = "connection",
|
CONNECTION = "connection",
|
||||||
TASK_START = "task_start",
|
TASK_START = "task_start",
|
||||||
ACHIEVEMENT = "achievement",
|
ACHIEVEMENT = "achievement",
|
||||||
|
COST_ALERT = "cost_alert",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationSound {
|
export interface NotificationSound {
|
||||||
@@ -52,4 +53,10 @@ export const NOTIFICATION_SOUNDS: Record<NotificationType, NotificationSound> =
|
|||||||
phrase: "Achievement Get~!",
|
phrase: "Achievement Get~!",
|
||||||
volume: 0.8,
|
volume: 0.8,
|
||||||
},
|
},
|
||||||
|
[NotificationType.COST_ALERT]: {
|
||||||
|
type: NotificationType.COST_ALERT,
|
||||||
|
filename: "oh-no.mp3",
|
||||||
|
phrase: "Cost Alert!",
|
||||||
|
volume: 0.9,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -187,6 +187,12 @@ describe("config store", () => {
|
|||||||
text_secondary: null,
|
text_secondary: null,
|
||||||
border_color: null,
|
border_color: null,
|
||||||
},
|
},
|
||||||
|
budget_enabled: false,
|
||||||
|
session_token_budget: null,
|
||||||
|
session_cost_budget: null,
|
||||||
|
budget_action: "warn",
|
||||||
|
budget_warning_threshold: 0.8,
|
||||||
|
discord_rpc_enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBe("claude-sonnet-4");
|
expect(config.model).toBe("claude-sonnet-4");
|
||||||
@@ -227,6 +233,12 @@ describe("config store", () => {
|
|||||||
text_secondary: null,
|
text_secondary: null,
|
||||||
border_color: null,
|
border_color: null,
|
||||||
},
|
},
|
||||||
|
budget_enabled: false,
|
||||||
|
session_token_budget: null,
|
||||||
|
session_cost_budget: null,
|
||||||
|
budget_action: "warn",
|
||||||
|
budget_warning_threshold: 0.8,
|
||||||
|
discord_rpc_enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBeNull();
|
expect(config.model).toBeNull();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { writable, derived } from "svelte/store";
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
export type Theme = "dark" | "light" | "high-contrast" | "custom";
|
export type Theme = "dark" | "light" | "high-contrast" | "custom";
|
||||||
|
export type BudgetAction = "warn" | "block";
|
||||||
|
|
||||||
export interface CustomThemeColors {
|
export interface CustomThemeColors {
|
||||||
bg_primary: string | null;
|
bg_primary: string | null;
|
||||||
@@ -37,6 +38,14 @@ export interface HikariConfig {
|
|||||||
profile_avatar_path: string | null;
|
profile_avatar_path: string | null;
|
||||||
profile_bio: string | null;
|
profile_bio: string | null;
|
||||||
custom_theme_colors: CustomThemeColors;
|
custom_theme_colors: CustomThemeColors;
|
||||||
|
// Budget settings
|
||||||
|
budget_enabled: boolean;
|
||||||
|
session_token_budget: number | null;
|
||||||
|
session_cost_budget: number | null;
|
||||||
|
budget_action: BudgetAction;
|
||||||
|
budget_warning_threshold: number;
|
||||||
|
// Discord RPC settings
|
||||||
|
discord_rpc_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: HikariConfig = {
|
const defaultConfig: HikariConfig = {
|
||||||
@@ -71,6 +80,12 @@ const defaultConfig: HikariConfig = {
|
|||||||
text_secondary: null,
|
text_secondary: null,
|
||||||
border_color: null,
|
border_color: null,
|
||||||
},
|
},
|
||||||
|
budget_enabled: false,
|
||||||
|
session_token_budget: null,
|
||||||
|
session_cost_budget: null,
|
||||||
|
budget_action: "warn",
|
||||||
|
budget_warning_threshold: 0.8,
|
||||||
|
discord_rpc_enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function createConfigStore() {
|
function createConfigStore() {
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import { cleanupConversationTracking } from "$lib/tauri";
|
|||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
import { sessionsStore } from "$lib/stores/sessions";
|
import { sessionsStore } from "$lib/stores/sessions";
|
||||||
|
|
||||||
|
export interface ConversationSummary {
|
||||||
|
generatedAt: Date;
|
||||||
|
content: string;
|
||||||
|
messageCount: number;
|
||||||
|
tokenEstimate: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -27,6 +34,8 @@ export interface Conversation {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
lastActivityAt: Date;
|
lastActivityAt: Date;
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
|
summary: ConversationSummary | null;
|
||||||
|
startedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createConversationsStore() {
|
function createConversationsStore() {
|
||||||
@@ -63,6 +72,8 @@ function createConversationsStore() {
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
lastActivityAt: new Date(),
|
lastActivityAt: new Date(),
|
||||||
attachments: [],
|
attachments: [],
|
||||||
|
summary: null,
|
||||||
|
startedAt: new Date(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,7 +431,12 @@ function createConversationsStore() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
addLine: (type: TerminalLine["type"], content: string, toolName?: string) => {
|
addLine: (
|
||||||
|
type: TerminalLine["type"],
|
||||||
|
content: string,
|
||||||
|
toolName?: string,
|
||||||
|
cost?: TerminalLine["cost"]
|
||||||
|
) => {
|
||||||
ensureInitialized();
|
ensureInitialized();
|
||||||
const activeId = get(activeConversationId);
|
const activeId = get(activeConversationId);
|
||||||
if (!activeId) return "";
|
if (!activeId) return "";
|
||||||
@@ -431,6 +447,7 @@ function createConversationsStore() {
|
|||||||
content,
|
content,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
toolName,
|
toolName,
|
||||||
|
cost,
|
||||||
};
|
};
|
||||||
|
|
||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
@@ -451,7 +468,8 @@ function createConversationsStore() {
|
|||||||
conversationId: string,
|
conversationId: string,
|
||||||
type: TerminalLine["type"],
|
type: TerminalLine["type"],
|
||||||
content: string,
|
content: string,
|
||||||
toolName?: string
|
toolName?: string,
|
||||||
|
cost?: TerminalLine["cost"]
|
||||||
) => {
|
) => {
|
||||||
ensureInitialized();
|
ensureInitialized();
|
||||||
|
|
||||||
@@ -461,6 +479,7 @@ function createConversationsStore() {
|
|||||||
content,
|
content,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
toolName,
|
toolName,
|
||||||
|
cost,
|
||||||
};
|
};
|
||||||
|
|
||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
@@ -636,6 +655,130 @@ function createConversationsStore() {
|
|||||||
return conv?.attachments || [];
|
return conv?.attachments || [];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Summary/compaction functions
|
||||||
|
setSummary: (conversationId: string, summary: ConversationSummary) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.summary = summary;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSummary: (conversationId: string) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.summary = null;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getSummary: (conversationId: string): ConversationSummary | null => {
|
||||||
|
const convs = get(conversations);
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
return conv?.summary || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Estimate token count for a conversation (rough approximation: ~4 chars per token)
|
||||||
|
estimateTokenCount: (conversationId: string): number => {
|
||||||
|
const convs = get(conversations);
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (!conv) return 0;
|
||||||
|
|
||||||
|
const relevantLines = conv.terminalLines.filter(
|
||||||
|
(line) => line.type === "user" || line.type === "assistant"
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalChars = relevantLines.reduce((sum, line) => sum + line.content.length, 0);
|
||||||
|
return Math.ceil(totalChars / 4);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get conversation content suitable for summarisation
|
||||||
|
getConversationForSummary: (conversationId: string): string => {
|
||||||
|
const convs = get(conversations);
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (!conv) return "";
|
||||||
|
|
||||||
|
const relevantLines = conv.terminalLines.filter(
|
||||||
|
(line) => line.type === "user" || line.type === "assistant"
|
||||||
|
);
|
||||||
|
|
||||||
|
return relevantLines
|
||||||
|
.map((line) => {
|
||||||
|
const role = line.type === "user" ? "User" : "Assistant";
|
||||||
|
return `${role}: ${line.content}`;
|
||||||
|
})
|
||||||
|
.join("\n\n");
|
||||||
|
},
|
||||||
|
|
||||||
|
// Compact conversation by keeping only recent messages
|
||||||
|
compactConversation: (conversationId: string, keepRecentCount: number = 10) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv && conv.terminalLines.length > keepRecentCount) {
|
||||||
|
// Keep system messages and the most recent user/assistant messages
|
||||||
|
const systemLines = conv.terminalLines.filter(
|
||||||
|
(line) => line.type !== "user" && line.type !== "assistant"
|
||||||
|
);
|
||||||
|
const chatLines = conv.terminalLines.filter(
|
||||||
|
(line) => line.type === "user" || line.type === "assistant"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep only the most recent chat messages
|
||||||
|
const recentChatLines = chatLines.slice(-keepRecentCount);
|
||||||
|
|
||||||
|
// Combine: system lines at original positions + recent chat lines
|
||||||
|
conv.terminalLines = [...systemLines.slice(-5), ...recentChatLines];
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Compact conversation with a summary - clears old messages and injects summary context
|
||||||
|
compactWithSummary: (
|
||||||
|
conversationId: string,
|
||||||
|
summaryContent: string,
|
||||||
|
messageCount: number,
|
||||||
|
tokenEstimate: number
|
||||||
|
) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
// Store the summary
|
||||||
|
conv.summary = {
|
||||||
|
generatedAt: new Date(),
|
||||||
|
content: summaryContent,
|
||||||
|
messageCount,
|
||||||
|
tokenEstimate,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear all messages and add a context injection message
|
||||||
|
conv.terminalLines = [
|
||||||
|
{
|
||||||
|
id: generateLineId(),
|
||||||
|
type: "system",
|
||||||
|
content: `[Conversation compacted] Previous session had ${messageCount} messages (~${tokenEstimate.toLocaleString()} tokens). Context preserved below.`,
|
||||||
|
timestamp: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: generateLineId(),
|
||||||
|
type: "system",
|
||||||
|
content: `Previous Session Context:\n${summaryContent}`,
|
||||||
|
timestamp: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// Add initialization helper
|
// Add initialization helper
|
||||||
initialize: () => {
|
initialize: () => {
|
||||||
ensureInitialized();
|
ensureInitialized();
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { writable, derived } from "svelte/store";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { notificationManager } from "$lib/notifications/notificationManager";
|
||||||
|
|
||||||
|
// Types matching Rust backend
|
||||||
|
export interface DailyCost {
|
||||||
|
date: string;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
cost_usd: number;
|
||||||
|
messages_sent: number;
|
||||||
|
sessions_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CostSummary {
|
||||||
|
period_days: number;
|
||||||
|
total_input_tokens: number;
|
||||||
|
total_output_tokens: number;
|
||||||
|
total_cost: number;
|
||||||
|
total_messages: number;
|
||||||
|
total_sessions: number;
|
||||||
|
average_daily_cost: number;
|
||||||
|
daily_breakdown: DailyCost[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AlertType = "Daily" | "Weekly" | "Monthly";
|
||||||
|
|
||||||
|
export interface CostAlert {
|
||||||
|
alert_type: AlertType;
|
||||||
|
threshold: number;
|
||||||
|
current_cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CostAlertThresholds {
|
||||||
|
daily: number | null;
|
||||||
|
weekly: number | null;
|
||||||
|
monthly: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store state
|
||||||
|
interface CostTrackingState {
|
||||||
|
todayCost: number;
|
||||||
|
weekCost: number;
|
||||||
|
monthCost: number;
|
||||||
|
summary: CostSummary | null;
|
||||||
|
alerts: CostAlert[];
|
||||||
|
thresholds: CostAlertThresholds;
|
||||||
|
isLoading: boolean;
|
||||||
|
lastUpdated: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultState: CostTrackingState = {
|
||||||
|
todayCost: 0,
|
||||||
|
weekCost: 0,
|
||||||
|
monthCost: 0,
|
||||||
|
summary: null,
|
||||||
|
alerts: [],
|
||||||
|
thresholds: { daily: null, weekly: null, monthly: null },
|
||||||
|
isLoading: false,
|
||||||
|
lastUpdated: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createCostTrackingStore() {
|
||||||
|
const { subscribe, set, update } = writable<CostTrackingState>(defaultState);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
update((s) => ({ ...s, isLoading: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [todayCost, weekCost, monthCost, alerts] = await Promise.all([
|
||||||
|
invoke<number>("get_today_cost"),
|
||||||
|
invoke<number>("get_week_cost"),
|
||||||
|
invoke<number>("get_month_cost"),
|
||||||
|
invoke<CostAlert[]>("get_cost_alerts"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
update((s) => ({
|
||||||
|
...s,
|
||||||
|
todayCost,
|
||||||
|
weekCost,
|
||||||
|
monthCost,
|
||||||
|
alerts,
|
||||||
|
isLoading: false,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Trigger notifications for any new alerts
|
||||||
|
if (alerts.length > 0) {
|
||||||
|
for (const alert of alerts) {
|
||||||
|
const message = getAlertMessage(alert);
|
||||||
|
notificationManager.notifyCostAlert(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return alerts;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to refresh cost tracking:", error);
|
||||||
|
update((s) => ({ ...s, isLoading: false }));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSummary(days: number): Promise<CostSummary | null> {
|
||||||
|
try {
|
||||||
|
const summary = await invoke<CostSummary>("get_cost_summary", { days });
|
||||||
|
update((s) => ({ ...s, summary }));
|
||||||
|
return summary;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get cost summary:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setAlertThresholds(thresholds: CostAlertThresholds) {
|
||||||
|
try {
|
||||||
|
await invoke("set_cost_alert_thresholds", {
|
||||||
|
daily: thresholds.daily,
|
||||||
|
weekly: thresholds.weekly,
|
||||||
|
monthly: thresholds.monthly,
|
||||||
|
});
|
||||||
|
update((s) => ({ ...s, thresholds }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to set alert thresholds:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async exportCsv(days: number): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return await invoke<string>("export_cost_csv", { days });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to export CSV:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
set(defaultState);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const costTrackingStore = createCostTrackingStore();
|
||||||
|
|
||||||
|
// Derived stores for formatted values
|
||||||
|
export const formattedCosts = derived(costTrackingStore, ($store) => ({
|
||||||
|
today: formatCost($store.todayCost),
|
||||||
|
week: formatCost($store.weekCost),
|
||||||
|
month: formatCost($store.monthCost),
|
||||||
|
todayRaw: $store.todayCost,
|
||||||
|
weekRaw: $store.weekCost,
|
||||||
|
monthRaw: $store.monthCost,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
export function formatCost(cost: number): string {
|
||||||
|
if (cost < 0.01) {
|
||||||
|
return `$${cost.toFixed(4)}`;
|
||||||
|
}
|
||||||
|
if (cost < 1) {
|
||||||
|
return `$${cost.toFixed(3)}`;
|
||||||
|
}
|
||||||
|
return `$${cost.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAlertType(type: AlertType): string {
|
||||||
|
switch (type) {
|
||||||
|
case "Daily":
|
||||||
|
return "Today";
|
||||||
|
case "Weekly":
|
||||||
|
return "This Week";
|
||||||
|
case "Monthly":
|
||||||
|
return "This Month";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAlertMessage(alert: CostAlert): string {
|
||||||
|
const period = formatAlertType(alert.alert_type);
|
||||||
|
return `${period}'s spending (${formatCost(alert.current_cost)}) has exceeded your ${formatCost(alert.threshold)} threshold`;
|
||||||
|
}
|
||||||
+269
-12
@@ -1,7 +1,25 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import { stats, formattedStats, resetSessionStats } from "./stats";
|
import {
|
||||||
import type { UsageStats } from "./stats";
|
stats,
|
||||||
|
formattedStats,
|
||||||
|
resetSessionStats,
|
||||||
|
contextWarning,
|
||||||
|
getContextWarningMessage,
|
||||||
|
estimateMessageCost,
|
||||||
|
formatTokenCount,
|
||||||
|
MODEL_PRICING,
|
||||||
|
} from "./stats";
|
||||||
|
import type { UsageStats, ToolTokenStats } from "./stats";
|
||||||
|
|
||||||
|
// Helper function to create ToolTokenStats for tests
|
||||||
|
function toolStats(callCount: number, inputTokens = 0, outputTokens = 0): ToolTokenStats {
|
||||||
|
return {
|
||||||
|
call_count: callCount,
|
||||||
|
estimated_input_tokens: inputTokens,
|
||||||
|
estimated_output_tokens: outputTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Mock Tauri APIs
|
// Mock Tauri APIs
|
||||||
vi.mock("@tauri-apps/api/event", () => ({
|
vi.mock("@tauri-apps/api/event", () => ({
|
||||||
@@ -34,6 +52,11 @@ describe("stats store", () => {
|
|||||||
tools_usage: {},
|
tools_usage: {},
|
||||||
session_tools_usage: {},
|
session_tools_usage: {},
|
||||||
session_duration_seconds: 0,
|
session_duration_seconds: 0,
|
||||||
|
context_tokens_used: 0,
|
||||||
|
context_window_limit: 200000,
|
||||||
|
context_utilisation_percent: 0,
|
||||||
|
potential_cache_hits: 0,
|
||||||
|
potential_cache_savings_tokens: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,9 +86,14 @@ describe("stats store", () => {
|
|||||||
session_files_edited: 2,
|
session_files_edited: 2,
|
||||||
files_created: 1,
|
files_created: 1,
|
||||||
session_files_created: 1,
|
session_files_created: 1,
|
||||||
tools_usage: { Read: 5, Edit: 3 },
|
tools_usage: { Read: toolStats(5), Edit: toolStats(3) },
|
||||||
session_tools_usage: { Read: 2, Edit: 1 },
|
session_tools_usage: { Read: toolStats(2), Edit: toolStats(1) },
|
||||||
session_duration_seconds: 300,
|
session_duration_seconds: 300,
|
||||||
|
context_tokens_used: 500,
|
||||||
|
context_window_limit: 200000,
|
||||||
|
context_utilisation_percent: 0.25,
|
||||||
|
potential_cache_hits: 0,
|
||||||
|
potential_cache_savings_tokens: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
stats.set(newStats);
|
stats.set(newStats);
|
||||||
@@ -74,7 +102,8 @@ describe("stats store", () => {
|
|||||||
expect(currentStats.total_input_tokens).toBe(1000);
|
expect(currentStats.total_input_tokens).toBe(1000);
|
||||||
expect(currentStats.total_output_tokens).toBe(2000);
|
expect(currentStats.total_output_tokens).toBe(2000);
|
||||||
expect(currentStats.model).toBe("claude-sonnet-4");
|
expect(currentStats.model).toBe("claude-sonnet-4");
|
||||||
expect(currentStats.tools_usage).toEqual({ Read: 5, Edit: 3 });
|
expect(currentStats.tools_usage.Read?.call_count).toBe(5);
|
||||||
|
expect(currentStats.tools_usage.Edit?.call_count).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can be updated with update function", () => {
|
it("can be updated with update function", () => {
|
||||||
@@ -109,9 +138,14 @@ describe("stats store", () => {
|
|||||||
session_files_edited: 2,
|
session_files_edited: 2,
|
||||||
files_created: 1,
|
files_created: 1,
|
||||||
session_files_created: 1,
|
session_files_created: 1,
|
||||||
tools_usage: { Read: 5, Edit: 3 },
|
tools_usage: { Read: toolStats(5), Edit: toolStats(3) },
|
||||||
session_tools_usage: { Read: 2, Edit: 1 },
|
session_tools_usage: { Read: toolStats(2), Edit: toolStats(1) },
|
||||||
session_duration_seconds: 300,
|
session_duration_seconds: 300,
|
||||||
|
context_tokens_used: 500,
|
||||||
|
context_window_limit: 200000,
|
||||||
|
context_utilisation_percent: 0.25,
|
||||||
|
potential_cache_hits: 0,
|
||||||
|
potential_cache_savings_tokens: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset session stats
|
// Reset session stats
|
||||||
@@ -127,7 +161,8 @@ describe("stats store", () => {
|
|||||||
expect(currentStats.code_blocks_generated).toBe(3);
|
expect(currentStats.code_blocks_generated).toBe(3);
|
||||||
expect(currentStats.files_edited).toBe(5);
|
expect(currentStats.files_edited).toBe(5);
|
||||||
expect(currentStats.files_created).toBe(1);
|
expect(currentStats.files_created).toBe(1);
|
||||||
expect(currentStats.tools_usage).toEqual({ Read: 5, Edit: 3 });
|
expect(currentStats.tools_usage.Read?.call_count).toBe(5);
|
||||||
|
expect(currentStats.tools_usage.Edit?.call_count).toBe(3);
|
||||||
expect(currentStats.model).toBe("claude-sonnet-4");
|
expect(currentStats.model).toBe("claude-sonnet-4");
|
||||||
|
|
||||||
// Session stats should be reset
|
// Session stats should be reset
|
||||||
@@ -277,8 +312,8 @@ describe("stats store", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("exposes tools usage directly", () => {
|
it("exposes tools usage directly", () => {
|
||||||
const toolsUsage = { Read: 10, Edit: 5, Write: 3 };
|
const toolsUsage = { Read: toolStats(10), Edit: toolStats(5), Write: toolStats(3) };
|
||||||
const sessionToolsUsage = { Read: 2, Edit: 1 };
|
const sessionToolsUsage = { Read: toolStats(2), Edit: toolStats(1) };
|
||||||
|
|
||||||
stats.update((current) => ({
|
stats.update((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -331,9 +366,14 @@ describe("stats store", () => {
|
|||||||
session_files_edited: 1,
|
session_files_edited: 1,
|
||||||
files_created: 1,
|
files_created: 1,
|
||||||
session_files_created: 0,
|
session_files_created: 0,
|
||||||
tools_usage: { Read: 3 },
|
tools_usage: { Read: toolStats(3) },
|
||||||
session_tools_usage: { Read: 1 },
|
session_tools_usage: { Read: toolStats(1) },
|
||||||
session_duration_seconds: 60,
|
session_duration_seconds: 60,
|
||||||
|
context_tokens_used: 50,
|
||||||
|
context_window_limit: 200000,
|
||||||
|
context_utilisation_percent: 0.025,
|
||||||
|
potential_cache_hits: 0,
|
||||||
|
potential_cache_savings_tokens: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
stats.set(fullStats);
|
stats.set(fullStats);
|
||||||
@@ -343,4 +383,221 @@ describe("stats store", () => {
|
|||||||
expect(currentStats).toEqual(fullStats);
|
expect(currentStats).toEqual(fullStats);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("context window tracking", () => {
|
||||||
|
it("tracks context tokens used", () => {
|
||||||
|
stats.update((current) => ({
|
||||||
|
...current,
|
||||||
|
context_tokens_used: 100000,
|
||||||
|
context_window_limit: 200000,
|
||||||
|
context_utilisation_percent: 50.0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const currentStats = get(stats);
|
||||||
|
expect(currentStats.context_tokens_used).toBe(100000);
|
||||||
|
expect(currentStats.context_window_limit).toBe(200000);
|
||||||
|
expect(currentStats.context_utilisation_percent).toBe(50.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats context stats correctly", () => {
|
||||||
|
stats.update((current) => ({
|
||||||
|
...current,
|
||||||
|
context_tokens_used: 150000,
|
||||||
|
context_window_limit: 200000,
|
||||||
|
context_utilisation_percent: 75.5,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const formatted = get(formattedStats);
|
||||||
|
expect(formatted.contextUsed).toBe("150,000");
|
||||||
|
expect(formatted.contextLimit).toBe("200,000");
|
||||||
|
expect(formatted.contextRemaining).toBe("50,000");
|
||||||
|
expect(formatted.contextUtilisation).toBe("75.5%");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates remaining tokens correctly at limit", () => {
|
||||||
|
stats.update((current) => ({
|
||||||
|
...current,
|
||||||
|
context_tokens_used: 200000,
|
||||||
|
context_window_limit: 200000,
|
||||||
|
context_utilisation_percent: 100.0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const formatted = get(formattedStats);
|
||||||
|
expect(formatted.contextRemaining).toBe("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles over-limit gracefully", () => {
|
||||||
|
stats.update((current) => ({
|
||||||
|
...current,
|
||||||
|
context_tokens_used: 250000,
|
||||||
|
context_window_limit: 200000,
|
||||||
|
context_utilisation_percent: 125.0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const formatted = get(formattedStats);
|
||||||
|
expect(formatted.contextRemaining).toBe("0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("contextWarning derived store", () => {
|
||||||
|
it("returns null when under 50%", () => {
|
||||||
|
stats.update((current) => ({
|
||||||
|
...current,
|
||||||
|
context_utilisation_percent: 40.0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const warning = get(contextWarning);
|
||||||
|
expect(warning).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns moderate when between 50-74%", () => {
|
||||||
|
stats.update((current) => ({
|
||||||
|
...current,
|
||||||
|
context_utilisation_percent: 60.0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const warning = get(contextWarning);
|
||||||
|
expect(warning).toBe("moderate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns high when between 75-89%", () => {
|
||||||
|
stats.update((current) => ({
|
||||||
|
...current,
|
||||||
|
context_utilisation_percent: 80.0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const warning = get(contextWarning);
|
||||||
|
expect(warning).toBe("high");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns critical when 90%+", () => {
|
||||||
|
stats.update((current) => ({
|
||||||
|
...current,
|
||||||
|
context_utilisation_percent: 95.0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const warning = get(contextWarning);
|
||||||
|
expect(warning).toBe("critical");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getContextWarningMessage", () => {
|
||||||
|
it("returns correct message for moderate warning", () => {
|
||||||
|
const message = getContextWarningMessage("moderate");
|
||||||
|
expect(message).toContain("50%+");
|
||||||
|
expect(message).toContain("Consider starting a new conversation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct message for high warning", () => {
|
||||||
|
const message = getContextWarningMessage("high");
|
||||||
|
expect(message).toContain("75%+");
|
||||||
|
expect(message).toContain("Responses may degrade");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct message for critical warning", () => {
|
||||||
|
const message = getContextWarningMessage("critical");
|
||||||
|
expect(message).toContain("90%+");
|
||||||
|
expect(message).toContain("Start a new conversation");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatTokenCount", () => {
|
||||||
|
it("formats small numbers directly", () => {
|
||||||
|
expect(formatTokenCount(0)).toBe("0");
|
||||||
|
expect(formatTokenCount(100)).toBe("100");
|
||||||
|
expect(formatTokenCount(999)).toBe("999");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats thousands with K suffix", () => {
|
||||||
|
expect(formatTokenCount(1000)).toBe("1.0K");
|
||||||
|
expect(formatTokenCount(1500)).toBe("1.5K");
|
||||||
|
expect(formatTokenCount(10000)).toBe("10.0K");
|
||||||
|
expect(formatTokenCount(999999)).toBe("1000.0K");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats millions with M suffix", () => {
|
||||||
|
expect(formatTokenCount(1000000)).toBe("1.0M");
|
||||||
|
expect(formatTokenCount(1500000)).toBe("1.5M");
|
||||||
|
expect(formatTokenCount(10000000)).toBe("10.0M");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("estimateMessageCost", () => {
|
||||||
|
it("estimates tokens at ~4 chars per token", () => {
|
||||||
|
const result = estimateMessageCost("test", 0, null); // 4 chars = 1 token
|
||||||
|
expect(result.messageTokens).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds up partial tokens", () => {
|
||||||
|
const result = estimateMessageCost("a", 0, null); // 1 char rounds up to 1 token
|
||||||
|
expect(result.messageTokens).toBe(1);
|
||||||
|
|
||||||
|
const result2 = estimateMessageCost("abcde", 0, null); // 5 chars = 2 tokens
|
||||||
|
expect(result2.messageTokens).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 tokens for empty string", () => {
|
||||||
|
const result = estimateMessageCost("", 0, null);
|
||||||
|
expect(result.messageTokens).toBe(0);
|
||||||
|
expect(result.estimatedCost).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds context tokens to total", () => {
|
||||||
|
const result = estimateMessageCost("test", 1000, null); // 1 token + 1000 context
|
||||||
|
expect(result.messageTokens).toBe(1);
|
||||||
|
expect(result.totalInputTokens).toBe(1001);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates cost using Sonnet pricing by default", () => {
|
||||||
|
// 100 chars = 25 tokens, $3 per million input tokens
|
||||||
|
const result = estimateMessageCost("a".repeat(100), 0, null);
|
||||||
|
expect(result.messageTokens).toBe(25);
|
||||||
|
const expectedCost = (25 / 1_000_000) * 3.0;
|
||||||
|
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses Opus pricing for Opus models", () => {
|
||||||
|
const result = estimateMessageCost("a".repeat(100), 0, "claude-opus-4-5-20251101");
|
||||||
|
expect(result.messageTokens).toBe(25);
|
||||||
|
const expectedCost = (25 / 1_000_000) * 5.0; // Opus 4.5: $5 per million input
|
||||||
|
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses Haiku pricing for Haiku models", () => {
|
||||||
|
const result = estimateMessageCost("a".repeat(100), 0, "claude-3-5-haiku-20241022");
|
||||||
|
expect(result.messageTokens).toBe(25);
|
||||||
|
const expectedCost = (25 / 1_000_000) * 1.0; // Haiku: $1 per million
|
||||||
|
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to Sonnet pricing for unknown models", () => {
|
||||||
|
const result = estimateMessageCost("a".repeat(100), 0, "unknown-model");
|
||||||
|
expect(result.messageTokens).toBe(25);
|
||||||
|
const expectedCost = (25 / 1_000_000) * 3.0; // Default Sonnet: $3 per million
|
||||||
|
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MODEL_PRICING", () => {
|
||||||
|
it("contains expected Opus pricing", () => {
|
||||||
|
// Opus 4.5 has reduced pricing
|
||||||
|
expect(MODEL_PRICING["claude-opus-4-5-20251101"]).toEqual({ input: 5.0, output: 25.0 });
|
||||||
|
// Previous Opus models have higher pricing
|
||||||
|
expect(MODEL_PRICING["claude-opus-4-1-20250805"]).toEqual({ input: 15.0, output: 75.0 });
|
||||||
|
expect(MODEL_PRICING["claude-opus-4-20250514"]).toEqual({ input: 15.0, output: 75.0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains expected Sonnet pricing", () => {
|
||||||
|
expect(MODEL_PRICING["claude-sonnet-4-5-20250929"]).toEqual({ input: 3.0, output: 15.0 });
|
||||||
|
expect(MODEL_PRICING["claude-sonnet-4-20250514"]).toEqual({ input: 3.0, output: 15.0 });
|
||||||
|
expect(MODEL_PRICING["claude-3-7-sonnet-20250219"]).toEqual({ input: 3.0, output: 15.0 });
|
||||||
|
expect(MODEL_PRICING["claude-3-5-sonnet-20241022"]).toEqual({ input: 3.0, output: 15.0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains expected Haiku pricing", () => {
|
||||||
|
expect(MODEL_PRICING["claude-haiku-4-5-20251001"]).toEqual({ input: 1.0, output: 5.0 });
|
||||||
|
expect(MODEL_PRICING["claude-3-5-haiku-20241022"]).toEqual({ input: 1.0, output: 5.0 });
|
||||||
|
expect(MODEL_PRICING["claude-3-haiku-20240307"]).toEqual({ input: 0.25, output: 1.25 });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+217
-4
@@ -1,6 +1,67 @@
|
|||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { costTrackingStore } from "./costTracking";
|
||||||
|
import { configStore } from "./config";
|
||||||
|
|
||||||
|
export type ContextWarning = "moderate" | "high" | "critical";
|
||||||
|
export type BudgetType = "token" | "cost";
|
||||||
|
|
||||||
|
// Model pricing (per million tokens) - keep in sync with stats.rs
|
||||||
|
// Source: https://platform.claude.com/docs/en/about-claude/models/overview
|
||||||
|
export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
|
||||||
|
// Current generation (Claude 4.5)
|
||||||
|
"claude-opus-4-5-20251101": { input: 5.0, output: 25.0 },
|
||||||
|
"claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 },
|
||||||
|
"claude-haiku-4-5-20251001": { input: 1.0, output: 5.0 },
|
||||||
|
// Previous generation (Claude 4.x)
|
||||||
|
"claude-opus-4-1-20250805": { input: 15.0, output: 75.0 },
|
||||||
|
"claude-opus-4-20250514": { input: 15.0, output: 75.0 },
|
||||||
|
"claude-sonnet-4-20250514": { input: 3.0, output: 15.0 },
|
||||||
|
// Legacy (Claude 3.x)
|
||||||
|
"claude-3-7-sonnet-20250219": { input: 3.0, output: 15.0 },
|
||||||
|
"claude-3-5-sonnet-20241022": { input: 3.0, output: 15.0 },
|
||||||
|
"claude-3-5-sonnet-20240620": { input: 3.0, output: 15.0 },
|
||||||
|
"claude-3-5-haiku-20241022": { input: 1.0, output: 5.0 },
|
||||||
|
"claude-3-opus-20240229": { input: 15.0, output: 75.0 },
|
||||||
|
"claude-3-sonnet-20240229": { input: 3.0, output: 15.0 },
|
||||||
|
"claude-3-haiku-20240307": { input: 0.25, output: 1.25 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PRICING = { input: 3.0, output: 15.0 }; // Default to Sonnet
|
||||||
|
|
||||||
|
export interface CostEstimate {
|
||||||
|
messageTokens: number;
|
||||||
|
totalInputTokens: number;
|
||||||
|
estimatedCost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate cost for a message before sending
|
||||||
|
export function estimateMessageCost(
|
||||||
|
messageText: string,
|
||||||
|
contextTokensUsed: number,
|
||||||
|
model: string | null
|
||||||
|
): CostEstimate {
|
||||||
|
// Estimate tokens using ~4 chars per token heuristic
|
||||||
|
const messageTokens = Math.ceil(messageText.length / 4);
|
||||||
|
const totalInputTokens = contextTokensUsed + messageTokens;
|
||||||
|
|
||||||
|
const pricing = model ? (MODEL_PRICING[model] ?? DEFAULT_PRICING) : DEFAULT_PRICING;
|
||||||
|
const estimatedCost = (totalInputTokens / 1_000_000) * pricing.input;
|
||||||
|
|
||||||
|
return { messageTokens, totalInputTokens, estimatedCost };
|
||||||
|
}
|
||||||
|
export type BudgetStatus =
|
||||||
|
| { type: "ok" }
|
||||||
|
| { type: "warning"; budget_type: BudgetType; percent_used: number }
|
||||||
|
| { type: "exceeded"; budget_type: BudgetType };
|
||||||
|
|
||||||
|
// Per-tool token usage statistics
|
||||||
|
export interface ToolTokenStats {
|
||||||
|
call_count: number;
|
||||||
|
estimated_input_tokens: number;
|
||||||
|
estimated_output_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UsageStats {
|
export interface UsageStats {
|
||||||
total_input_tokens: number;
|
total_input_tokens: number;
|
||||||
@@ -20,9 +81,18 @@ export interface UsageStats {
|
|||||||
session_files_edited: number;
|
session_files_edited: number;
|
||||||
files_created: number;
|
files_created: number;
|
||||||
session_files_created: number;
|
session_files_created: number;
|
||||||
tools_usage: Record<string, number>;
|
tools_usage: Record<string, ToolTokenStats>;
|
||||||
session_tools_usage: Record<string, number>;
|
session_tools_usage: Record<string, ToolTokenStats>;
|
||||||
session_duration_seconds: number;
|
session_duration_seconds: number;
|
||||||
|
|
||||||
|
// Context window tracking
|
||||||
|
context_tokens_used: number;
|
||||||
|
context_window_limit: number;
|
||||||
|
context_utilisation_percent: number;
|
||||||
|
|
||||||
|
// Cache analytics (tracks potential savings from repeated tool calls)
|
||||||
|
potential_cache_hits: number;
|
||||||
|
potential_cache_savings_tokens: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main stats store
|
// Main stats store
|
||||||
@@ -45,10 +115,26 @@ export const stats = writable<UsageStats>({
|
|||||||
tools_usage: {},
|
tools_usage: {},
|
||||||
session_tools_usage: {},
|
session_tools_usage: {},
|
||||||
session_duration_seconds: 0,
|
session_duration_seconds: 0,
|
||||||
|
context_tokens_used: 0,
|
||||||
|
context_window_limit: 200000,
|
||||||
|
context_utilisation_percent: 0,
|
||||||
|
potential_cache_hits: 0,
|
||||||
|
potential_cache_savings_tokens: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Format token count with K/M suffix
|
||||||
|
export function formatTokenCount(tokens: number): string {
|
||||||
|
if (tokens >= 1000000) {
|
||||||
|
return `${(tokens / 1000000).toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
if (tokens >= 1000) {
|
||||||
|
return `${(tokens / 1000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
return tokens.toString();
|
||||||
|
}
|
||||||
|
|
||||||
// Derived store for formatted display values
|
// Derived store for formatted display values
|
||||||
export const formattedStats = derived(stats, ($stats) => {
|
export const formattedStats = derived([stats, configStore.config], ([$stats, $config]) => {
|
||||||
const formatNumber = (num: number) => num.toLocaleString();
|
const formatNumber = (num: number) => num.toLocaleString();
|
||||||
const formatCost = (cost: number) => `$${cost.toFixed(4)}`;
|
const formatCost = (cost: number) => `$${cost.toFixed(4)}`;
|
||||||
const formatDuration = (seconds: number) => {
|
const formatDuration = (seconds: number) => {
|
||||||
@@ -65,6 +151,23 @@ export const formattedStats = derived(stats, ($stats) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Format tool stats with token info
|
||||||
|
const formatToolStats = (toolStats: Record<string, ToolTokenStats>) => {
|
||||||
|
return Object.entries(toolStats).map(([name, stats]) => ({
|
||||||
|
name,
|
||||||
|
callCount: stats.call_count,
|
||||||
|
totalTokens: stats.estimated_input_tokens + stats.estimated_output_tokens,
|
||||||
|
formattedTokens: formatTokenCount(
|
||||||
|
stats.estimated_input_tokens + stats.estimated_output_tokens
|
||||||
|
),
|
||||||
|
inputTokens: stats.estimated_input_tokens,
|
||||||
|
outputTokens: stats.estimated_output_tokens,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use the model from stats if available, otherwise fall back to the configured model
|
||||||
|
const currentModel = $stats.model ?? $config.model ?? "No model selected";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalTokens: formatNumber($stats.total_input_tokens + $stats.total_output_tokens),
|
totalTokens: formatNumber($stats.total_input_tokens + $stats.total_output_tokens),
|
||||||
totalInputTokens: formatNumber($stats.total_input_tokens),
|
totalInputTokens: formatNumber($stats.total_input_tokens),
|
||||||
@@ -74,7 +177,7 @@ export const formattedStats = derived(stats, ($stats) => {
|
|||||||
sessionInputTokens: formatNumber($stats.session_input_tokens),
|
sessionInputTokens: formatNumber($stats.session_input_tokens),
|
||||||
sessionOutputTokens: formatNumber($stats.session_output_tokens),
|
sessionOutputTokens: formatNumber($stats.session_output_tokens),
|
||||||
sessionCost: formatCost($stats.session_cost_usd),
|
sessionCost: formatCost($stats.session_cost_usd),
|
||||||
model: $stats.model || "No model selected",
|
model: currentModel,
|
||||||
|
|
||||||
// New formatted fields
|
// New formatted fields
|
||||||
messagesTotal: formatNumber($stats.messages_exchanged),
|
messagesTotal: formatNumber($stats.messages_exchanged),
|
||||||
@@ -88,9 +191,116 @@ export const formattedStats = derived(stats, ($stats) => {
|
|||||||
sessionDuration: formatDuration($stats.session_duration_seconds),
|
sessionDuration: formatDuration($stats.session_duration_seconds),
|
||||||
toolsUsage: $stats.tools_usage,
|
toolsUsage: $stats.tools_usage,
|
||||||
sessionToolsUsage: $stats.session_tools_usage,
|
sessionToolsUsage: $stats.session_tools_usage,
|
||||||
|
// Formatted tool stats with token info
|
||||||
|
sessionToolsFormatted: formatToolStats($stats.session_tools_usage),
|
||||||
|
toolsFormatted: formatToolStats($stats.tools_usage),
|
||||||
|
|
||||||
|
// Context window tracking
|
||||||
|
contextUsed: formatNumber($stats.context_tokens_used),
|
||||||
|
contextLimit: formatNumber($stats.context_window_limit),
|
||||||
|
contextRemaining: formatNumber(
|
||||||
|
Math.max(0, $stats.context_window_limit - $stats.context_tokens_used)
|
||||||
|
),
|
||||||
|
contextUtilisation: `${$stats.context_utilisation_percent.toFixed(1)}%`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Derived store for context warning state
|
||||||
|
export const contextWarning = derived(stats, ($stats): ContextWarning | null => {
|
||||||
|
if ($stats.context_utilisation_percent >= 90) {
|
||||||
|
return "critical";
|
||||||
|
} else if ($stats.context_utilisation_percent >= 75) {
|
||||||
|
return "high";
|
||||||
|
} else if ($stats.context_utilisation_percent >= 50) {
|
||||||
|
return "moderate";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get warning message for context utilisation
|
||||||
|
export function getContextWarningMessage(warning: ContextWarning): string {
|
||||||
|
switch (warning) {
|
||||||
|
case "moderate":
|
||||||
|
return "Context window is 50%+ full. Consider starting a new conversation for better performance.";
|
||||||
|
case "high":
|
||||||
|
return "Context window is 75%+ full. Responses may degrade. Consider summarising or starting fresh.";
|
||||||
|
case "critical":
|
||||||
|
return "Context window is nearly full (90%+)! Start a new conversation to avoid errors.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Budget checking functions
|
||||||
|
export function checkBudget(
|
||||||
|
stats: UsageStats,
|
||||||
|
budgetEnabled: boolean,
|
||||||
|
tokenBudget: number | null,
|
||||||
|
costBudget: number | null,
|
||||||
|
warningThreshold: number
|
||||||
|
): BudgetStatus {
|
||||||
|
if (!budgetEnabled) {
|
||||||
|
return { type: "ok" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionTokens = stats.session_input_tokens + stats.session_output_tokens;
|
||||||
|
|
||||||
|
// Check token budget
|
||||||
|
if (tokenBudget !== null) {
|
||||||
|
if (sessionTokens >= tokenBudget) {
|
||||||
|
return { type: "exceeded", budget_type: "token" };
|
||||||
|
}
|
||||||
|
const percentUsed = sessionTokens / tokenBudget;
|
||||||
|
if (percentUsed >= warningThreshold) {
|
||||||
|
return { type: "warning", budget_type: "token", percent_used: percentUsed * 100 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cost budget
|
||||||
|
if (costBudget !== null) {
|
||||||
|
if (stats.session_cost_usd >= costBudget) {
|
||||||
|
return { type: "exceeded", budget_type: "cost" };
|
||||||
|
}
|
||||||
|
const percentUsed = stats.session_cost_usd / costBudget;
|
||||||
|
if (percentUsed >= warningThreshold) {
|
||||||
|
return { type: "warning", budget_type: "cost", percent_used: percentUsed * 100 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "ok" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get budget status message
|
||||||
|
export function getBudgetStatusMessage(status: BudgetStatus): string | null {
|
||||||
|
if (status.type === "ok") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const budgetTypeLabel = status.budget_type === "token" ? "token" : "cost";
|
||||||
|
|
||||||
|
if (status.type === "exceeded") {
|
||||||
|
return `Session ${budgetTypeLabel} budget exceeded! Consider starting a new session.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Approaching ${budgetTypeLabel} budget limit (${status.percent_used.toFixed(0)}% used).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get remaining budget values
|
||||||
|
export function getRemainingTokenBudget(
|
||||||
|
stats: UsageStats,
|
||||||
|
tokenBudget: number | null
|
||||||
|
): number | null {
|
||||||
|
if (tokenBudget === null) return null;
|
||||||
|
const used = stats.session_input_tokens + stats.session_output_tokens;
|
||||||
|
return Math.max(0, tokenBudget - used);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRemainingCostBudget(
|
||||||
|
stats: UsageStats,
|
||||||
|
costBudget: number | null
|
||||||
|
): number | null {
|
||||||
|
if (costBudget === null) return null;
|
||||||
|
return Math.max(0, costBudget - stats.session_cost_usd);
|
||||||
|
}
|
||||||
|
|
||||||
// Note: Cost calculation is now done in the Rust backend
|
// Note: Cost calculation is now done in the Rust backend
|
||||||
|
|
||||||
// Initialize stats listener
|
// Initialize stats listener
|
||||||
@@ -102,6 +312,9 @@ export async function initStatsListener() {
|
|||||||
|
|
||||||
// The backend already tracks all totals - just set the stats directly
|
// The backend already tracks all totals - just set the stats directly
|
||||||
stats.set(newStats);
|
stats.set(newStats);
|
||||||
|
|
||||||
|
// Refresh cost tracking to check for alerts (debounced - won't spam)
|
||||||
|
costTrackingStore.refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load initial persisted stats from backend (no bridge required)
|
// Load initial persisted stats from backend (no bridge required)
|
||||||
|
|||||||
+89
-3
@@ -90,6 +90,11 @@ interface OutputPayload {
|
|||||||
content: string;
|
content: string;
|
||||||
tool_name: string | null;
|
tool_name: string | null;
|
||||||
conversation_id?: string;
|
conversation_id?: string;
|
||||||
|
cost?: {
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
cost_usd: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConnectionPayload {
|
interface ConnectionPayload {
|
||||||
@@ -242,7 +247,16 @@ export async function initializeTauriListeners() {
|
|||||||
unlisteners.push(stateUnlisten);
|
unlisteners.push(stateUnlisten);
|
||||||
|
|
||||||
const outputUnlisten = await listen<OutputPayload>("claude:output", (event) => {
|
const outputUnlisten = await listen<OutputPayload>("claude:output", (event) => {
|
||||||
const { line_type, content, tool_name, conversation_id } = event.payload;
|
const { line_type, content, tool_name, conversation_id, cost } = event.payload;
|
||||||
|
|
||||||
|
// Convert snake_case cost to camelCase for TypeScript
|
||||||
|
const costData = cost
|
||||||
|
? {
|
||||||
|
inputTokens: cost.input_tokens,
|
||||||
|
outputTokens: cost.output_tokens,
|
||||||
|
costUsd: cost.cost_usd,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Always store the output to the correct conversation
|
// Always store the output to the correct conversation
|
||||||
if (conversation_id) {
|
if (conversation_id) {
|
||||||
@@ -250,14 +264,16 @@ export async function initializeTauriListeners() {
|
|||||||
conversation_id,
|
conversation_id,
|
||||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
||||||
content,
|
content,
|
||||||
tool_name || undefined
|
tool_name || undefined,
|
||||||
|
costData
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to active conversation if no conversation_id provided
|
// Fallback to active conversation if no conversation_id provided
|
||||||
claudeStore.addLine(
|
claudeStore.addLine(
|
||||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
||||||
content,
|
content,
|
||||||
tool_name || undefined
|
tool_name || undefined,
|
||||||
|
costData
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -357,3 +373,73 @@ export function cleanupTauriListeners() {
|
|||||||
// Cleanup notification rules
|
// Cleanup notification rules
|
||||||
cleanupNotificationRules();
|
cleanupNotificationRules();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function initializeDiscordRpc() {
|
||||||
|
const config = configStore.getConfig();
|
||||||
|
if (config.discord_rpc_enabled) {
|
||||||
|
try {
|
||||||
|
const startedAt = new Date();
|
||||||
|
const startedAtUnixSeconds = Math.floor(startedAt.getTime() / 1000);
|
||||||
|
const model = config.model || "claude";
|
||||||
|
|
||||||
|
await invoke("log_discord_rpc", {
|
||||||
|
message: `[FRONTEND] Attempting to initialize Discord RPC: session='Idle', model='${model}', timestamp=${startedAtUnixSeconds}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Initializing Discord RPC with initial activity:", {
|
||||||
|
session_name: "Idle",
|
||||||
|
model,
|
||||||
|
started_at: startedAtUnixSeconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
await invoke("init_discord_rpc", {
|
||||||
|
sessionName: "Idle",
|
||||||
|
model,
|
||||||
|
startedAt: startedAtUnixSeconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
await invoke("log_discord_rpc", {
|
||||||
|
message: "[FRONTEND] Discord RPC initialized successfully!",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Discord RPC initialized successfully with initial presence");
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
await invoke("log_discord_rpc", {
|
||||||
|
message: `[FRONTEND] ERROR: Failed to initialize Discord RPC: ${errorMessage}`,
|
||||||
|
});
|
||||||
|
console.error("Failed to initialize Discord RPC:", error);
|
||||||
|
console.warn("Discord RPC will be unavailable. Make sure Discord is running.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await invoke("log_discord_rpc", {
|
||||||
|
message: "[FRONTEND] Discord RPC is disabled in config, skipping initialization",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDiscordRpc(sessionName: string, model: string, startedAt: Date) {
|
||||||
|
const config = configStore.getConfig();
|
||||||
|
if (!config.discord_rpc_enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startedAtUnixSeconds = Math.floor(startedAt.getTime() / 1000);
|
||||||
|
await invoke("update_discord_rpc", {
|
||||||
|
sessionName: sessionName,
|
||||||
|
model,
|
||||||
|
startedAt: startedAtUnixSeconds,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update Discord RPC:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopDiscordRpc() {
|
||||||
|
try {
|
||||||
|
await invoke("stop_discord_rpc");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to stop Discord RPC:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ export interface TerminalLine {
|
|||||||
content: string;
|
content: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
|
// Cost tracking for this specific message
|
||||||
|
cost?: {
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
costUsd: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemInitMessage {
|
export interface SystemInitMessage {
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
generateSummaryPrompt,
|
||||||
|
generateContextInjection,
|
||||||
|
estimateTokens,
|
||||||
|
createSummary,
|
||||||
|
shouldSuggestCompaction,
|
||||||
|
formatTokenCount,
|
||||||
|
sanitizeForJson,
|
||||||
|
} from "./conversationUtils";
|
||||||
|
import type { ConversationSummary } from "$lib/stores/conversations";
|
||||||
|
|
||||||
|
describe("conversationUtils", () => {
|
||||||
|
describe("generateSummaryPrompt", () => {
|
||||||
|
it("generates a prompt containing the conversation content", () => {
|
||||||
|
const content = "User: Hello\n\nAssistant: Hi there!";
|
||||||
|
const prompt = generateSummaryPrompt(content);
|
||||||
|
|
||||||
|
expect(prompt).toContain(content);
|
||||||
|
expect(prompt).toContain("summary");
|
||||||
|
expect(prompt).toContain("Key topics");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty content", () => {
|
||||||
|
const prompt = generateSummaryPrompt("");
|
||||||
|
expect(prompt).toContain("summary");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateContextInjection", () => {
|
||||||
|
it("creates context injection message from summary", () => {
|
||||||
|
const summary: ConversationSummary = {
|
||||||
|
generatedAt: new Date("2024-01-01"),
|
||||||
|
content: "We discussed building a new feature",
|
||||||
|
messageCount: 50,
|
||||||
|
tokenEstimate: 10000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const injection = generateContextInjection(summary);
|
||||||
|
|
||||||
|
expect(injection).toContain("Previous Session Context");
|
||||||
|
expect(injection).toContain("We discussed building a new feature");
|
||||||
|
expect(injection).toContain("50 messages");
|
||||||
|
expect(injection).toContain("10,000 tokens");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("estimateTokens", () => {
|
||||||
|
it("estimates tokens at ~4 chars per token", () => {
|
||||||
|
expect(estimateTokens("")).toBe(0);
|
||||||
|
expect(estimateTokens("test")).toBe(1); // 4 chars = 1 token
|
||||||
|
expect(estimateTokens("testing")).toBe(2); // 7 chars = 2 tokens
|
||||||
|
expect(estimateTokens("a".repeat(100))).toBe(25); // 100 chars = 25 tokens
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds up partial tokens", () => {
|
||||||
|
expect(estimateTokens("a")).toBe(1); // 1 char rounds up to 1 token
|
||||||
|
expect(estimateTokens("ab")).toBe(1); // 2 chars rounds up to 1 token
|
||||||
|
expect(estimateTokens("abc")).toBe(1); // 3 chars rounds up to 1 token
|
||||||
|
expect(estimateTokens("abcde")).toBe(2); // 5 chars rounds up to 2 tokens
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createSummary", () => {
|
||||||
|
it("creates a valid ConversationSummary object", () => {
|
||||||
|
const summary = createSummary("Test summary content", 25, 5000);
|
||||||
|
|
||||||
|
expect(summary.content).toBe("Test summary content");
|
||||||
|
expect(summary.messageCount).toBe(25);
|
||||||
|
expect(summary.tokenEstimate).toBe(5000);
|
||||||
|
expect(summary.generatedAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets generatedAt to current time", () => {
|
||||||
|
const before = new Date();
|
||||||
|
const summary = createSummary("content", 10, 1000);
|
||||||
|
const after = new Date();
|
||||||
|
|
||||||
|
expect(summary.generatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||||
|
expect(summary.generatedAt.getTime()).toBeLessThanOrEqual(after.getTime());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldSuggestCompaction", () => {
|
||||||
|
it("returns false when under threshold", () => {
|
||||||
|
expect(shouldSuggestCompaction(50000, 200000, 60)).toBe(false); // 25%
|
||||||
|
expect(shouldSuggestCompaction(100000, 200000, 60)).toBe(false); // 50%
|
||||||
|
expect(shouldSuggestCompaction(119000, 200000, 60)).toBe(false); // 59.5%
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when at or above threshold", () => {
|
||||||
|
expect(shouldSuggestCompaction(120000, 200000, 60)).toBe(true); // 60%
|
||||||
|
expect(shouldSuggestCompaction(150000, 200000, 60)).toBe(true); // 75%
|
||||||
|
expect(shouldSuggestCompaction(200000, 200000, 60)).toBe(true); // 100%
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles zero context window limit", () => {
|
||||||
|
expect(shouldSuggestCompaction(50000, 0, 60)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses default threshold of 60%", () => {
|
||||||
|
expect(shouldSuggestCompaction(110000, 200000)).toBe(false); // 55%
|
||||||
|
expect(shouldSuggestCompaction(130000, 200000)).toBe(true); // 65%
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects custom threshold", () => {
|
||||||
|
expect(shouldSuggestCompaction(70000, 200000, 40)).toBe(false); // 35%
|
||||||
|
expect(shouldSuggestCompaction(90000, 200000, 40)).toBe(true); // 45%
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatTokenCount", () => {
|
||||||
|
it("formats small numbers directly", () => {
|
||||||
|
expect(formatTokenCount(0)).toBe("0");
|
||||||
|
expect(formatTokenCount(100)).toBe("100");
|
||||||
|
expect(formatTokenCount(999)).toBe("999");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats thousands with K suffix", () => {
|
||||||
|
expect(formatTokenCount(1000)).toBe("1.0K");
|
||||||
|
expect(formatTokenCount(1500)).toBe("1.5K");
|
||||||
|
expect(formatTokenCount(10000)).toBe("10.0K");
|
||||||
|
expect(formatTokenCount(999999)).toBe("1000.0K");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats millions with M suffix", () => {
|
||||||
|
expect(formatTokenCount(1000000)).toBe("1.0M");
|
||||||
|
expect(formatTokenCount(1500000)).toBe("1.5M");
|
||||||
|
expect(formatTokenCount(10000000)).toBe("10.0M");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeForJson", () => {
|
||||||
|
it("returns normal text unchanged", () => {
|
||||||
|
expect(sanitizeForJson("Hello world")).toBe("Hello world");
|
||||||
|
expect(sanitizeForJson("Test 123")).toBe("Test 123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves common whitespace", () => {
|
||||||
|
expect(sanitizeForJson("line1\nline2")).toBe("line1\nline2");
|
||||||
|
expect(sanitizeForJson("col1\tcol2")).toBe("col1\tcol2");
|
||||||
|
expect(sanitizeForJson("line\r\nend")).toBe("line\r\nend");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes null bytes", () => {
|
||||||
|
expect(sanitizeForJson("hello\x00world")).toBe("helloworld");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes other control characters", () => {
|
||||||
|
// Bell character
|
||||||
|
expect(sanitizeForJson("alert\x07here")).toBe("alerthere");
|
||||||
|
// Backspace
|
||||||
|
expect(sanitizeForJson("back\x08space")).toBe("backspace");
|
||||||
|
// Form feed is removed
|
||||||
|
expect(sanitizeForJson("page\x0Cbreak")).toBe("pagebreak");
|
||||||
|
// Escape character
|
||||||
|
expect(sanitizeForJson("esc\x1Bhere")).toBe("eschere");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves printable characters including backslashes", () => {
|
||||||
|
const codeContent = '```rust\nfn main() {\n println!("Hello");\n}\n```';
|
||||||
|
expect(sanitizeForJson(codeContent)).toBe(codeContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mixed content with various characters", () => {
|
||||||
|
const mixed = "User: Hello\n\nAssistant: Here's some code:\n```\nconst x = 42;\n```";
|
||||||
|
expect(sanitizeForJson(mixed)).toBe(mixed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves backslash sequences", () => {
|
||||||
|
// Backslashes followed by letters should be preserved as-is
|
||||||
|
expect(sanitizeForJson("path\\to\\file")).toBe("path\\to\\file");
|
||||||
|
expect(sanitizeForJson("color\\x1b")).toBe("color\\x1b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes lone surrogates", () => {
|
||||||
|
// Lone surrogates (U+D800-U+DFFF) can cause JSON parse errors
|
||||||
|
// High surrogate without low
|
||||||
|
expect(sanitizeForJson("test\uD800end")).toBe("testend");
|
||||||
|
// Low surrogate without high
|
||||||
|
expect(sanitizeForJson("test\uDC00end")).toBe("testend");
|
||||||
|
// But valid surrogate pairs should remain (they form valid characters)
|
||||||
|
// Actually, JavaScript represents emoji as surrogate pairs, so this is tricky
|
||||||
|
// The regex will remove the surrogates, which may break emoji. That's acceptable
|
||||||
|
// for a conversation summary where data integrity is more important.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { ConversationSummary } from "$lib/stores/conversations";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitises a string for safe JSON serialization through Tauri IPC.
|
||||||
|
* Removes control characters and lone surrogates that could cause issues
|
||||||
|
* during JSON serialization/deserialization.
|
||||||
|
*/
|
||||||
|
export function sanitizeForJson(text: string): string {
|
||||||
|
// Remove control characters except for common whitespace (tab, newline, carriage return)
|
||||||
|
// These can cause JSON parsing issues and are rarely meaningful in conversation summaries.
|
||||||
|
// eslint-disable-next-line no-control-regex -- regex uses control character codes
|
||||||
|
let sanitized = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
|
||||||
|
|
||||||
|
// Remove extended ASCII control chars (C1 control codes)
|
||||||
|
sanitized = sanitized.replace(/[\x80-\x9F]/g, "");
|
||||||
|
|
||||||
|
// Remove lone surrogates (U+D800 to U+DFFF) which cause "unexpected end of hex escape"
|
||||||
|
// errors in serde_json when they appear without proper pairing.
|
||||||
|
// These are invalid in JSON and can cause parse failures.
|
||||||
|
sanitized = sanitized.replace(/[\uD800-\uDFFF]/g, "");
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a prompt to ask Claude to summarise a conversation.
|
||||||
|
* This can be sent as a user message to get a summary.
|
||||||
|
*/
|
||||||
|
export function generateSummaryPrompt(conversationContent: string): string {
|
||||||
|
return `Please provide a concise summary of our conversation so far. Focus on:
|
||||||
|
1. Key topics discussed
|
||||||
|
2. Important decisions or conclusions made
|
||||||
|
3. Any ongoing tasks or context that would be helpful to remember
|
||||||
|
4. Code changes or files that were modified
|
||||||
|
|
||||||
|
Keep the summary brief but comprehensive enough to continue our work in a new session.
|
||||||
|
|
||||||
|
Here is our conversation:
|
||||||
|
|
||||||
|
${conversationContent}
|
||||||
|
|
||||||
|
Please provide the summary now:`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a context injection message to prepend to a new conversation.
|
||||||
|
* This provides Claude with context from a previous session.
|
||||||
|
*/
|
||||||
|
export function generateContextInjection(summary: ConversationSummary): string {
|
||||||
|
return `[Previous Session Context]
|
||||||
|
The following is a summary from our previous conversation (${summary.messageCount} messages, approximately ${summary.tokenEstimate.toLocaleString()} tokens):
|
||||||
|
|
||||||
|
${summary.content}
|
||||||
|
|
||||||
|
[End of Previous Context]
|
||||||
|
|
||||||
|
Please continue from where we left off, or let me know if you need any clarification about the previous context.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimates the token count for a given string.
|
||||||
|
* Uses a rough approximation of ~4 characters per token.
|
||||||
|
*/
|
||||||
|
export function estimateTokens(text: string): number {
|
||||||
|
return Math.ceil(text.length / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a ConversationSummary object from summary content.
|
||||||
|
*/
|
||||||
|
export function createSummary(
|
||||||
|
content: string,
|
||||||
|
messageCount: number,
|
||||||
|
originalTokenEstimate: number
|
||||||
|
): ConversationSummary {
|
||||||
|
return {
|
||||||
|
generatedAt: new Date(),
|
||||||
|
content,
|
||||||
|
messageCount,
|
||||||
|
tokenEstimate: originalTokenEstimate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if a conversation should be compacted based on token usage.
|
||||||
|
* Returns true if the conversation is using more than the threshold percentage
|
||||||
|
* of the context window.
|
||||||
|
*/
|
||||||
|
export function shouldSuggestCompaction(
|
||||||
|
contextTokensUsed: number,
|
||||||
|
contextWindowLimit: number,
|
||||||
|
thresholdPercent: number = 60
|
||||||
|
): boolean {
|
||||||
|
if (contextWindowLimit === 0) return false;
|
||||||
|
const utilisationPercent = (contextTokensUsed / contextWindowLimit) * 100;
|
||||||
|
return utilisationPercent >= thresholdPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a token count for display.
|
||||||
|
*/
|
||||||
|
export function formatTokenCount(tokens: number): string {
|
||||||
|
if (tokens >= 1000000) {
|
||||||
|
return `${(tokens / 1000000).toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
if (tokens >= 1000) {
|
||||||
|
return `${(tokens / 1000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
return tokens.toString();
|
||||||
|
}
|
||||||
+29
-1
@@ -2,7 +2,13 @@
|
|||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
|
import {
|
||||||
|
initializeTauriListeners,
|
||||||
|
cleanupTauriListeners,
|
||||||
|
initializeDiscordRpc,
|
||||||
|
stopDiscordRpc,
|
||||||
|
updateDiscordRpc,
|
||||||
|
} from "$lib/tauri";
|
||||||
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
|
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
|
||||||
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
|
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
|
||||||
import { conversationsStore } from "$lib/stores/conversations";
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
@@ -57,6 +63,24 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get reactive references to conversation stores
|
||||||
|
const activeConversationId = conversationsStore.activeConversationId;
|
||||||
|
const conversations = conversationsStore.conversations;
|
||||||
|
|
||||||
|
// Update Discord RPC when active conversation or model changes
|
||||||
|
$effect(() => {
|
||||||
|
// Access stores directly (without get()) to create reactive dependencies
|
||||||
|
const activeId = $activeConversationId;
|
||||||
|
const convs = $conversations;
|
||||||
|
const activeConv = activeId ? convs.get(activeId) : null;
|
||||||
|
const config = configStore.getConfig();
|
||||||
|
const model = config.model || "claude";
|
||||||
|
|
||||||
|
if (activeConv && config.discord_rpc_enabled) {
|
||||||
|
updateDiscordRpc(activeConv.name, model, activeConv.startedAt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Window size constants
|
// Window size constants
|
||||||
const COMPACT_WIDTH = 280;
|
const COMPACT_WIDTH = 280;
|
||||||
const COMPACT_HEIGHT = 400;
|
const COMPACT_HEIGHT = 400;
|
||||||
@@ -356,6 +380,9 @@
|
|||||||
const window = getCurrentWindow();
|
const window = getCurrentWindow();
|
||||||
await window.setSize(new LogicalSize(COMPACT_WIDTH, COMPACT_HEIGHT));
|
await window.setSize(new LogicalSize(COMPACT_WIDTH, COMPACT_HEIGHT));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize Discord RPC
|
||||||
|
await initializeDiscordRpc();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -363,6 +390,7 @@
|
|||||||
if (initialized) {
|
if (initialized) {
|
||||||
cleanupTauriListeners();
|
cleanupTauriListeners();
|
||||||
cleanupNotificationSync();
|
cleanupNotificationSync();
|
||||||
|
stopDiscordRpc();
|
||||||
window.removeEventListener("keydown", handleGlobalKeydown);
|
window.removeEventListener("keydown", handleGlobalKeydown);
|
||||||
initialized = false;
|
initialized = false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user