generated from nhcarrigan/template
Compare commits
6 Commits
v1.2.0
...
97a93c31c2
| Author | SHA1 | Date | |
|---|---|---|---|
| 97a93c31c2 | |||
| 3e7cb7ef60 | |||
| 136f95cd1a | |||
|
6a12a7a34d
|
|||
| 479652b69e | |||
| a72f2afaff |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hikari-desktop",
|
"name": "hikari-desktop",
|
||||||
"version": "1.2.0",
|
"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.3.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.2.0"
|
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 = [
|
||||||
|
|||||||
@@ -2329,6 +2329,10 @@ mod tests {
|
|||||||
context_utilisation_percent: 0.0,
|
context_utilisation_percent: 0.0,
|
||||||
potential_cache_hits: 0,
|
potential_cache_hits: 0,
|
||||||
potential_cache_savings_tokens: 0,
|
potential_cache_savings_tokens: 0,
|
||||||
|
current_request_input: None,
|
||||||
|
current_request_output_chars: 0,
|
||||||
|
current_request_thinking_chars: 0,
|
||||||
|
current_request_tools: Vec::new(),
|
||||||
achievements: AchievementProgress::new(),
|
achievements: AchievementProgress::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+543
-33
@@ -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,26 +160,65 @@ 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};
|
||||||
|
|
||||||
|
// 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('/')));
|
||||||
|
|
||||||
|
if is_wsl_path {
|
||||||
|
// WSL path - handle as Unix-style path without filesystem validation
|
||||||
|
// since the Windows binary can't validate WSL filesystem paths
|
||||||
|
let resolved = if path.starts_with('/') {
|
||||||
|
// Absolute WSL path - use as-is
|
||||||
|
path
|
||||||
|
} else if let Some(ref cwd) = current_dir {
|
||||||
|
// Relative path - resolve manually using Unix path logic
|
||||||
|
if path == "." {
|
||||||
|
cwd.clone()
|
||||||
|
} else if path == ".." {
|
||||||
|
// Go up one directory
|
||||||
|
cwd.rsplit_once('/').map(|x| x.0).unwrap_or("/").to_string()
|
||||||
|
} else if path.starts_with("../") {
|
||||||
|
// Handle ../ prefix
|
||||||
|
let parent = cwd.rsplit_once('/').map(|x| x.0).unwrap_or("/");
|
||||||
|
let remainder = path.strip_prefix("../").unwrap();
|
||||||
|
if remainder.is_empty() {
|
||||||
|
parent.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", parent, remainder)
|
||||||
|
}
|
||||||
|
} else if path.starts_with("./") {
|
||||||
|
// Handle ./ prefix
|
||||||
|
format!("{}/{}", cwd, path.strip_prefix("./").unwrap())
|
||||||
|
} else {
|
||||||
|
// Regular relative path
|
||||||
|
format!("{}/{}", cwd, path)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err("Cannot resolve relative WSL path without current directory".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Normalize the path (remove duplicate slashes, etc.)
|
||||||
|
let normalized = resolved.split('/').filter(|s| !s.is_empty()).collect::<Vec<_>>().join("/");
|
||||||
|
Ok(if normalized.is_empty() { "/".to_string() } else { format!("/{}", normalized) })
|
||||||
|
} else {
|
||||||
|
// Native path (Windows on Windows, Unix on Unix) - validate normally
|
||||||
let path = Path::new(&path);
|
let path = Path::new(&path);
|
||||||
|
|
||||||
// Expand ~ to home directory
|
|
||||||
let expanded_path = if path.starts_with("~") {
|
let expanded_path = if path.starts_with("~") {
|
||||||
if let Some(home) = std::env::var_os("HOME") {
|
if let Some(home) = dirs::home_dir() {
|
||||||
let home_path = Path::new(&home);
|
|
||||||
if path == Path::new("~") {
|
if path == Path::new("~") {
|
||||||
home_path.to_path_buf()
|
home
|
||||||
} else {
|
} else {
|
||||||
home_path.join(path.strip_prefix("~").unwrap())
|
home.join(path.strip_prefix("~").unwrap())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return Err("Could not determine home directory".to_string());
|
return Err("Could not determine home directory".to_string());
|
||||||
}
|
}
|
||||||
} else if path.is_relative() {
|
} else if path.is_relative() {
|
||||||
// Handle relative paths (., .., or any relative path) by resolving against current_dir
|
|
||||||
if let Some(ref cwd) = current_dir {
|
if let Some(ref cwd) = current_dir {
|
||||||
Path::new(cwd).join(path)
|
let cwd_path = PathBuf::from(cwd);
|
||||||
|
cwd_path.join(path)
|
||||||
} else {
|
} else {
|
||||||
path.to_path_buf()
|
path.to_path_buf()
|
||||||
}
|
}
|
||||||
@@ -168,6 +247,7 @@ pub async fn validate_directory(
|
|||||||
.map(|p| p.to_string_lossy().to_string())
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
.map_err(|e| format!("Failed to resolve path: {}", e))
|
.map_err(|e| format!("Failed to resolve path: {}", e))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn load_saved_achievements(
|
pub async fn load_saved_achievements(
|
||||||
@@ -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,44 @@ 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 ====================
|
// ==================== Cost Tracking Commands ====================
|
||||||
|
|
||||||
const COST_HISTORY_STORE_KEY: &str = "cost_history";
|
const COST_HISTORY_STORE_KEY: &str = "cost_history";
|
||||||
@@ -647,6 +1118,42 @@ async fn save_cost_history(app: &AppHandle, history: &crate::cost_tracking::Cost
|
|||||||
Ok(())
|
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::*;
|
||||||
@@ -856,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 ====================
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ pub struct HikariConfig {
|
|||||||
|
|
||||||
#[serde(default = "default_budget_warning_threshold")]
|
#[serde(default = "default_budget_warning_threshold")]
|
||||||
pub budget_warning_threshold: f32,
|
pub budget_warning_threshold: f32,
|
||||||
|
|
||||||
|
#[serde(default = "default_discord_rpc_enabled")]
|
||||||
|
pub discord_rpc_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HikariConfig {
|
impl Default for HikariConfig {
|
||||||
@@ -144,6 +147,7 @@ impl Default for HikariConfig {
|
|||||||
session_cost_budget: None,
|
session_cost_budget: None,
|
||||||
budget_action: BudgetAction::Warn,
|
budget_action: BudgetAction::Warn,
|
||||||
budget_warning_threshold: 0.8,
|
budget_warning_threshold: 0.8,
|
||||||
|
discord_rpc_enabled: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,6 +180,10 @@ fn default_budget_warning_threshold() -> f32 {
|
|||||||
0.8
|
0.8
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_discord_rpc_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum BudgetAction {
|
pub enum BudgetAction {
|
||||||
@@ -247,6 +255,7 @@ mod tests {
|
|||||||
assert!(config.session_cost_budget.is_none());
|
assert!(config.session_cost_budget.is_none());
|
||||||
assert_eq!(config.budget_action, BudgetAction::Warn);
|
assert_eq!(config.budget_action, BudgetAction::Warn);
|
||||||
assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON);
|
assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON);
|
||||||
|
assert!(config.discord_rpc_enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -279,6 +288,7 @@ mod tests {
|
|||||||
session_cost_budget: Some(1.50),
|
session_cost_budget: Some(1.50),
|
||||||
budget_action: BudgetAction::Block,
|
budget_action: BudgetAction::Block,
|
||||||
budget_warning_threshold: 0.75,
|
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,218 @@
|
|||||||
|
use discord_rich_presence::activity::{Activity, Assets, Timestamps};
|
||||||
|
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
pub struct DiscordRpcManager {
|
||||||
|
client: Arc<RwLock<Option<DiscordIpcClient>>>,
|
||||||
|
session_name: Arc<RwLock<String>>,
|
||||||
|
model: Arc<RwLock<String>>,
|
||||||
|
started_at: Arc<RwLock<i64>>,
|
||||||
|
log_path: Arc<RwLock<Option<PathBuf>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiscordRpcManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
client: Arc::new(RwLock::new(None)),
|
||||||
|
session_name: Arc::new(RwLock::new(String::new())),
|
||||||
|
model: Arc::new(RwLock::new(String::new())),
|
||||||
|
started_at: Arc::new(RwLock::new(0)),
|
||||||
|
log_path: Arc::new(RwLock::new(None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_app_handle(&self, app_handle: &AppHandle) {
|
||||||
|
if let Ok(app_data_dir) = app_handle.path().app_data_dir() {
|
||||||
|
// Ensure the directory exists
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&app_data_dir) {
|
||||||
|
eprintln!("Failed to create app data directory: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let log_path = app_data_dir.join("hikari_discord_rpc.log");
|
||||||
|
*self.log_path.write() = Some(log_path.clone());
|
||||||
|
self.log(&format!(
|
||||||
|
"Log file initialised at: {}",
|
||||||
|
log_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn log(&self, message: &str) {
|
||||||
|
let log_path_guard = self.log_path.read();
|
||||||
|
let path = match log_path_guard.as_ref() {
|
||||||
|
Some(p) => p.clone(),
|
||||||
|
None => PathBuf::from("hikari_discord_rpc.log"),
|
||||||
|
};
|
||||||
|
drop(log_path_guard);
|
||||||
|
|
||||||
|
if let Ok(mut file) = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&path)
|
||||||
|
{
|
||||||
|
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||||
|
let _ = writeln!(file, "[{}] {}", timestamp, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init(&self, initial_session_name: String, initial_model: String, started_at: i64) -> Result<(), String> {
|
||||||
|
self.log("Attempting to initialize Discord RPC...");
|
||||||
|
self.log("DEBUG: Application ID: 1391117878182281316");
|
||||||
|
self.log(&format!("DEBUG: Initial session: '{}', model: '{}', timestamp: {}",
|
||||||
|
initial_session_name, initial_model, started_at));
|
||||||
|
|
||||||
|
let mut client = DiscordIpcClient::new("1391117878182281316")
|
||||||
|
.map_err(|e| {
|
||||||
|
let error_msg = format!("Failed to create Discord RPC client: {} (is Discord running?)", e);
|
||||||
|
self.log(&format!("ERROR: {}", error_msg));
|
||||||
|
error_msg
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.log("DEBUG: DiscordIpcClient created successfully");
|
||||||
|
|
||||||
|
client
|
||||||
|
.connect()
|
||||||
|
.map_err(|e| {
|
||||||
|
let error_msg = format!("Failed to connect to Discord RPC: {} (ensure Discord is running)", e);
|
||||||
|
self.log(&format!("ERROR: {}", error_msg));
|
||||||
|
error_msg
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.log("DEBUG: Connected to Discord IPC socket");
|
||||||
|
|
||||||
|
// Set initial activity immediately after connecting
|
||||||
|
self.log("DEBUG: Building initial activity...");
|
||||||
|
let state_text = format!("Model: {}", initial_model);
|
||||||
|
let assets = Assets::new()
|
||||||
|
.large_image("hikari")
|
||||||
|
.large_text("Hikari - Claude Code Assistant");
|
||||||
|
|
||||||
|
self.log("DEBUG: Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
|
||||||
|
|
||||||
|
let timestamps = Timestamps::new()
|
||||||
|
.start(started_at);
|
||||||
|
|
||||||
|
self.log(&format!("DEBUG: Timestamps created - start: {}", started_at));
|
||||||
|
|
||||||
|
let activity = Activity::new()
|
||||||
|
.details(initial_session_name.as_str())
|
||||||
|
.state(state_text.as_str())
|
||||||
|
.assets(assets)
|
||||||
|
.timestamps(timestamps);
|
||||||
|
|
||||||
|
self.log(&format!("DEBUG: Activity created - details: '{}', state: '{}'",
|
||||||
|
initial_session_name, state_text));
|
||||||
|
|
||||||
|
self.log("DEBUG: Attempting to set initial activity...");
|
||||||
|
client
|
||||||
|
.set_activity(activity)
|
||||||
|
.map_err(|e| {
|
||||||
|
let error_msg = format!("Failed to set initial Discord RPC activity: {}", e);
|
||||||
|
self.log(&format!("ERROR: {}", error_msg));
|
||||||
|
error_msg
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.log("DEBUG: Initial activity set successfully!");
|
||||||
|
|
||||||
|
// Store the client and initial state
|
||||||
|
*self.client.write() = Some(client);
|
||||||
|
*self.session_name.write() = initial_session_name.clone();
|
||||||
|
*self.model.write() = initial_model.clone();
|
||||||
|
*self.started_at.write() = started_at;
|
||||||
|
|
||||||
|
self.log(&format!("Discord RPC connected successfully with initial activity: session='{}', model='{}'",
|
||||||
|
initial_session_name, initial_model));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(
|
||||||
|
&self,
|
||||||
|
session_name: String,
|
||||||
|
model: String,
|
||||||
|
started_at: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
self.log(&format!("DEBUG: update() called with session='{}', model='{}', timestamp={}",
|
||||||
|
session_name, model, started_at));
|
||||||
|
|
||||||
|
*self.session_name.write() = session_name.clone();
|
||||||
|
*self.model.write() = model.clone();
|
||||||
|
*self.started_at.write() = started_at;
|
||||||
|
|
||||||
|
self.log("DEBUG: State variables updated");
|
||||||
|
|
||||||
|
let mut client_guard = self.client.write();
|
||||||
|
let client = client_guard
|
||||||
|
.as_mut()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
let error_msg = "Discord RPC client not initialized".to_string();
|
||||||
|
self.log(&format!("ERROR: {}", error_msg));
|
||||||
|
error_msg
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.log("DEBUG: Client lock acquired");
|
||||||
|
|
||||||
|
let state_text = format!("Model: {}", model);
|
||||||
|
let assets = Assets::new()
|
||||||
|
.large_image("hikari")
|
||||||
|
.large_text("Hikari - Claude Code Assistant");
|
||||||
|
|
||||||
|
self.log("DEBUG: Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
|
||||||
|
|
||||||
|
let timestamps = Timestamps::new()
|
||||||
|
.start(started_at);
|
||||||
|
|
||||||
|
self.log(&format!("DEBUG: Timestamps created - start: {}", started_at));
|
||||||
|
|
||||||
|
let activity = Activity::new()
|
||||||
|
.details(session_name.as_str())
|
||||||
|
.state(state_text.as_str())
|
||||||
|
.assets(assets)
|
||||||
|
.timestamps(timestamps);
|
||||||
|
|
||||||
|
self.log(&format!("DEBUG: Activity created - details: '{}', state: '{}'",
|
||||||
|
session_name, state_text));
|
||||||
|
|
||||||
|
self.log("DEBUG: Attempting to set activity...");
|
||||||
|
client
|
||||||
|
.set_activity(activity)
|
||||||
|
.map_err(|e| {
|
||||||
|
let error_msg = format!("Failed to update Discord RPC: {}", e);
|
||||||
|
self.log(&format!("ERROR: {}", error_msg));
|
||||||
|
error_msg
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.log(&format!("Updated Discord RPC: session='{}', model='{}'", session_name, model));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&self) -> Result<(), String> {
|
||||||
|
self.log("DEBUG: stop() called");
|
||||||
|
|
||||||
|
let mut client_guard = self.client.write();
|
||||||
|
if let Some(mut client) = client_guard.take() {
|
||||||
|
self.log("DEBUG: Client found, attempting to close...");
|
||||||
|
client
|
||||||
|
.close()
|
||||||
|
.map_err(|e| {
|
||||||
|
let error_msg = format!("Failed to close Discord RPC: {}", e);
|
||||||
|
self.log(&format!("ERROR: {}", error_msg));
|
||||||
|
error_msg
|
||||||
|
})?;
|
||||||
|
self.log("Discord RPC stopped successfully");
|
||||||
|
} else {
|
||||||
|
self.log("DEBUG: No client to stop (already stopped or never initialized)");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DiscordRpcManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ mod clipboard;
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
mod cost_tracking;
|
mod cost_tracking;
|
||||||
|
mod discord_rpc;
|
||||||
mod git;
|
mod git;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
mod quick_actions;
|
mod quick_actions;
|
||||||
@@ -23,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};
|
||||||
@@ -39,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())
|
||||||
@@ -52,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 {
|
||||||
@@ -169,6 +177,10 @@ pub fn run() {
|
|||||||
get_today_cost,
|
get_today_cost,
|
||||||
get_week_cost,
|
get_week_cost,
|
||||||
get_month_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");
|
||||||
|
|||||||
+77
-23
@@ -86,6 +86,8 @@ impl ContextWarning {
|
|||||||
/// Get the context window limit (in tokens) for a given model
|
/// Get the context window limit (in tokens) for a given model
|
||||||
fn get_context_window_limit(model: &str) -> u64 {
|
fn get_context_window_limit(model: &str) -> u64 {
|
||||||
match model {
|
match model {
|
||||||
|
// Claude 4.6 family - 200K standard (1M beta available via header)
|
||||||
|
"claude-opus-4-6" => 200_000,
|
||||||
// Claude 4.5 family - 200K standard context
|
// Claude 4.5 family - 200K standard context
|
||||||
"claude-opus-4-5-20251101"
|
"claude-opus-4-5-20251101"
|
||||||
| "claude-sonnet-4-5-20250929"
|
| "claude-sonnet-4-5-20250929"
|
||||||
@@ -154,6 +156,16 @@ pub struct UsageStats {
|
|||||||
// Achievement tracking
|
// Achievement tracking
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub achievements: AchievementProgress,
|
pub achievements: AchievementProgress,
|
||||||
|
|
||||||
|
// Track current in-flight request for cost estimation on interrupt
|
||||||
|
#[serde(skip)]
|
||||||
|
pub current_request_input: Option<String>,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub current_request_output_chars: u64,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub current_request_thinking_chars: u64,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub current_request_tools: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UsageStats {
|
impl UsageStats {
|
||||||
@@ -163,13 +175,26 @@ impl UsageStats {
|
|||||||
stats
|
stats
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_usage(&mut self, input_tokens: u64, output_tokens: u64, model: &str) {
|
pub fn add_usage(
|
||||||
|
&mut self,
|
||||||
|
input_tokens: u64,
|
||||||
|
output_tokens: u64,
|
||||||
|
model: &str,
|
||||||
|
cache_creation_tokens: Option<u64>,
|
||||||
|
cache_read_tokens: Option<u64>,
|
||||||
|
) {
|
||||||
self.total_input_tokens += input_tokens;
|
self.total_input_tokens += input_tokens;
|
||||||
self.total_output_tokens += output_tokens;
|
self.total_output_tokens += output_tokens;
|
||||||
self.session_input_tokens += input_tokens;
|
self.session_input_tokens += input_tokens;
|
||||||
self.session_output_tokens += output_tokens;
|
self.session_output_tokens += output_tokens;
|
||||||
|
|
||||||
let cost = calculate_cost(input_tokens, output_tokens, model);
|
let cost = calculate_cost(
|
||||||
|
input_tokens,
|
||||||
|
output_tokens,
|
||||||
|
model,
|
||||||
|
cache_creation_tokens,
|
||||||
|
cache_read_tokens,
|
||||||
|
);
|
||||||
self.total_cost_usd += cost;
|
self.total_cost_usd += cost;
|
||||||
self.session_cost_usd += cost;
|
self.session_cost_usd += cost;
|
||||||
|
|
||||||
@@ -439,6 +464,10 @@ impl UsageStats {
|
|||||||
potential_cache_hits: self.potential_cache_hits,
|
potential_cache_hits: self.potential_cache_hits,
|
||||||
potential_cache_savings_tokens: self.potential_cache_savings_tokens,
|
potential_cache_savings_tokens: self.potential_cache_savings_tokens,
|
||||||
achievements: AchievementProgress::new(), // Dummy for copy
|
achievements: AchievementProgress::new(), // Dummy for copy
|
||||||
|
current_request_input: None, // Don't copy tracking fields
|
||||||
|
current_request_output_chars: 0,
|
||||||
|
current_request_thinking_chars: 0,
|
||||||
|
current_request_tools: Vec::new(),
|
||||||
};
|
};
|
||||||
check_achievements(&stats_copy, &mut self.achievements)
|
check_achievements(&stats_copy, &mut self.achievements)
|
||||||
}
|
}
|
||||||
@@ -462,9 +491,19 @@ fn is_consecutive_day(prev_date: &str, current_date: &str) -> bool {
|
|||||||
|
|
||||||
// Pricing as of February 2026
|
// Pricing as of February 2026
|
||||||
// https://platform.claude.com/docs/en/about-claude/models/overview
|
// https://platform.claude.com/docs/en/about-claude/models/overview
|
||||||
pub fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
|
// Cache pricing: https://platform.claude.com/docs/en/build-with-claude/prompt-caching
|
||||||
|
pub fn calculate_cost(
|
||||||
|
input_tokens: u64,
|
||||||
|
output_tokens: u64,
|
||||||
|
model: &str,
|
||||||
|
cache_creation_tokens: Option<u64>,
|
||||||
|
cache_read_tokens: Option<u64>,
|
||||||
|
) -> f64 {
|
||||||
let (input_price_per_million, output_price_per_million) = match model {
|
let (input_price_per_million, output_price_per_million) = match model {
|
||||||
// Current generation (Claude 4.5)
|
// Current generation (Claude 4.6)
|
||||||
|
"claude-opus-4-6" => (5.0, 25.0),
|
||||||
|
|
||||||
|
// Previous generation (Claude 4.5)
|
||||||
"claude-opus-4-5-20251101" => (5.0, 25.0),
|
"claude-opus-4-5-20251101" => (5.0, 25.0),
|
||||||
"claude-sonnet-4-5-20250929" => (3.0, 15.0),
|
"claude-sonnet-4-5-20250929" => (3.0, 15.0),
|
||||||
"claude-haiku-4-5-20251001" => (1.0, 5.0),
|
"claude-haiku-4-5-20251001" => (1.0, 5.0),
|
||||||
@@ -487,10 +526,25 @@ pub fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64
|
|||||||
_ => (3.0, 15.0),
|
_ => (3.0, 15.0),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Regular input/output tokens
|
||||||
let input_cost = (input_tokens as f64 / 1_000_000.0) * input_price_per_million;
|
let input_cost = (input_tokens as f64 / 1_000_000.0) * input_price_per_million;
|
||||||
let output_cost = (output_tokens as f64 / 1_000_000.0) * output_price_per_million;
|
let output_cost = (output_tokens as f64 / 1_000_000.0) * output_price_per_million;
|
||||||
|
|
||||||
input_cost + output_cost
|
// Cache write tokens (cache creation) cost 1.25x the base input price
|
||||||
|
let cache_write_cost = if let Some(cache_creation) = cache_creation_tokens {
|
||||||
|
(cache_creation as f64 / 1_000_000.0) * input_price_per_million * 1.25
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache read tokens cost 0.1x (10%) the base input price
|
||||||
|
let cache_read_cost = if let Some(cache_read) = cache_read_tokens {
|
||||||
|
(cache_read as f64 / 1_000_000.0) * input_price_per_million * 0.1
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
input_cost + output_cost + cache_write_cost + cache_read_cost
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -609,7 +663,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cost_calculation_sonnet() {
|
fn test_cost_calculation_sonnet() {
|
||||||
let cost = calculate_cost(1000, 2000, "claude-sonnet-4-20250514");
|
let cost = calculate_cost(1000, 2000, "claude-sonnet-4-20250514", None, None);
|
||||||
// 1000 input * $3/M = $0.003
|
// 1000 input * $3/M = $0.003
|
||||||
// 2000 output * $15/M = $0.030
|
// 2000 output * $15/M = $0.030
|
||||||
// Total = $0.033
|
// Total = $0.033
|
||||||
@@ -618,7 +672,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cost_calculation_opus() {
|
fn test_cost_calculation_opus() {
|
||||||
let cost = calculate_cost(1000, 2000, "claude-opus-4-20250514");
|
let cost = calculate_cost(1000, 2000, "claude-opus-4-20250514", None, None);
|
||||||
// 1000 input * $15/M = $0.015
|
// 1000 input * $15/M = $0.015
|
||||||
// 2000 output * $75/M = $0.150
|
// 2000 output * $75/M = $0.150
|
||||||
// Total = $0.165
|
// Total = $0.165
|
||||||
@@ -627,7 +681,7 @@ 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", None, None);
|
||||||
// Opus 4.5 pricing: $5/MTok input, $25/MTok output
|
// Opus 4.5 pricing: $5/MTok input, $25/MTok output
|
||||||
// 1000 input tokens = $0.005, 2000 output tokens = $0.05
|
// 1000 input tokens = $0.005, 2000 output tokens = $0.05
|
||||||
// Total = $0.055
|
// Total = $0.055
|
||||||
@@ -636,7 +690,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cost_calculation_haiku() {
|
fn test_cost_calculation_haiku() {
|
||||||
let cost = calculate_cost(1000, 2000, "claude-3-5-haiku-20241022");
|
let cost = calculate_cost(1000, 2000, "claude-3-5-haiku-20241022", None, None);
|
||||||
// 1000 input * $1/M = $0.001
|
// 1000 input * $1/M = $0.001
|
||||||
// 2000 output * $5/M = $0.010
|
// 2000 output * $5/M = $0.010
|
||||||
// Total = $0.011
|
// Total = $0.011
|
||||||
@@ -645,14 +699,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cost_calculation_unknown_defaults_to_sonnet() {
|
fn test_cost_calculation_unknown_defaults_to_sonnet() {
|
||||||
let cost = calculate_cost(1000, 2000, "some-unknown-model");
|
let cost = calculate_cost(1000, 2000, "some-unknown-model", None, None);
|
||||||
// Should default to Sonnet pricing
|
// Should default to Sonnet pricing
|
||||||
assert!((cost - 0.033).abs() < 0.0001);
|
assert!((cost - 0.033).abs() < 0.0001);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cost_calculation_legacy_sonnet() {
|
fn test_cost_calculation_legacy_sonnet() {
|
||||||
let cost = calculate_cost(1000, 2000, "claude-3-5-sonnet-20241022");
|
let cost = calculate_cost(1000, 2000, "claude-3-5-sonnet-20241022", None, None);
|
||||||
// Same as Sonnet 4 pricing
|
// Same as Sonnet 4 pricing
|
||||||
assert!((cost - 0.033).abs() < 0.0001);
|
assert!((cost - 0.033).abs() < 0.0001);
|
||||||
}
|
}
|
||||||
@@ -660,7 +714,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_usage_stats_accumulation() {
|
fn test_usage_stats_accumulation() {
|
||||||
let mut stats = UsageStats::new();
|
let mut stats = UsageStats::new();
|
||||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
|
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514", None, None);
|
||||||
|
|
||||||
assert_eq!(stats.total_input_tokens, 1000);
|
assert_eq!(stats.total_input_tokens, 1000);
|
||||||
assert_eq!(stats.total_output_tokens, 2000);
|
assert_eq!(stats.total_output_tokens, 2000);
|
||||||
@@ -672,8 +726,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_usage_stats_multiple_accumulations() {
|
fn test_usage_stats_multiple_accumulations() {
|
||||||
let mut stats = UsageStats::new();
|
let mut stats = UsageStats::new();
|
||||||
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514");
|
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514", None, None);
|
||||||
stats.add_usage(500, 500, "claude-sonnet-4-20250514");
|
stats.add_usage(500, 500, "claude-sonnet-4-20250514", None, None);
|
||||||
|
|
||||||
assert_eq!(stats.total_input_tokens, 1500);
|
assert_eq!(stats.total_input_tokens, 1500);
|
||||||
assert_eq!(stats.total_output_tokens, 1500);
|
assert_eq!(stats.total_output_tokens, 1500);
|
||||||
@@ -684,17 +738,17 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_usage_stats_model_updated() {
|
fn test_usage_stats_model_updated() {
|
||||||
let mut stats = UsageStats::new();
|
let mut stats = UsageStats::new();
|
||||||
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514");
|
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514", None, None);
|
||||||
assert_eq!(stats.model, Some("claude-sonnet-4-20250514".to_string()));
|
assert_eq!(stats.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||||
|
|
||||||
stats.add_usage(500, 500, "claude-opus-4-20250514");
|
stats.add_usage(500, 500, "claude-opus-4-20250514", None, None);
|
||||||
assert_eq!(stats.model, Some("claude-opus-4-20250514".to_string()));
|
assert_eq!(stats.model, Some("claude-opus-4-20250514".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_session_reset() {
|
fn test_session_reset() {
|
||||||
let mut stats = UsageStats::new();
|
let mut stats = UsageStats::new();
|
||||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
|
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514", None, None);
|
||||||
stats.reset_session();
|
stats.reset_session();
|
||||||
|
|
||||||
assert_eq!(stats.total_input_tokens, 1000);
|
assert_eq!(stats.total_input_tokens, 1000);
|
||||||
@@ -921,7 +975,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_usage_stats_serialization() {
|
fn test_usage_stats_serialization() {
|
||||||
let mut stats = UsageStats::new();
|
let mut stats = UsageStats::new();
|
||||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
|
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514", None, None);
|
||||||
stats.increment_messages();
|
stats.increment_messages();
|
||||||
|
|
||||||
// UsageStats should be serializable (for events)
|
// UsageStats should be serializable (for events)
|
||||||
@@ -950,7 +1004,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_stats_update_event_serialization() {
|
fn test_stats_update_event_serialization() {
|
||||||
let mut stats = UsageStats::new();
|
let mut stats = UsageStats::new();
|
||||||
stats.add_usage(100, 200, "claude-sonnet-4-20250514");
|
stats.add_usage(100, 200, "claude-sonnet-4-20250514", None, None);
|
||||||
|
|
||||||
let event = StatsUpdateEvent { stats };
|
let event = StatsUpdateEvent { stats };
|
||||||
let json = serde_json::to_string(&event).expect("Failed to serialize");
|
let json = serde_json::to_string(&event).expect("Failed to serialize");
|
||||||
@@ -1004,7 +1058,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_context_tracking_update() {
|
fn test_context_tracking_update() {
|
||||||
let mut stats = UsageStats::new();
|
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", None, None);
|
||||||
|
|
||||||
assert_eq!(stats.context_tokens_used, 50_000);
|
assert_eq!(stats.context_tokens_used, 50_000);
|
||||||
assert_eq!(stats.context_window_limit, 200_000);
|
assert_eq!(stats.context_window_limit, 200_000);
|
||||||
@@ -1014,8 +1068,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_context_tracking_accumulates() {
|
fn test_context_tracking_accumulates() {
|
||||||
let mut stats = UsageStats::new();
|
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", None, None);
|
||||||
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514");
|
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514", None, None);
|
||||||
|
|
||||||
assert_eq!(stats.context_tokens_used, 100_000);
|
assert_eq!(stats.context_tokens_used, 100_000);
|
||||||
assert!((stats.context_utilisation_percent - 50.0).abs() < 0.1);
|
assert!((stats.context_utilisation_percent - 50.0).abs() < 0.1);
|
||||||
@@ -1079,7 +1133,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_context_reset_on_session_reset() {
|
fn test_context_reset_on_session_reset() {
|
||||||
let mut stats = UsageStats::new();
|
let mut stats = UsageStats::new();
|
||||||
stats.add_usage(100_000, 20_000, "claude-sonnet-4-20250514");
|
stats.add_usage(100_000, 20_000, "claude-sonnet-4-20250514", None, None);
|
||||||
|
|
||||||
assert!(stats.context_tokens_used > 0);
|
assert!(stats.context_tokens_used > 0);
|
||||||
assert!(stats.context_utilisation_percent > 0.0);
|
assert!(stats.context_utilisation_percent > 0.0);
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ use serde::{Deserialize, Serialize};
|
|||||||
pub struct UsageInfo {
|
pub struct UsageInfo {
|
||||||
pub input_tokens: u64,
|
pub input_tokens: u64,
|
||||||
pub output_tokens: u64,
|
pub output_tokens: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_creation_input_tokens: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_read_input_tokens: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
@@ -193,6 +197,8 @@ pub struct OutputEvent {
|
|||||||
pub conversation_id: Option<String>,
|
pub conversation_id: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub cost: Option<MessageCost>,
|
pub cost: Option<MessageCost>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_tool_use_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -244,6 +250,33 @@ pub struct UserQuestionEvent {
|
|||||||
pub conversation_id: Option<String>,
|
pub conversation_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AgentStartEvent {
|
||||||
|
pub tool_use_id: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub agent_id: Option<String>,
|
||||||
|
pub description: String,
|
||||||
|
pub subagent_type: String,
|
||||||
|
pub started_at: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_tool_use_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AgentEndEvent {
|
||||||
|
pub tool_use_id: String,
|
||||||
|
pub ended_at: u64,
|
||||||
|
pub is_error: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub duration_ms: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub num_turns: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -365,6 +398,7 @@ mod tests {
|
|||||||
tool_name: None,
|
tool_name: None,
|
||||||
conversation_id: None,
|
conversation_id: None,
|
||||||
cost: None,
|
cost: None,
|
||||||
|
parent_tool_use_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let serialized = serde_json::to_string(&event).unwrap();
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
@@ -384,6 +418,7 @@ mod tests {
|
|||||||
output_tokens: 50,
|
output_tokens: 50,
|
||||||
cost_usd: 0.005,
|
cost_usd: 0.005,
|
||||||
}),
|
}),
|
||||||
|
parent_tool_use_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let serialized = serde_json::to_string(&event).unwrap();
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
|||||||
+357
-19
@@ -2,6 +2,7 @@ use std::io::{BufRead, BufReader, Write};
|
|||||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
@@ -13,9 +14,9 @@ use crate::commands::record_cost;
|
|||||||
use crate::config::ClaudeStartOptions;
|
use crate::config::ClaudeStartOptions;
|
||||||
use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
|
use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, MessageCost,
|
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
|
||||||
OutputEvent, PermissionPromptEvent, QuestionOption, SessionEvent, StateChangeEvent,
|
ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent,
|
||||||
UserQuestionEvent, WorkingDirectoryEvent,
|
QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent, WorkingDirectoryEvent,
|
||||||
};
|
};
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
|
|
||||||
@@ -193,6 +194,8 @@ impl WslBridge {
|
|||||||
"--input-format",
|
"--input-format",
|
||||||
"stream-json",
|
"stream-json",
|
||||||
"--verbose",
|
"--verbose",
|
||||||
|
"--debug",
|
||||||
|
"hooks",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Add model if specified
|
// Add model if specified
|
||||||
@@ -355,6 +358,15 @@ impl WslBridge {
|
|||||||
pub fn send_message(&mut self, message: &str) -> Result<(), String> {
|
pub fn send_message(&mut self, message: &str) -> Result<(), String> {
|
||||||
let stdin = self.stdin.as_mut().ok_or("Process not running")?;
|
let stdin = self.stdin.as_mut().ok_or("Process not running")?;
|
||||||
|
|
||||||
|
// Track input for cost estimation on interrupt
|
||||||
|
{
|
||||||
|
let mut stats = self.stats.write();
|
||||||
|
stats.current_request_input = Some(message.to_string());
|
||||||
|
stats.current_request_output_chars = 0;
|
||||||
|
stats.current_request_thinking_chars = 0;
|
||||||
|
stats.current_request_tools.clear();
|
||||||
|
}
|
||||||
|
|
||||||
let input = serde_json::json!({
|
let input = serde_json::json!({
|
||||||
"type": "user",
|
"type": "user",
|
||||||
"message": {
|
"message": {
|
||||||
@@ -419,6 +431,9 @@ impl WslBridge {
|
|||||||
// we have to kill the process. This is the only reliable way to stop it.
|
// we have to kill the process. This is the only reliable way to stop it.
|
||||||
// See: https://github.com/anthropics/claude-code/issues/3455
|
// See: https://github.com/anthropics/claude-code/issues/3455
|
||||||
if let Some(mut process) = self.process.take() {
|
if let Some(mut process) = self.process.take() {
|
||||||
|
// Estimate cost for interrupted request before killing
|
||||||
|
self.estimate_interrupted_request_cost(app);
|
||||||
|
|
||||||
// Kill the process immediately
|
// Kill the process immediately
|
||||||
let _ = process.kill();
|
let _ = process.kill();
|
||||||
let _ = process.wait();
|
let _ = process.wait();
|
||||||
@@ -426,6 +441,15 @@ impl WslBridge {
|
|||||||
// Clear stdin
|
// Clear stdin
|
||||||
self.stdin = None;
|
self.stdin = None;
|
||||||
|
|
||||||
|
// Clear tracking fields
|
||||||
|
{
|
||||||
|
let mut stats = self.stats.write();
|
||||||
|
stats.current_request_input = None;
|
||||||
|
stats.current_request_output_chars = 0;
|
||||||
|
stats.current_request_thinking_chars = 0;
|
||||||
|
stats.current_request_tools.clear();
|
||||||
|
}
|
||||||
|
|
||||||
// Keep session_id and working directory for user reference
|
// Keep session_id and working directory for user reference
|
||||||
// The user will see what session was interrupted
|
// The user will see what session was interrupted
|
||||||
|
|
||||||
@@ -442,6 +466,99 @@ impl WslBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn estimate_interrupted_request_cost(&mut self, app: &AppHandle) {
|
||||||
|
// Read tracking data from stats
|
||||||
|
let (input_chars, output_chars, thinking_chars, tools, model) = {
|
||||||
|
let stats = self.stats.read();
|
||||||
|
|
||||||
|
// Only estimate if we have tracked content
|
||||||
|
if stats.current_request_input.is_none()
|
||||||
|
&& stats.current_request_output_chars == 0
|
||||||
|
&& stats.current_request_thinking_chars == 0
|
||||||
|
&& stats.current_request_tools.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let input_chars = stats.current_request_input.as_ref().map(|s| s.len() as u64).unwrap_or(0);
|
||||||
|
let model = stats.model.clone().unwrap_or_else(|| "claude-sonnet-4-5-20250929".to_string());
|
||||||
|
|
||||||
|
(input_chars, stats.current_request_output_chars, stats.current_request_thinking_chars, stats.current_request_tools.clone(), model)
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("[COST ESTIMATION] Estimating cost for interrupted request");
|
||||||
|
|
||||||
|
// Use conservative 3.5 chars/token for estimation (vs standard 4)
|
||||||
|
let estimated_input_tokens = (input_chars as f64 / 3.5).ceil() as u64;
|
||||||
|
let estimated_output_tokens = ((output_chars as f64 / 3.5).ceil() as u64)
|
||||||
|
+ ((thinking_chars as f64 / 3.5).ceil() as u64);
|
||||||
|
|
||||||
|
// Add tool overhead based on session averages
|
||||||
|
let mut tool_overhead_tokens = 0u64;
|
||||||
|
{
|
||||||
|
let stats = self.stats.read();
|
||||||
|
for tool_name in &tools {
|
||||||
|
if let Some(tool_stats) = stats.session_tools_usage.get(tool_name) {
|
||||||
|
if tool_stats.call_count > 0 {
|
||||||
|
// Use session average tokens per call for this tool
|
||||||
|
let avg_tokens = (tool_stats.estimated_input_tokens + tool_stats.estimated_output_tokens)
|
||||||
|
/ tool_stats.call_count;
|
||||||
|
tool_overhead_tokens += avg_tokens;
|
||||||
|
println!("[COST ESTIMATION] Tool {} average: {} tokens", tool_name, avg_tokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_estimated_input = estimated_input_tokens + tool_overhead_tokens;
|
||||||
|
let total_estimated_output = estimated_output_tokens;
|
||||||
|
|
||||||
|
// Add 20% safety margin to overestimate
|
||||||
|
let safety_margin = 1.2;
|
||||||
|
let conservative_input = (total_estimated_input as f64 * safety_margin).ceil() as u64;
|
||||||
|
let conservative_output = (total_estimated_output as f64 * safety_margin).ceil() as u64;
|
||||||
|
|
||||||
|
println!("[COST ESTIMATION] Input: {} chars → {} tokens (+ {} tool overhead) × 1.2 safety = {} tokens",
|
||||||
|
input_chars, estimated_input_tokens, tool_overhead_tokens, conservative_input);
|
||||||
|
println!("[COST ESTIMATION] Output: {} chars → {} tokens × 1.2 safety = {} tokens",
|
||||||
|
output_chars + thinking_chars,
|
||||||
|
estimated_output_tokens, conservative_output);
|
||||||
|
|
||||||
|
// Calculate cost (no cache tokens for interrupted requests)
|
||||||
|
let estimated_cost = calculate_cost(
|
||||||
|
conservative_input,
|
||||||
|
conservative_output,
|
||||||
|
&model,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("[COST ESTIMATION] Estimated cost: ${:.4} (conservative)", estimated_cost);
|
||||||
|
|
||||||
|
// Add to stats with estimated flag
|
||||||
|
{
|
||||||
|
let mut stats_guard = self.stats.write();
|
||||||
|
stats_guard.add_usage(
|
||||||
|
conservative_input,
|
||||||
|
conservative_output,
|
||||||
|
&model,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit stats update
|
||||||
|
let stats_update_event = StatsUpdateEvent {
|
||||||
|
stats: self.stats.read().clone(),
|
||||||
|
};
|
||||||
|
let _ = app.emit("claude:stats", stats_update_event);
|
||||||
|
|
||||||
|
// Record to historical cost tracking (mark as estimated)
|
||||||
|
let app_clone = app.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
record_cost(&app_clone, conservative_input, conservative_output, estimated_cost).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pub fn stop(&mut self, app: &AppHandle) {
|
pub fn stop(&mut self, app: &AppHandle) {
|
||||||
if let Some(mut process) = self.process.take() {
|
if let Some(mut process) = self.process.take() {
|
||||||
let _ = process.kill();
|
let _ = process.kill();
|
||||||
@@ -528,6 +645,25 @@ fn handle_stderr(
|
|||||||
for line in reader.lines() {
|
for line in reader.lines() {
|
||||||
match line {
|
match line {
|
||||||
Ok(line) if !line.is_empty() => {
|
Ok(line) if !line.is_empty() => {
|
||||||
|
// Check if this is a SubagentStart hook message
|
||||||
|
if line.contains("[SubagentStart Hook]") {
|
||||||
|
if let Some(agent_data) = parse_subagent_start_hook(&line) {
|
||||||
|
eprintln!("[DEBUG] Parsed SubagentStart hook: agent_id={}, parent={:?}",
|
||||||
|
agent_data.agent_id, agent_data.parent_tool_use_id);
|
||||||
|
|
||||||
|
// Emit an agent-update event with the agent_id
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:agent-update",
|
||||||
|
serde_json::json!({
|
||||||
|
"conversationId": conversation_id.clone(),
|
||||||
|
"toolUseId": agent_data.parent_tool_use_id,
|
||||||
|
"agentId": agent_data.agent_id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still emit the stderr line as output
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"claude:output",
|
"claude:output",
|
||||||
OutputEvent {
|
OutputEvent {
|
||||||
@@ -536,6 +672,7 @@ fn handle_stderr(
|
|||||||
tool_name: None,
|
tool_name: None,
|
||||||
conversation_id: conversation_id.clone(),
|
conversation_id: conversation_id.clone(),
|
||||||
cost: None,
|
cost: None,
|
||||||
|
parent_tool_use_id: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -545,6 +682,41 @@ fn handle_stderr(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct SubagentStartData {
|
||||||
|
agent_id: String,
|
||||||
|
parent_tool_use_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
|
||||||
|
// Parse: [SubagentStart Hook] agent_id=agent-xxx, parent_tool_use_id=Some("toolu_xxx"), ...
|
||||||
|
|
||||||
|
// Extract agent_id
|
||||||
|
let agent_id = line
|
||||||
|
.split("agent_id=")
|
||||||
|
.nth(1)?
|
||||||
|
.split(',')
|
||||||
|
.next()?
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Extract parent_tool_use_id if present
|
||||||
|
let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") {
|
||||||
|
line.split("parent_tool_use_id=Some(\"")
|
||||||
|
.nth(1)?
|
||||||
|
.split('"')
|
||||||
|
.next()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(SubagentStartData {
|
||||||
|
agent_id,
|
||||||
|
parent_tool_use_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn process_json_line(
|
fn process_json_line(
|
||||||
line: &str,
|
line: &str,
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
@@ -584,7 +756,7 @@ fn process_json_line(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ClaudeMessage::Assistant { message, .. } => {
|
ClaudeMessage::Assistant { message, parent_tool_use_id } => {
|
||||||
let mut state = CharacterState::Typing;
|
let mut state = CharacterState::Typing;
|
||||||
let mut tool_name = None;
|
let mut tool_name = None;
|
||||||
|
|
||||||
@@ -603,9 +775,31 @@ fn process_json_line(
|
|||||||
|
|
||||||
// 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 {
|
// Get model from message, or fall back to last known model from stats
|
||||||
// Calculate cost for historical tracking
|
let model = message.model.clone().or_else(|| {
|
||||||
let cost_usd = calculate_cost(usage.input_tokens, usage.output_tokens, model);
|
let stats_guard = stats.read();
|
||||||
|
stats_guard.model.clone()
|
||||||
|
}).unwrap_or_else(|| {
|
||||||
|
println!("[WARNING] No model info available for cost calculation, using default");
|
||||||
|
"claude-sonnet-4-5-20250929".to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate cost for historical tracking (including cache tokens)
|
||||||
|
let cost_usd = calculate_cost(
|
||||||
|
usage.input_tokens,
|
||||||
|
usage.output_tokens,
|
||||||
|
&model,
|
||||||
|
usage.cache_creation_input_tokens,
|
||||||
|
usage.cache_read_input_tokens,
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("Assistant message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}, cost: ${:.4}",
|
||||||
|
usage.input_tokens,
|
||||||
|
usage.output_tokens,
|
||||||
|
usage.cache_creation_input_tokens,
|
||||||
|
usage.cache_read_input_tokens,
|
||||||
|
cost_usd
|
||||||
|
);
|
||||||
|
|
||||||
// Store cost for later use in output events
|
// Store cost for later use in output events
|
||||||
message_cost = Some(MessageCost {
|
message_cost = Some(MessageCost {
|
||||||
@@ -618,7 +812,13 @@ fn process_json_line(
|
|||||||
{
|
{
|
||||||
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,
|
||||||
|
usage.cache_creation_input_tokens,
|
||||||
|
usage.cache_read_input_tokens,
|
||||||
|
);
|
||||||
stats_guard.get_session_duration();
|
stats_guard.get_session_duration();
|
||||||
|
|
||||||
// Attribute tokens to tools if any tools were used in this message
|
// Attribute tokens to tools if any tools were used in this message
|
||||||
@@ -645,14 +845,10 @@ fn process_json_line(
|
|||||||
// Just increment message count if no usage info
|
// Just increment message count if no usage info
|
||||||
stats.write().increment_messages();
|
stats.write().increment_messages();
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Just increment message count if no usage info
|
|
||||||
stats.write().increment_messages();
|
|
||||||
}
|
|
||||||
|
|
||||||
for block in &message.content {
|
for block in &message.content {
|
||||||
match block {
|
match block {
|
||||||
ContentBlock::ToolUse { name, input, .. } => {
|
ContentBlock::ToolUse { id, name, input } => {
|
||||||
tool_name = Some(name.clone());
|
tool_name = Some(name.clone());
|
||||||
state = get_tool_state(name);
|
state = get_tool_state(name);
|
||||||
|
|
||||||
@@ -669,6 +865,42 @@ fn process_json_line(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit agent-start event for Task tool invocations
|
||||||
|
if name == "Task" {
|
||||||
|
let description = input
|
||||||
|
.get("description")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Subagent")
|
||||||
|
.to_string();
|
||||||
|
let subagent_type = input
|
||||||
|
.get("subagent_type")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as u64;
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"[DEBUG] Emitting agent-start: id={}, desc={}, type={}, parent={:?}",
|
||||||
|
id, description, subagent_type, parent_tool_use_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:agent-start",
|
||||||
|
AgentStartEvent {
|
||||||
|
tool_use_id: id.clone(),
|
||||||
|
agent_id: None, // Will be updated when SubagentStart hook is received
|
||||||
|
description,
|
||||||
|
subagent_type,
|
||||||
|
started_at: now,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
parent_tool_use_id: parent_tool_use_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let desc = format_tool_description(name, input);
|
let desc = format_tool_description(name, input);
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"claude:output",
|
"claude:output",
|
||||||
@@ -678,6 +910,7 @@ fn process_json_line(
|
|||||||
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
|
cost: None, // Tool use doesn't have separate cost
|
||||||
|
parent_tool_use_id: parent_tool_use_id.clone(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -696,6 +929,7 @@ fn process_json_line(
|
|||||||
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
|
cost: message_cost.clone(), // Include cost with assistant text
|
||||||
|
parent_tool_use_id: parent_tool_use_id.clone(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -709,10 +943,34 @@ fn process_json_line(
|
|||||||
tool_name: None,
|
tool_name: None,
|
||||||
conversation_id: conversation_id.clone(),
|
conversation_id: conversation_id.clone(),
|
||||||
cost: None,
|
cost: None,
|
||||||
|
parent_tool_use_id: parent_tool_use_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ContentBlock::ToolResult {
|
||||||
|
tool_use_id,
|
||||||
|
is_error,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
// Emit agent-end for all tool results
|
||||||
|
// The frontend will ignore IDs that don't match known agents
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as u64;
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:agent-end",
|
||||||
|
AgentEndEvent {
|
||||||
|
tool_use_id: tool_use_id.clone(),
|
||||||
|
ended_at: now,
|
||||||
|
is_error: is_error.unwrap_or(false),
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
duration_ms: None,
|
||||||
|
num_turns: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,6 +980,14 @@ fn process_json_line(
|
|||||||
ClaudeMessage::StreamEvent { event } => {
|
ClaudeMessage::StreamEvent { event } => {
|
||||||
if event.event_type == "content_block_start" {
|
if event.event_type == "content_block_start" {
|
||||||
if let Some(block) = &event.content_block {
|
if let Some(block) = &event.content_block {
|
||||||
|
// Track tool calls for cost estimation
|
||||||
|
if block.block_type == "tool_use" {
|
||||||
|
if let Some(name) = &block.name {
|
||||||
|
let mut stats_guard = stats.write();
|
||||||
|
stats_guard.current_request_tools.push(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let state = match block.block_type.as_str() {
|
let state = match block.block_type.as_str() {
|
||||||
"thinking" => CharacterState::Thinking,
|
"thinking" => CharacterState::Thinking,
|
||||||
"text" => CharacterState::Typing,
|
"text" => CharacterState::Typing,
|
||||||
@@ -739,7 +1005,16 @@ fn process_json_line(
|
|||||||
} else if event.event_type == "content_block_delta" {
|
} else if event.event_type == "content_block_delta" {
|
||||||
if let Some(delta) = &event.delta {
|
if let Some(delta) = &event.delta {
|
||||||
if let Some(text) = &delta.text {
|
if let Some(text) = &delta.text {
|
||||||
|
// Track output characters for cost estimation
|
||||||
|
{
|
||||||
|
let mut stats_guard = stats.write();
|
||||||
|
stats_guard.current_request_output_chars += text.len() as u64;
|
||||||
|
}
|
||||||
let _ = app.emit("claude:stream", text.clone());
|
let _ = app.emit("claude:stream", text.clone());
|
||||||
|
} else if let Some(thinking) = &delta.thinking {
|
||||||
|
// Track thinking characters for cost estimation
|
||||||
|
let mut stats_guard = stats.write();
|
||||||
|
stats_guard.current_request_thinking_chars += thinking.len() as u64;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -750,7 +1025,8 @@ fn process_json_line(
|
|||||||
result,
|
result,
|
||||||
permission_denials,
|
permission_denials,
|
||||||
usage,
|
usage,
|
||||||
..
|
duration_ms,
|
||||||
|
num_turns,
|
||||||
} => {
|
} => {
|
||||||
let state = if subtype == "success" {
|
let state = if subtype == "success" {
|
||||||
CharacterState::Success
|
CharacterState::Success
|
||||||
@@ -758,6 +1034,14 @@ fn process_json_line(
|
|||||||
CharacterState::Error
|
CharacterState::Error
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Log turn metrics if available
|
||||||
|
if let Some(duration) = duration_ms {
|
||||||
|
println!("Turn completed in {}ms", duration);
|
||||||
|
}
|
||||||
|
if let Some(turns) = num_turns {
|
||||||
|
println!("Turn count: {}", turns);
|
||||||
|
}
|
||||||
|
|
||||||
// Track token usage from Result messages if available
|
// Track token usage from Result messages if available
|
||||||
// This captures tokens from tool outputs and other operations
|
// This captures tokens from tool outputs and other operations
|
||||||
if let Some(usage_info) = usage {
|
if let Some(usage_info) = usage {
|
||||||
@@ -768,12 +1052,29 @@ 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
|
// Calculate cost for historical tracking (including cache tokens)
|
||||||
let cost_usd = calculate_cost(usage_info.input_tokens, usage_info.output_tokens, &model);
|
let cost_usd = calculate_cost(
|
||||||
|
usage_info.input_tokens,
|
||||||
|
usage_info.output_tokens,
|
||||||
|
&model,
|
||||||
|
usage_info.cache_creation_input_tokens,
|
||||||
|
usage_info.cache_read_input_tokens,
|
||||||
|
);
|
||||||
|
|
||||||
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(
|
||||||
println!("Result message tokens - input: {}, output: {}", usage_info.input_tokens, usage_info.output_tokens);
|
usage_info.input_tokens,
|
||||||
|
usage_info.output_tokens,
|
||||||
|
&model,
|
||||||
|
usage_info.cache_creation_input_tokens,
|
||||||
|
usage_info.cache_read_input_tokens,
|
||||||
|
);
|
||||||
|
println!("Result message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}",
|
||||||
|
usage_info.input_tokens,
|
||||||
|
usage_info.output_tokens,
|
||||||
|
usage_info.cache_creation_input_tokens,
|
||||||
|
usage_info.cache_read_input_tokens
|
||||||
|
);
|
||||||
|
|
||||||
// Record to historical cost tracking
|
// Record to historical cost tracking
|
||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
@@ -784,6 +1085,15 @@ fn process_json_line(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear tracking fields since request completed successfully
|
||||||
|
{
|
||||||
|
let mut stats_guard = stats.write();
|
||||||
|
stats_guard.current_request_input = None;
|
||||||
|
stats_guard.current_request_output_chars = 0;
|
||||||
|
stats_guard.current_request_thinking_chars = 0;
|
||||||
|
stats_guard.current_request_tools.clear();
|
||||||
|
}
|
||||||
|
|
||||||
// Always emit updated stats on result message (less frequent)
|
// Always emit updated stats on result message (less frequent)
|
||||||
// This includes the latest session duration
|
// This includes the latest session duration
|
||||||
let newly_unlocked = {
|
let newly_unlocked = {
|
||||||
@@ -854,6 +1164,7 @@ fn process_json_line(
|
|||||||
tool_name: None,
|
tool_name: None,
|
||||||
conversation_id: conversation_id.clone(),
|
conversation_id: conversation_id.clone(),
|
||||||
cost: None,
|
cost: None,
|
||||||
|
parent_tool_use_id: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -959,6 +1270,33 @@ fn process_json_line(
|
|||||||
// Increment message count for user messages
|
// Increment message count for user messages
|
||||||
stats.write().increment_messages();
|
stats.write().increment_messages();
|
||||||
|
|
||||||
|
// Process content blocks for tool results (e.g., background Task agent completions)
|
||||||
|
for block in &message.content {
|
||||||
|
if let ContentBlock::ToolResult {
|
||||||
|
tool_use_id,
|
||||||
|
is_error,
|
||||||
|
..
|
||||||
|
} = block
|
||||||
|
{
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as u64;
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:agent-end",
|
||||||
|
AgentEndEvent {
|
||||||
|
tool_use_id: tool_use_id.clone(),
|
||||||
|
ended_at: now,
|
||||||
|
is_error: is_error.unwrap_or(false),
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
duration_ms: None,
|
||||||
|
num_turns: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Extract text content from the message
|
// Extract text content from the message
|
||||||
let message_text = message
|
let message_text = message
|
||||||
.content
|
.content
|
||||||
|
|||||||
@@ -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.2.0",
|
"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) => {
|
||||||
|
const actual = await importOriginal<typeof import("svelte/store")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
get: vi.fn(),
|
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;
|
||||||
@@ -35,6 +37,12 @@ async function changeDirectory(path: string): Promise<void> {
|
|||||||
// Capture conversation history before disconnecting
|
// Capture conversation history before disconnecting
|
||||||
const conversationHistory = claudeStore.getConversationHistory();
|
const conversationHistory = claudeStore.getConversationHistory();
|
||||||
|
|
||||||
|
// Get currently granted tools and config auto-granted tools
|
||||||
|
const activeConversation = get(conversationsStore.activeConversation);
|
||||||
|
const grantedTools = activeConversation ? Array.from(activeConversation.grantedTools) : [];
|
||||||
|
const config = configStore.getConfig();
|
||||||
|
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
|
||||||
|
|
||||||
await invoke("stop_claude", { conversationId });
|
await invoke("stop_claude", { conversationId });
|
||||||
|
|
||||||
// Wait for clean shutdown
|
// Wait for clean shutdown
|
||||||
@@ -48,9 +56,23 @@ async function changeDirectory(path: string): Promise<void> {
|
|||||||
conversationId,
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
working_dir: validatedPath,
|
working_dir: validatedPath,
|
||||||
|
model: config.model || null,
|
||||||
|
api_key: config.api_key || null,
|
||||||
|
custom_instructions: config.custom_instructions || null,
|
||||||
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
|
allowed_tools: allAllowedTools,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update Discord RPC when reconnecting after directory change
|
||||||
|
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));
|
||||||
|
|
||||||
@@ -89,6 +111,12 @@ async function startNewConversation(): Promise<void> {
|
|||||||
conversationId,
|
conversationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get granted tools before interrupting
|
||||||
|
const activeConversation = get(conversationsStore.activeConversation);
|
||||||
|
const grantedTools = activeConversation ? Array.from(activeConversation.grantedTools) : [];
|
||||||
|
const config = configStore.getConfig();
|
||||||
|
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
|
||||||
|
|
||||||
claudeStore.addLine("system", "Starting new conversation...");
|
claudeStore.addLine("system", "Starting new conversation...");
|
||||||
characterState.setState("thinking");
|
characterState.setState("thinking");
|
||||||
|
|
||||||
@@ -102,9 +130,23 @@ async function startNewConversation(): Promise<void> {
|
|||||||
conversationId,
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
working_dir: workingDir,
|
working_dir: workingDir,
|
||||||
|
model: config.model || null,
|
||||||
|
api_key: config.api_key || null,
|
||||||
|
custom_instructions: config.custom_instructions || null,
|
||||||
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
|
allowed_tools: allAllowedTools,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update Discord RPC when starting new conversation
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { SvelteMap } from "svelte/reactivity";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
|
import { agentStore, getAgentsForConversation } from "$lib/stores/agents";
|
||||||
|
import type { AgentInfo } from "$lib/types/agents";
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isOpen, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let now = $state(Date.now());
|
||||||
|
let timerInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
// We need a reactive subscription to agents for the active conversation
|
||||||
|
let agents: AgentInfo[] = $state([]);
|
||||||
|
let agentsUnsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
|
// Track active conversation reactively
|
||||||
|
let currentConversationId = $state<string | null>("");
|
||||||
|
const conversationIdUnsubscribe = claudeStore.activeConversationId.subscribe((id) => {
|
||||||
|
currentConversationId = id;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Re-subscribe when conversation changes
|
||||||
|
if (agentsUnsubscribe) {
|
||||||
|
agentsUnsubscribe();
|
||||||
|
}
|
||||||
|
if (currentConversationId) {
|
||||||
|
const store = getAgentsForConversation(currentConversationId);
|
||||||
|
agentsUnsubscribe = store.subscribe((value) => {
|
||||||
|
agents = value;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
agents = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const runningAgents = $derived(agents.filter((a) => a.status === "running"));
|
||||||
|
const completedAgents = $derived(agents.filter((a) => a.status === "completed"));
|
||||||
|
const erroredAgents = $derived(agents.filter((a) => a.status === "errored"));
|
||||||
|
|
||||||
|
// Organize agents into a tree structure based on parent_tool_use_id
|
||||||
|
const agentTree = $derived.by(() => {
|
||||||
|
const topLevel = agents.filter((a) => !a.parentToolUseId);
|
||||||
|
const childrenMap = new SvelteMap<string, AgentInfo[]>();
|
||||||
|
|
||||||
|
// Group children by their parent
|
||||||
|
agents.forEach((agent) => {
|
||||||
|
if (agent.parentToolUseId) {
|
||||||
|
const siblings = childrenMap.get(agent.parentToolUseId) || [];
|
||||||
|
siblings.push(agent);
|
||||||
|
childrenMap.set(agent.parentToolUseId, siblings);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { topLevel, childrenMap };
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
timerInterval = setInterval(() => {
|
||||||
|
now = Date.now();
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (timerInterval) clearInterval(timerInterval);
|
||||||
|
if (agentsUnsubscribe) agentsUnsubscribe();
|
||||||
|
conversationIdUnsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDuration(startedAt: number, endedAt?: number): string {
|
||||||
|
const end = endedAt || now;
|
||||||
|
const durationMs = end - startedAt;
|
||||||
|
const seconds = Math.floor(durationMs / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
||||||
|
}
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${minutes}m ${seconds % 60}s`;
|
||||||
|
}
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubagentTypeLabel(type: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
Explore: "Explorer",
|
||||||
|
"general-purpose": "General",
|
||||||
|
Plan: "Planner",
|
||||||
|
Bash: "Shell",
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadgeClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "running":
|
||||||
|
return "bg-blue-500/20 text-blue-400 border-blue-500/30";
|
||||||
|
case "completed":
|
||||||
|
return "bg-green-500/20 text-green-400 border-green-500/30";
|
||||||
|
case "errored":
|
||||||
|
return "bg-red-500/20 text-red-400 border-red-500/30";
|
||||||
|
default:
|
||||||
|
return "bg-gray-500/20 text-gray-400 border-gray-500/30";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleKillAll() {
|
||||||
|
if (!currentConversationId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke("interrupt_claude", { conversationId: currentConversationId });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to kill Claude process:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClearCompleted() {
|
||||||
|
if (currentConversationId) {
|
||||||
|
agentStore.clearCompleted(currentConversationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten the tree for rendering with depth information
|
||||||
|
const flattenedAgents = $derived.by(() => {
|
||||||
|
const result: { agent: AgentInfo; depth: number }[] = [];
|
||||||
|
const { topLevel, childrenMap } = agentTree;
|
||||||
|
|
||||||
|
function addAgentAndChildren(agent: AgentInfo, depth: number) {
|
||||||
|
result.push({ agent, depth });
|
||||||
|
const children = childrenMap.get(agent.toolUseId);
|
||||||
|
if (children) {
|
||||||
|
children.forEach((child) => addAgentAndChildren(child, depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
topLevel.forEach((agent) => addAgentAndChildren(agent, 0));
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="fixed inset-0 z-40" onclick={onClose}></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed top-12 right-0 bottom-0 w-80 bg-[var(--bg-primary)] border-l border-[var(--border-color)] shadow-xl z-50 flex flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between p-4 border-b border-[var(--border-color)]">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-[var(--accent-primary)]"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-sm font-semibold text-[var(--text-primary)]">Agent Monitor</h3>
|
||||||
|
{#if runningAgents.length > 0}
|
||||||
|
<span
|
||||||
|
class="px-1.5 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400 animate-pulse"
|
||||||
|
>
|
||||||
|
{runningAgents.length} running
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
aria-label="Close agent monitor"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="flex gap-2 px-4 py-2 border-b border-[var(--border-color)]">
|
||||||
|
<button
|
||||||
|
onclick={handleKillAll}
|
||||||
|
disabled={runningAgents.length === 0}
|
||||||
|
class="flex-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
title="Kills the entire Claude Code process to stop all agents"
|
||||||
|
>
|
||||||
|
Kill All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={handleClearCompleted}
|
||||||
|
disabled={completedAgents.length === 0 && erroredAgents.length === 0}
|
||||||
|
class="flex-1 px-2 py-1 text-xs bg-[var(--bg-secondary)] hover:bg-[var(--bg-hover,var(--bg-secondary))] text-[var(--text-secondary)] rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Clear Finished
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Agent list -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-4 space-y-2">
|
||||||
|
{#if agents.length === 0}
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)] text-sm"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-8 h-8 mb-2 opacity-50"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p>No agents detected yet</p>
|
||||||
|
<p class="text-xs mt-1 opacity-70">
|
||||||
|
Agents will appear here when Claude uses the Task tool
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each flattenedAgents as { agent, depth } (agent.toolUseId)}
|
||||||
|
<div
|
||||||
|
class="p-3 rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] {agent.status ===
|
||||||
|
'running'
|
||||||
|
? 'border-l-2 border-l-blue-500'
|
||||||
|
: agent.status === 'errored'
|
||||||
|
? 'border-l-2 border-l-red-500'
|
||||||
|
: 'border-l-2 border-l-green-500'}"
|
||||||
|
style="margin-left: {depth * 12}px; width: calc(100% - {depth * 12}px);"
|
||||||
|
>
|
||||||
|
<!-- Agent header -->
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
{#if depth > 0}
|
||||||
|
<svg
|
||||||
|
class="w-3 h-3 text-[var(--text-secondary)]"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<span
|
||||||
|
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
|
||||||
|
agent.status
|
||||||
|
)}"
|
||||||
|
>
|
||||||
|
{getSubagentTypeLabel(agent.subagentType)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="text-[10px] {agent.status === 'running'
|
||||||
|
? 'text-blue-400'
|
||||||
|
: 'text-[var(--text-secondary)]'}"
|
||||||
|
>
|
||||||
|
{#if agent.durationMs !== undefined}
|
||||||
|
{Math.floor(agent.durationMs / 1000)}s
|
||||||
|
{:else}
|
||||||
|
{formatDuration(agent.startedAt, agent.endedAt)}
|
||||||
|
{/if}
|
||||||
|
{#if agent.status === "running"}
|
||||||
|
<span class="inline-block w-1 h-1 bg-blue-400 rounded-full animate-pulse ml-1"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Agent description -->
|
||||||
|
<p class="text-xs text-[var(--text-primary)] truncate" title={agent.description}>
|
||||||
|
{agent.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Status indicator -->
|
||||||
|
<div class="mt-1 flex items-center gap-1">
|
||||||
|
{#if agent.status === "running"}
|
||||||
|
<span class="text-[10px] text-blue-400">Running...</span>
|
||||||
|
{:else if agent.status === "completed"}
|
||||||
|
<span class="text-[10px] text-green-400">Completed</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-[10px] text-red-400">Errored / Killed</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer summary -->
|
||||||
|
{#if agents.length > 0}
|
||||||
|
<div
|
||||||
|
class="px-4 py-2 border-t border-[var(--border-color)] text-[10px] text-[var(--text-secondary)]"
|
||||||
|
>
|
||||||
|
{agents.length} total ·
|
||||||
|
{runningAgents.length} running ·
|
||||||
|
{completedAgents.length} completed ·
|
||||||
|
{erroredAgents.length} errored
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import { characterState, characterInfo } from "$lib/stores/character";
|
import { characterState, characterInfo } from "$lib/stores/character";
|
||||||
import { isStreamerMode } from "$lib/stores/config";
|
import { isStreamerMode } from "$lib/stores/config";
|
||||||
import { handleNewUserMessage } from "$lib/notifications/rules";
|
import { handleNewUserMessage } from "$lib/notifications/rules";
|
||||||
|
import { setSkipNextGreeting } from "$lib/tauri";
|
||||||
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
|
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -127,6 +128,9 @@
|
|||||||
const conversationId = get(claudeStore.activeConversationId);
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
if (!conversationId) return;
|
if (!conversationId) return;
|
||||||
|
|
||||||
|
// Set flag to preserve stats/permissions (don't treat next connect as new session)
|
||||||
|
setSkipNextGreeting(true);
|
||||||
|
|
||||||
await invoke("interrupt_claude", { conversationId });
|
await invoke("interrupt_claude", { conversationId });
|
||||||
claudeStore.addLine("system", "Interrupted");
|
claudeStore.addLine("system", "Interrupted");
|
||||||
characterState.setState("idle");
|
characterState.setState("idle");
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
session_cost_budget: null,
|
session_cost_budget: null,
|
||||||
budget_action: "warn",
|
budget_action: "warn",
|
||||||
budget_warning_threshold: 0.8,
|
budget_warning_threshold: 0.8,
|
||||||
|
discord_rpc_enabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
let showCustomThemeEditor = $state(false);
|
let showCustomThemeEditor = $state(false);
|
||||||
@@ -80,16 +81,20 @@
|
|||||||
|
|
||||||
const availableModels = [
|
const availableModels = [
|
||||||
{ value: "", label: "Default (from ~/.claude)" },
|
{ value: "", label: "Default (from ~/.claude)" },
|
||||||
// Current generation (Claude 4.5)
|
// Current generation (Claude 4.6)
|
||||||
|
{ value: "claude-opus-4-6", label: "Claude Opus 4.6 (Most Capable)" },
|
||||||
|
// Previous generation (Claude 4.5)
|
||||||
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5 (Recommended)" },
|
{ 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-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" },
|
||||||
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5 (Most Capable)" },
|
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5" },
|
||||||
// Previous generation (Claude 4)
|
// Previous generation (Claude 4.x)
|
||||||
{ value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" },
|
{ 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)
|
// Legacy (Claude 3.x)
|
||||||
{ value: "claude-3-7-sonnet-20250219", label: "Claude 3.7 Sonnet" },
|
{ value: "claude-3-7-sonnet-20250219", label: "Claude 3.7 Sonnet" },
|
||||||
|
{ value: "claude-3-5-sonnet-20241022", label: "Claude 3.5 Sonnet (Oct 2024)" },
|
||||||
|
{ value: "claude-3-5-sonnet-20240620", label: "Claude 3.5 Sonnet (Jun 2024)" },
|
||||||
{ value: "claude-3-haiku-20240307", label: "Claude 3 Haiku (Cheapest)" },
|
{ value: "claude-3-haiku-20240307", label: "Claude 3 Haiku (Cheapest)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -967,6 +972,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
|
||||||
|
|||||||
@@ -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,7 @@
|
|||||||
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 { 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";
|
||||||
@@ -337,19 +338,39 @@ User: ${formattedMessage}`;
|
|||||||
throw new Error("No active conversation");
|
throw new Error("No active conversation");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current working directory before reconnecting
|
// Get current working directory and granted tools before reconnecting
|
||||||
const workingDir = await invoke<string>("get_working_directory", { conversationId });
|
const workingDir = await invoke<string>("get_working_directory", { conversationId });
|
||||||
|
const activeConversation = get(conversationsStore.activeConversation);
|
||||||
|
const grantedTools = activeConversation
|
||||||
|
? Array.from(activeConversation.grantedTools)
|
||||||
|
: [];
|
||||||
|
const config = configStore.getConfig();
|
||||||
|
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
|
||||||
|
|
||||||
// Set the flag to skip greeting on next connection
|
// Set the flag to skip greeting on next connection
|
||||||
setSkipNextGreeting(true);
|
setSkipNextGreeting(true);
|
||||||
|
|
||||||
// Reconnect to Claude
|
// Reconnect to Claude with preserved permissions
|
||||||
await invoke("start_claude", {
|
await invoke("start_claude", {
|
||||||
conversationId,
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
working_dir: workingDir,
|
working_dir: workingDir,
|
||||||
|
model: config.model || null,
|
||||||
|
api_key: config.api_key || null,
|
||||||
|
custom_instructions: config.custom_instructions || null,
|
||||||
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
|
allowed_tools: allAllowedTools,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update Discord RPC when reconnecting
|
||||||
|
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}`);
|
||||||
@@ -435,11 +456,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;
|
||||||
@@ -573,11 +595,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);
|
||||||
}
|
}
|
||||||
@@ -635,11 +658,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
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 { claudeStore, hasPermissionPending } from "$lib/stores/claude";
|
import { claudeStore } 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, setSkipNextGreeting } from "$lib/tauri";
|
||||||
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
|
import { configStore } from "$lib/stores/config";
|
||||||
|
|
||||||
let isVisible = $state(false);
|
|
||||||
let permission: PermissionRequest | null = $state(null);
|
let permission: PermissionRequest | null = $state(null);
|
||||||
let grantedToolsList: string[] = $state([]);
|
let grantedToolsList: string[] = $state([]);
|
||||||
let workingDirectory = $state("");
|
let workingDirectory = $state("");
|
||||||
|
|
||||||
hasPermissionPending.subscribe((pending) => {
|
|
||||||
isVisible = pending;
|
|
||||||
});
|
|
||||||
|
|
||||||
claudeStore.pendingPermission.subscribe((perm) => {
|
claudeStore.pendingPermission.subscribe((perm) => {
|
||||||
permission = perm;
|
permission = perm;
|
||||||
if (perm) {
|
if (perm) {
|
||||||
@@ -51,19 +49,37 @@
|
|||||||
throw new Error("No active conversation");
|
throw new Error("No active conversation");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent stats reset on reconnection
|
||||||
|
setSkipNextGreeting(true);
|
||||||
|
|
||||||
await invoke("stop_claude", { conversationId });
|
await invoke("stop_claude", { conversationId });
|
||||||
|
|
||||||
// Small delay to ensure clean shutdown
|
// Small delay to ensure clean shutdown
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const config = configStore.getConfig();
|
||||||
await invoke("start_claude", {
|
await invoke("start_claude", {
|
||||||
conversationId,
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
working_dir: workingDirectory || "/home/naomi",
|
working_dir: workingDirectory || "/home/naomi",
|
||||||
|
model: config.model || null,
|
||||||
|
api_key: config.api_key || null,
|
||||||
|
custom_instructions: config.custom_instructions || null,
|
||||||
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
allowed_tools: newGrantedTools,
|
allowed_tools: newGrantedTools,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update Discord RPC when reconnecting after permission grant
|
||||||
|
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));
|
||||||
|
|
||||||
@@ -111,7 +127,7 @@ Please continue where we left off and retry that action now that you have permis
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if (!isVisible || !permission) return;
|
if (!permission) return;
|
||||||
|
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -125,7 +141,7 @@ Please continue where we left off and retry that action now that you have permis
|
|||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
{#if isVisible && permission}
|
{#if permission}
|
||||||
<div
|
<div
|
||||||
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
|
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -21,15 +21,18 @@
|
|||||||
import HelpPanel from "./HelpPanel.svelte";
|
import HelpPanel from "./HelpPanel.svelte";
|
||||||
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
|
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
|
||||||
import { achievementProgress } from "$lib/stores/achievements";
|
import { achievementProgress } from "$lib/stores/achievements";
|
||||||
|
import { runningAgentCount } from "$lib/stores/agents";
|
||||||
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 AgentMonitorPanel from "./AgentMonitorPanel.svelte";
|
||||||
import { conversationsStore } from "$lib/stores/conversations";
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
import {
|
import {
|
||||||
generateContextInjection,
|
generateContextInjection,
|
||||||
createSummary,
|
createSummary,
|
||||||
sanitizeForJson,
|
sanitizeForJson,
|
||||||
} from "$lib/utils/conversationUtils";
|
} from "$lib/utils/conversationUtils";
|
||||||
|
import { updateDiscordRpc, setSkipNextGreeting } 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";
|
||||||
@@ -47,8 +50,10 @@
|
|||||||
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 showAgentMonitor = $state(false);
|
||||||
let isSummarising = $state(false);
|
let isSummarising = $state(false);
|
||||||
const progress = $derived($achievementProgress);
|
const progress = $derived($achievementProgress);
|
||||||
|
const activeAgentCount = $derived($runningAgentCount);
|
||||||
let currentConfig: HikariConfig = $state({
|
let currentConfig: HikariConfig = $state({
|
||||||
model: null,
|
model: null,
|
||||||
api_key: null,
|
api_key: null,
|
||||||
@@ -86,6 +91,7 @@
|
|||||||
session_cost_budget: null,
|
session_cost_budget: null,
|
||||||
budget_action: "warn",
|
budget_action: "warn",
|
||||||
budget_warning_threshold: 0.8,
|
budget_warning_threshold: 0.8,
|
||||||
|
discord_rpc_enabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
let streamerModeActive = $state(false);
|
let streamerModeActive = $state(false);
|
||||||
@@ -165,6 +171,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}`);
|
||||||
@@ -178,6 +194,9 @@
|
|||||||
throw new Error("No active conversation");
|
throw new Error("No active conversation");
|
||||||
}
|
}
|
||||||
await invoke("stop_claude", { conversationId });
|
await invoke("stop_claude", { conversationId });
|
||||||
|
|
||||||
|
// Clear granted permissions when user explicitly disconnects
|
||||||
|
claudeStore.revokeAllTools();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to stop Claude:", error);
|
console.error("Failed to stop Claude:", error);
|
||||||
}
|
}
|
||||||
@@ -236,6 +255,9 @@
|
|||||||
: sanitizedContent;
|
: sanitizedContent;
|
||||||
|
|
||||||
// Step 1: Disconnect from Claude to reset context
|
// Step 1: Disconnect from Claude to reset context
|
||||||
|
// Prevent stats reset on reconnection
|
||||||
|
setSkipNextGreeting(true);
|
||||||
|
|
||||||
if (connectionStatus === "connected") {
|
if (connectionStatus === "connected") {
|
||||||
await invoke("stop_claude", { conversationId: activeId });
|
await invoke("stop_claude", { conversationId: activeId });
|
||||||
}
|
}
|
||||||
@@ -448,6 +470,29 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (showAgentMonitor = !showAgentMonitor)}
|
||||||
|
class="p-1 text-gray-500 icon-trans-hover relative {showAgentMonitor
|
||||||
|
? 'text-[var(--trans-pink)]'
|
||||||
|
: ''}"
|
||||||
|
title="Agent Monitor"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{#if activeAgentCount > 0}
|
||||||
|
<span
|
||||||
|
class="absolute -top-1 -right-1 bg-blue-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px] animate-pulse"
|
||||||
|
>
|
||||||
|
{activeAgentCount}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => (showStats = !showStats)}
|
onclick={() => (showStats = !showStats)}
|
||||||
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
|
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
|
||||||
@@ -620,3 +665,7 @@
|
|||||||
{#if showProfile}
|
{#if showProfile}
|
||||||
<ProfilePanel onClose={() => (showProfile = false)} />
|
<ProfilePanel onClose={() => (showProfile = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showAgentMonitor}
|
||||||
|
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -209,8 +209,30 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#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"
|
||||||
|
style={line.parentToolUseId
|
||||||
|
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
|
||||||
|
: ""}
|
||||||
|
>
|
||||||
<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.parentToolUseId}
|
||||||
|
<span class="text-xs mr-2 opacity-60" title="Message from subagent">
|
||||||
|
<svg
|
||||||
|
class="inline-block w-3 h-3 -mt-0.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
{#if line.cost && line.cost.costUsd > 0}
|
{#if line.cost && line.cost.costUsd > 0}
|
||||||
<span
|
<span
|
||||||
class="terminal-cost text-xs mr-2"
|
class="terminal-cost text-xs mr-2"
|
||||||
|
|||||||
@@ -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, setSkipNextGreeting } 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);
|
||||||
@@ -86,18 +89,36 @@
|
|||||||
claudeStore.clearQuestion();
|
claudeStore.clearQuestion();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Prevent stats reset on reconnection
|
||||||
|
setSkipNextGreeting(true);
|
||||||
|
|
||||||
await invoke("stop_claude", { conversationId });
|
await invoke("stop_claude", { conversationId });
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const config = configStore.getConfig();
|
||||||
await invoke("start_claude", {
|
await invoke("start_claude", {
|
||||||
conversationId,
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
working_dir: workingDirectory || "/home/naomi",
|
working_dir: workingDirectory || "/home/naomi",
|
||||||
|
model: config.model || null,
|
||||||
|
api_key: config.api_key || null,
|
||||||
|
custom_instructions: config.custom_instructions || null,
|
||||||
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
allowed_tools: grantedToolsList,
|
allowed_tools: grantedToolsList,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update Discord RPC when reconnecting after answering question
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { writable, derived } from "svelte/store";
|
||||||
|
import type { AgentInfo } from "$lib/types/agents";
|
||||||
|
|
||||||
|
// Map of conversation ID -> agents in that conversation
|
||||||
|
const agentsByConversation = writable<Record<string, AgentInfo[]>>({});
|
||||||
|
|
||||||
|
function createAgentStore() {
|
||||||
|
return {
|
||||||
|
subscribe: agentsByConversation.subscribe,
|
||||||
|
|
||||||
|
addAgent(conversationId: string, agent: AgentInfo) {
|
||||||
|
agentsByConversation.update((state) => {
|
||||||
|
const existing = state[conversationId] || [];
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
[conversationId]: [...existing, agent],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAgentId(conversationId: string, toolUseId: string, agentId: string) {
|
||||||
|
agentsByConversation.update((state) => {
|
||||||
|
const agents = state[conversationId];
|
||||||
|
if (!agents) return state;
|
||||||
|
|
||||||
|
const agentIndex = agents.findIndex((a) => a.toolUseId === toolUseId);
|
||||||
|
if (agentIndex === -1) return state;
|
||||||
|
|
||||||
|
const updated = [...agents];
|
||||||
|
updated[agentIndex] = {
|
||||||
|
...updated[agentIndex],
|
||||||
|
agentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
[conversationId]: updated,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
endAgent(conversationId: string, toolUseId: string, endedAt: number, isError: boolean) {
|
||||||
|
agentsByConversation.update((state) => {
|
||||||
|
const agents = state[conversationId];
|
||||||
|
if (!agents) return state;
|
||||||
|
|
||||||
|
const agentIndex = agents.findIndex((a) => a.toolUseId === toolUseId);
|
||||||
|
if (agentIndex === -1) return state;
|
||||||
|
|
||||||
|
const updated = [...agents];
|
||||||
|
const agent = updated[agentIndex];
|
||||||
|
const durationMs = endedAt - agent.startedAt;
|
||||||
|
|
||||||
|
updated[agentIndex] = {
|
||||||
|
...agent,
|
||||||
|
endedAt,
|
||||||
|
status: isError ? "errored" : "completed",
|
||||||
|
durationMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
[conversationId]: updated,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
markAllErrored(conversationId: string) {
|
||||||
|
agentsByConversation.update((state) => {
|
||||||
|
const agents = state[conversationId];
|
||||||
|
if (!agents) return state;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const updated = agents.map((agent) =>
|
||||||
|
agent.status === "running"
|
||||||
|
? { ...agent, endedAt: now, status: "errored" as const }
|
||||||
|
: agent
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
[conversationId]: updated,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCompleted(conversationId: string) {
|
||||||
|
agentsByConversation.update((state) => {
|
||||||
|
const agents = state[conversationId];
|
||||||
|
if (!agents) return state;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
[conversationId]: agents.filter((a) => a.status === "running"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearConversation(conversationId: string) {
|
||||||
|
agentsByConversation.update((state) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Unused destructured value
|
||||||
|
const { [conversationId]: _, ...rest } = state;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentStore = createAgentStore();
|
||||||
|
|
||||||
|
export function getAgentsForConversation(conversationId: string) {
|
||||||
|
return derived(agentsByConversation, ($state) => $state[conversationId] || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runningAgentCount = derived(agentsByConversation, ($state) => {
|
||||||
|
let count = 0;
|
||||||
|
for (const agents of Object.values($state)) {
|
||||||
|
count += agents.filter((a) => a.status === "running").length;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
});
|
||||||
@@ -192,6 +192,7 @@ describe("config store", () => {
|
|||||||
session_cost_budget: null,
|
session_cost_budget: null,
|
||||||
budget_action: "warn",
|
budget_action: "warn",
|
||||||
budget_warning_threshold: 0.8,
|
budget_warning_threshold: 0.8,
|
||||||
|
discord_rpc_enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBe("claude-sonnet-4");
|
expect(config.model).toBe("claude-sonnet-4");
|
||||||
@@ -237,6 +238,7 @@ describe("config store", () => {
|
|||||||
session_cost_budget: null,
|
session_cost_budget: null,
|
||||||
budget_action: "warn",
|
budget_action: "warn",
|
||||||
budget_warning_threshold: 0.8,
|
budget_warning_threshold: 0.8,
|
||||||
|
discord_rpc_enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBeNull();
|
expect(config.model).toBeNull();
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export interface HikariConfig {
|
|||||||
session_cost_budget: number | null;
|
session_cost_budget: number | null;
|
||||||
budget_action: BudgetAction;
|
budget_action: BudgetAction;
|
||||||
budget_warning_threshold: number;
|
budget_warning_threshold: number;
|
||||||
|
// Discord RPC settings
|
||||||
|
discord_rpc_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: HikariConfig = {
|
const defaultConfig: HikariConfig = {
|
||||||
@@ -83,6 +85,7 @@ const defaultConfig: HikariConfig = {
|
|||||||
session_cost_budget: null,
|
session_cost_budget: null,
|
||||||
budget_action: "warn",
|
budget_action: "warn",
|
||||||
budget_warning_threshold: 0.8,
|
budget_warning_threshold: 0.8,
|
||||||
|
discord_rpc_enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function createConfigStore() {
|
function createConfigStore() {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface Conversation {
|
|||||||
lastActivityAt: Date;
|
lastActivityAt: Date;
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
summary: ConversationSummary | null;
|
summary: ConversationSummary | null;
|
||||||
|
startedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createConversationsStore() {
|
function createConversationsStore() {
|
||||||
@@ -72,6 +73,7 @@ function createConversationsStore() {
|
|||||||
lastActivityAt: new Date(),
|
lastActivityAt: new Date(),
|
||||||
attachments: [],
|
attachments: [],
|
||||||
summary: null,
|
summary: null,
|
||||||
|
startedAt: new Date(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,7 +435,8 @@ function createConversationsStore() {
|
|||||||
type: TerminalLine["type"],
|
type: TerminalLine["type"],
|
||||||
content: string,
|
content: string,
|
||||||
toolName?: string,
|
toolName?: string,
|
||||||
cost?: TerminalLine["cost"]
|
cost?: TerminalLine["cost"],
|
||||||
|
parentToolUseId?: string
|
||||||
) => {
|
) => {
|
||||||
ensureInitialized();
|
ensureInitialized();
|
||||||
const activeId = get(activeConversationId);
|
const activeId = get(activeConversationId);
|
||||||
@@ -446,6 +449,7 @@ function createConversationsStore() {
|
|||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
toolName,
|
toolName,
|
||||||
cost,
|
cost,
|
||||||
|
parentToolUseId,
|
||||||
};
|
};
|
||||||
|
|
||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
@@ -467,7 +471,8 @@ function createConversationsStore() {
|
|||||||
type: TerminalLine["type"],
|
type: TerminalLine["type"],
|
||||||
content: string,
|
content: string,
|
||||||
toolName?: string,
|
toolName?: string,
|
||||||
cost?: TerminalLine["cost"]
|
cost?: TerminalLine["cost"],
|
||||||
|
parentToolUseId?: string
|
||||||
) => {
|
) => {
|
||||||
ensureInitialized();
|
ensureInitialized();
|
||||||
|
|
||||||
@@ -478,6 +483,7 @@ function createConversationsStore() {
|
|||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
toolName,
|
toolName,
|
||||||
cost,
|
cost,
|
||||||
|
parentToolUseId,
|
||||||
};
|
};
|
||||||
|
|
||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 { costTrackingStore } from "./costTracking";
|
||||||
|
import { configStore } from "./config";
|
||||||
|
|
||||||
export type ContextWarning = "moderate" | "high" | "critical";
|
export type ContextWarning = "moderate" | "high" | "critical";
|
||||||
export type BudgetType = "token" | "cost";
|
export type BudgetType = "token" | "cost";
|
||||||
@@ -9,7 +10,9 @@ export type BudgetType = "token" | "cost";
|
|||||||
// Model pricing (per million tokens) - keep in sync with stats.rs
|
// Model pricing (per million tokens) - keep in sync with stats.rs
|
||||||
// Source: https://platform.claude.com/docs/en/about-claude/models/overview
|
// Source: https://platform.claude.com/docs/en/about-claude/models/overview
|
||||||
export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
|
export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
|
||||||
// Current generation (Claude 4.5)
|
// Current generation (Claude 4.6)
|
||||||
|
"claude-opus-4-6": { input: 5.0, output: 25.0 },
|
||||||
|
// Previous generation (Claude 4.5)
|
||||||
"claude-opus-4-5-20251101": { input: 5.0, output: 25.0 },
|
"claude-opus-4-5-20251101": { input: 5.0, output: 25.0 },
|
||||||
"claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 },
|
"claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 },
|
||||||
"claude-haiku-4-5-20251001": { input: 1.0, output: 5.0 },
|
"claude-haiku-4-5-20251001": { input: 1.0, output: 5.0 },
|
||||||
@@ -133,7 +136,7 @@ export function formatTokenCount(tokens: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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) => {
|
||||||
@@ -164,6 +167,9 @@ export const formattedStats = derived(stats, ($stats) => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Use the model from stats if available, otherwise fall back to the configured model
|
||||||
|
const currentModel = $stats.model ?? $config.model ?? "No model selected";
|
||||||
|
|
||||||
return {
|
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),
|
||||||
@@ -173,7 +179,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),
|
||||||
|
|||||||
+129
-3
@@ -12,6 +12,8 @@ import type {
|
|||||||
UserQuestionEvent,
|
UserQuestionEvent,
|
||||||
} from "$lib/types/messages";
|
} from "$lib/types/messages";
|
||||||
import type { CharacterState } from "$lib/types/states";
|
import type { CharacterState } from "$lib/types/states";
|
||||||
|
import type { AgentStartPayload, AgentEndPayload } from "$lib/types/agents";
|
||||||
|
import { agentStore } from "$lib/stores/agents";
|
||||||
import {
|
import {
|
||||||
initializeNotificationRules,
|
initializeNotificationRules,
|
||||||
cleanupNotificationRules,
|
cleanupNotificationRules,
|
||||||
@@ -95,6 +97,7 @@ interface OutputPayload {
|
|||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
cost_usd: number;
|
cost_usd: number;
|
||||||
};
|
};
|
||||||
|
parent_tool_use_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConnectionPayload {
|
interface ConnectionPayload {
|
||||||
@@ -175,6 +178,12 @@ export async function initializeTauriListeners() {
|
|||||||
} else if (status === "disconnected") {
|
} else if (status === "disconnected") {
|
||||||
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||||
|
|
||||||
|
// Mark all running agents as errored on disconnect, but not during reconnects
|
||||||
|
// (permission prompts trigger reconnects and agents may complete before reconnect)
|
||||||
|
if (!skipNextGreeting && targetConversationId) {
|
||||||
|
agentStore.markAllErrored(targetConversationId);
|
||||||
|
}
|
||||||
|
|
||||||
// Only remove from connected set if we're not about to reconnect
|
// Only remove from connected set if we're not about to reconnect
|
||||||
if (!skipNextGreeting && targetConversationId) {
|
if (!skipNextGreeting && targetConversationId) {
|
||||||
connectedConversations.delete(targetConversationId);
|
connectedConversations.delete(targetConversationId);
|
||||||
@@ -247,7 +256,8 @@ 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, cost } = event.payload;
|
const { line_type, content, tool_name, conversation_id, cost, parent_tool_use_id } =
|
||||||
|
event.payload;
|
||||||
|
|
||||||
// Convert snake_case cost to camelCase for TypeScript
|
// Convert snake_case cost to camelCase for TypeScript
|
||||||
const costData = cost
|
const costData = cost
|
||||||
@@ -265,7 +275,8 @@ export async function initializeTauriListeners() {
|
|||||||
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
|
costData,
|
||||||
|
parent_tool_use_id
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to active conversation if no conversation_id provided
|
// Fallback to active conversation if no conversation_id provided
|
||||||
@@ -273,7 +284,8 @@ export async function initializeTauriListeners() {
|
|||||||
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
|
costData,
|
||||||
|
parent_tool_use_id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -345,6 +357,50 @@ export async function initializeTauriListeners() {
|
|||||||
});
|
});
|
||||||
unlisteners.push(permissionUnlisten);
|
unlisteners.push(permissionUnlisten);
|
||||||
|
|
||||||
|
const agentStartUnlisten = await listen<AgentStartPayload>("claude:agent-start", (event) => {
|
||||||
|
const {
|
||||||
|
tool_use_id,
|
||||||
|
agent_id,
|
||||||
|
description,
|
||||||
|
subagent_type,
|
||||||
|
started_at,
|
||||||
|
conversation_id,
|
||||||
|
parent_tool_use_id,
|
||||||
|
} = event.payload;
|
||||||
|
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||||
|
if (targetConversationId) {
|
||||||
|
agentStore.addAgent(targetConversationId, {
|
||||||
|
toolUseId: tool_use_id,
|
||||||
|
agentId: agent_id,
|
||||||
|
description,
|
||||||
|
subagentType: subagent_type,
|
||||||
|
startedAt: started_at,
|
||||||
|
status: "running",
|
||||||
|
parentToolUseId: parent_tool_use_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
unlisteners.push(agentStartUnlisten);
|
||||||
|
|
||||||
|
const agentUpdateUnlisten = await listen<{
|
||||||
|
conversationId: string;
|
||||||
|
toolUseId: string;
|
||||||
|
agentId: string;
|
||||||
|
}>("claude:agent-update", (event) => {
|
||||||
|
const { conversationId, toolUseId, agentId } = event.payload;
|
||||||
|
agentStore.updateAgentId(conversationId, toolUseId, agentId);
|
||||||
|
});
|
||||||
|
unlisteners.push(agentUpdateUnlisten);
|
||||||
|
|
||||||
|
const agentEndUnlisten = await listen<AgentEndPayload>("claude:agent-end", (event) => {
|
||||||
|
const { tool_use_id, ended_at, is_error, conversation_id } = event.payload;
|
||||||
|
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||||
|
if (targetConversationId) {
|
||||||
|
agentStore.endAgent(targetConversationId, tool_use_id, ended_at, is_error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
unlisteners.push(agentEndUnlisten);
|
||||||
|
|
||||||
const questionUnlisten = await listen<UserQuestionEvent>("claude:question", (event) => {
|
const questionUnlisten = await listen<UserQuestionEvent>("claude:question", (event) => {
|
||||||
const questionEvent = event.payload;
|
const questionEvent = event.payload;
|
||||||
|
|
||||||
@@ -373,3 +429,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export type AgentStatus = "running" | "completed" | "errored";
|
||||||
|
|
||||||
|
export interface AgentInfo {
|
||||||
|
toolUseId: string;
|
||||||
|
agentId?: string;
|
||||||
|
description: string;
|
||||||
|
subagentType: string;
|
||||||
|
startedAt: number;
|
||||||
|
endedAt?: number;
|
||||||
|
status: AgentStatus;
|
||||||
|
parentToolUseId?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentStartPayload {
|
||||||
|
tool_use_id: string;
|
||||||
|
agent_id?: string;
|
||||||
|
description: string;
|
||||||
|
subagent_type: string;
|
||||||
|
started_at: number;
|
||||||
|
conversation_id?: string;
|
||||||
|
parent_tool_use_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentEndPayload {
|
||||||
|
tool_use_id: string;
|
||||||
|
ended_at: number;
|
||||||
|
is_error: boolean;
|
||||||
|
conversation_id?: string;
|
||||||
|
duration_ms?: number;
|
||||||
|
num_turns?: number;
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ export interface TerminalLine {
|
|||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
costUsd: number;
|
costUsd: number;
|
||||||
};
|
};
|
||||||
|
// Indicates if this message is from a subagent
|
||||||
|
parentToolUseId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemInitMessage {
|
export interface SystemInitMessage {
|
||||||
|
|||||||
+33
-1
@@ -2,7 +2,14 @@
|
|||||||
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,
|
||||||
|
setSkipNextGreeting,
|
||||||
|
} 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 +64,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;
|
||||||
@@ -254,6 +279,9 @@
|
|||||||
const conversationId = get(claudeStore.activeConversationId);
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
if (!conversationId) return;
|
if (!conversationId) return;
|
||||||
|
|
||||||
|
// Set flag to preserve stats/permissions (don't treat next connect as new session)
|
||||||
|
setSkipNextGreeting(true);
|
||||||
|
|
||||||
await invoke("interrupt_claude", { conversationId });
|
await invoke("interrupt_claude", { conversationId });
|
||||||
claudeStore.addLine("system", "Process interrupted");
|
claudeStore.addLine("system", "Process interrupted");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -356,6 +384,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 +394,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