Compare commits

..

5 Commits

Author SHA1 Message Date
naomi 32fd2ddf45 fix: live settings updates
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 55s
CI / Lint & Test (pull_request) Failing after 5m35s
CI / Build Linux (pull_request) Has been skipped
CI / Build Windows (cross-compile) (pull_request) Has been skipped
2026-01-20 17:24:19 -08:00
naomi 599ed082ad feat: add changelog link to info panel 2026-01-20 16:33:09 -08:00
naomi c37c530f89 feat: add help panel 2026-01-20 16:29:57 -08:00
naomi 7aec0c1287 feat: add about panel 2026-01-20 14:29:10 -08:00
naomi 8aa4116063 feat: add donate icon 2026-01-20 14:16:39 -08:00
52 changed files with 436 additions and 3784 deletions
-1
View File
@@ -2,7 +2,6 @@ build/
.svelte-kit/ .svelte-kit/
dist/ dist/
src-tauri/target/ src-tauri/target/
src-tauri/gen/
node_modules/ node_modules/
.pnpm-store/ .pnpm-store/
pnpm-lock.yaml pnpm-lock.yaml
+3 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "hikari-desktop", "name": "hikari-desktop",
"version": "0.3.0", "version": "0.1.0",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -25,13 +25,11 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2",
"@tauri-apps/plugin-shell": "^2.3.4", "@tauri-apps/plugin-shell": "^2.3.4",
"@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-store": "^2",
"highlight.js": "^11.11.1", "@tauri-apps/plugin-notification": "^2",
"marked": "^17.0.1" "@tauri-apps/plugin-os": "^2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
-19
View File
@@ -29,12 +29,6 @@ importers:
'@tauri-apps/plugin-store': '@tauri-apps/plugin-store':
specifier: ^2 specifier: ^2
version: 2.4.2 version: 2.4.2
highlight.js:
specifier: ^11.11.1
version: 11.11.1
marked:
specifier: ^17.0.1
version: 17.0.1
devDependencies: devDependencies:
'@eslint/js': '@eslint/js':
specifier: ^9.39.2 specifier: ^9.39.2
@@ -1191,10 +1185,6 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
html-encoding-sniffer@6.0.0: html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -1382,11 +1372,6 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@17.0.1:
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
engines: {node: '>= 20'}
hasBin: true
mdn-data@2.12.2: mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
@@ -2795,8 +2780,6 @@ snapshots:
has-flag@4.0.0: {} has-flag@4.0.0: {}
highlight.js@11.11.1: {}
html-encoding-sniffer@6.0.0: html-encoding-sniffer@6.0.0:
dependencies: dependencies:
'@exodus/bytes': 1.8.0 '@exodus/bytes': 1.8.0
@@ -2966,8 +2949,6 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
marked@17.0.1: {}
mdn-data@2.12.2: {} mdn-data@2.12.2: {}
min-indent@1.0.1: {} min-indent@1.0.1: {}
+4 -387
View File
@@ -480,57 +480,10 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [ dependencies = [
"percent-encoding",
"time", "time",
"version_check", "version_check",
] ]
[[package]]
name = "cookie_store"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "cookie_store"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fc4bff745c9b4c7fb1e97b25d13153da2bc7796260141df62378998d070207f"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@@ -554,7 +507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"core-foundation 0.10.1", "core-foundation",
"core-graphics-types", "core-graphics-types",
"foreign-types", "foreign-types",
"libc", "libc",
@@ -567,7 +520,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"core-foundation 0.10.1", "core-foundation",
"libc", "libc",
] ]
@@ -686,12 +639,6 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.5" version = "0.5.5"
@@ -798,15 +745,6 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]] [[package]]
name = "dpi" name = "dpi"
version = "0.1.2" version = "0.1.2"
@@ -1271,10 +1209,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi 0.11.1+wasi-snapshot-preview1", "wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1284,11 +1220,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi", "r-efi",
"wasip2", "wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1439,25 +1373,6 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.13.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -1496,17 +1411,15 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]] [[package]]
name = "hikari-desktop" name = "hikari-desktop"
version = "0.2.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"parking_lot", "parking_lot",
"semver",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-http",
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-os", "tauri-plugin-os",
@@ -1579,7 +1492,6 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@@ -1591,23 +1503,6 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.19" version = "0.1.19"
@@ -1627,11 +1522,9 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@@ -2017,12 +1910,6 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -2038,12 +1925,6 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "mac" name = "mac"
version = "0.1.1" version = "0.1.1"
@@ -2929,22 +2810,6 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.37.5" version = "0.37.5"
@@ -2963,61 +2828,6 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.17",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.17",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.43" version = "1.0.43"
@@ -3226,32 +3036,22 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"cookie",
"cookie_store 0.22.0",
"encoding_rs",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -3261,7 +3061,6 @@ dependencies = [
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams", "wasm-streams",
"web-sys", "web-sys",
"webpki-roots",
] ]
[[package]] [[package]]
@@ -3288,26 +3087,6 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -3330,41 +3109,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustls"
version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@@ -3825,12 +3569,6 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.7" version = "1.0.7"
@@ -3893,27 +3631,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "system-configuration"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@@ -3935,7 +3652,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"block2", "block2",
"core-foundation 0.10.1", "core-foundation",
"core-graphics", "core-graphics",
"crossbeam-channel", "crossbeam-channel",
"dispatch", "dispatch",
@@ -4155,30 +3872,6 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "tauri-plugin-http"
version = "2.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68bef611ccbfbce67c813959c11b23c1c084d201aa94222de9eba5f9edc3f897"
dependencies = [
"bytes",
"cookie_store 0.21.1",
"data-url",
"http",
"regex",
"reqwest",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.17",
"tokio",
"url",
"urlpattern",
]
[[package]] [[package]]
name = "tauri-plugin-notification" name = "tauri-plugin-notification"
version = "2.3.3" version = "2.3.3"
@@ -4493,21 +4186,6 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "tinyvec"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.49.0" version = "1.49.0"
@@ -4536,16 +4214,6 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.18" version = "0.7.18"
@@ -4835,12 +4503,6 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -5044,16 +4706,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "webkit2gtk" name = "webkit2gtk"
version = "2.0.1" version = "2.0.1"
@@ -5098,15 +4750,6 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-roots"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"
@@ -5334,17 +4977,6 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.3.4" version = "0.3.4"
@@ -5390,15 +5022,6 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -5870,12 +5493,6 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.3" version = "0.2.3"
+1 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "hikari-desktop" name = "hikari-desktop"
version = "0.3.0" version = "0.1.0"
description = "Hikari - Claude Code Visual Assistant" description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"] authors = ["Naomi Carrigan"]
edition = "2021" edition = "2021"
@@ -25,9 +25,7 @@ uuid = { version = "1", features = ["v4"] }
tauri-plugin-store = "2.4.2" tauri-plugin-store = "2.4.2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
tauri-plugin-os = "2" tauri-plugin-os = "2"
tauri-plugin-http = "2"
tempfile = "3" tempfile = "3"
semver = "1"
chrono = { version = "0.4.43", features = ["serde"] } chrono = { version = "0.4.43", features = ["serde"] }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
+53 -78
View File
@@ -1,6 +1,6 @@
use chrono::{DateTime, Datelike, Timelike, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashSet; use std::collections::HashSet;
use chrono::{DateTime, Utc, Timelike, Datelike};
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
@@ -12,9 +12,9 @@ pub enum AchievementId {
TokenMaster, // 1,000,000 tokens TokenMaster, // 1,000,000 tokens
// Code Generation // Code Generation
HelloWorld, // First code block HelloWorld, // First code block
CodeWizard, // 100 code blocks CodeWizard, // 100 code blocks
ThousandBlocks, // 1,000 code blocks ThousandBlocks, // 1,000 code blocks
// File Operations // File Operations
FileManipulator, // 10 files edited FileManipulator, // 10 files edited
@@ -22,23 +22,23 @@ pub enum AchievementId {
// Conversation milestones // Conversation milestones
ConversationStarter, // 10 messages ConversationStarter, // 10 messages
ChattyKathy, // 100 messages ChattyKathy, // 100 messages
Conversationalist, // 1,000 messages Conversationalist, // 1,000 messages
// Tool usage // Tool usage
Toolsmith, // 5 different tools Toolsmith, // 5 different tools
ToolMaster, // 10 different tools ToolMaster, // 10 different tools
// Time-based achievements // Time-based achievements
EarlyBird, // Started session 5-7 AM EarlyBird, // Started session 5-7 AM
NightOwl, // Coding after midnight NightOwl, // Coding after midnight
AllNighter, // Worked 2-5 AM AllNighter, // Worked 2-5 AM
WeekendWarrior, // Coding on weekend WeekendWarrior, // Coding on weekend
DedicatedDeveloper, // 30 days in a row DedicatedDeveloper, // 30 days in a row
// Search and exploration // Search and exploration
Explorer, // 50 searches Explorer, // 50 searches
MasterSearcher, // 500 searches MasterSearcher, // 500 searches
// Session achievements // Session achievements
QuickSession, // Productive session < 5 min QuickSession, // Productive session < 5 min
@@ -47,36 +47,36 @@ pub enum AchievementId {
MarathonSession, // 5+ hour session MarathonSession, // 5+ hour session
// Special achievements // Special achievements
FirstMessage, // First message sent FirstMessage, // First message sent
FirstTool, // First tool used FirstTool, // First tool used
FirstCodeBlock, // First code generated FirstCodeBlock, // First code generated
FirstFileEdit, // First file edit FirstFileEdit, // First file edit
Polyglot, // 5+ languages in one session Polyglot, // 5+ languages in one session
SpeedCoder, // 10 code blocks in 10 minutes SpeedCoder, // 10 code blocks in 10 minutes
ClaudeConnoisseur, // Used all Claude models ClaudeConnoisseur, // Used all Claude models
MarathonCoder, // 10k tokens in one session MarathonCoder, // 10k tokens in one session
// Relationship & Greetings // Relationship & Greetings
GoodMorning, // Say "good morning" GoodMorning, // Say "good morning"
GoodNight, // Say "good night" or "goodnight" GoodNight, // Say "good night" or "goodnight"
ThankYou, // Say "thank you" or "thanks" ThankYou, // Say "thank you" or "thanks"
LoveYou, // Say "love you" or "ily" LoveYou, // Say "love you" or "ily"
// Personality & Fun // Personality & Fun
EmojiUser, // Use an emoji in a message EmojiUser, // Use an emoji in a message
QuestionMaster, // Use "?" in 20 messages QuestionMaster, // Use "?" in 20 messages
CapsLock, // Send a message in ALL CAPS CapsLock, // Send a message in ALL CAPS
PleaseAndThankYou, // Use "please" in messages PleaseAndThankYou, // Use "please" in messages
// Git & Development // Git & Development
GitGuru, // Use git commands 10 times GitGuru, // Use git commands 10 times
TestWriter, // Create test files TestWriter, // Create test files
Debugger, // Fix bugs (messages with "fix", "bug", "error") Debugger, // Fix bugs (messages with "fix", "bug", "error")
// Tool Mastery // Tool Mastery
BashMaster, // Use Bash tool 50 times BashMaster, // Use Bash tool 50 times
FileExplorer, // Use Read tool 100 times FileExplorer, // Use Read tool 100 times
SearchExpert, // Use Grep tool 50 times SearchExpert, // Use Grep tool 50 times
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -509,20 +509,15 @@ pub fn check_message_achievements(
newly_unlocked.push(AchievementId::GoodMorning); newly_unlocked.push(AchievementId::GoodMorning);
} }
if (message_lower.contains("good night") || message_lower.contains("goodnight")) if (message_lower.contains("good night") || message_lower.contains("goodnight"))
&& progress.unlock(AchievementId::GoodNight) && progress.unlock(AchievementId::GoodNight) {
{
newly_unlocked.push(AchievementId::GoodNight); newly_unlocked.push(AchievementId::GoodNight);
} }
if (message_lower.contains("thank you") if (message_lower.contains("thank you") || message_lower.contains("thanks") || message_lower.contains("thx"))
|| message_lower.contains("thanks") && progress.unlock(AchievementId::ThankYou) {
|| message_lower.contains("thx"))
&& progress.unlock(AchievementId::ThankYou)
{
newly_unlocked.push(AchievementId::ThankYou); newly_unlocked.push(AchievementId::ThankYou);
} }
if (message_lower.contains("love you") || message_lower.contains("ily")) if (message_lower.contains("love you") || message_lower.contains("ily"))
&& progress.unlock(AchievementId::LoveYou) && progress.unlock(AchievementId::LoveYou) {
{
newly_unlocked.push(AchievementId::LoveYou); newly_unlocked.push(AchievementId::LoveYou);
} }
@@ -530,11 +525,9 @@ pub fn check_message_achievements(
if message.chars().any(|c| c as u32 >= 0x1F300) && progress.unlock(AchievementId::EmojiUser) { if message.chars().any(|c| c as u32 >= 0x1F300) && progress.unlock(AchievementId::EmojiUser) {
newly_unlocked.push(AchievementId::EmojiUser); newly_unlocked.push(AchievementId::EmojiUser);
} }
if message == message.to_uppercase() if message == message.to_uppercase() && message.len() > 5
&& message.len() > 5
&& message.chars().any(|c| c.is_alphabetic()) && message.chars().any(|c| c.is_alphabetic())
&& progress.unlock(AchievementId::CapsLock) && progress.unlock(AchievementId::CapsLock) {
{
newly_unlocked.push(AchievementId::CapsLock); newly_unlocked.push(AchievementId::CapsLock);
} }
if message_lower.contains("please") && progress.unlock(AchievementId::PleaseAndThankYou) { if message_lower.contains("please") && progress.unlock(AchievementId::PleaseAndThankYou) {
@@ -542,11 +535,8 @@ pub fn check_message_achievements(
} }
// Git & Development patterns in messages // Git & Development patterns in messages
if (message_lower.contains("fix") if (message_lower.contains("fix") || message_lower.contains("bug") || message_lower.contains("error"))
|| message_lower.contains("bug") && progress.unlock(AchievementId::Debugger) {
|| message_lower.contains("error"))
&& progress.unlock(AchievementId::Debugger)
{
newly_unlocked.push(AchievementId::Debugger); newly_unlocked.push(AchievementId::Debugger);
} }
@@ -560,12 +550,10 @@ pub fn check_achievements(
) -> Vec<AchievementId> { ) -> Vec<AchievementId> {
let mut newly_unlocked = Vec::new(); let mut newly_unlocked = Vec::new();
println!( println!("Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
"Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
stats.messages_exchanged, stats.messages_exchanged,
stats.total_input_tokens + stats.total_output_tokens, stats.total_input_tokens + stats.total_output_tokens,
stats.code_blocks_generated stats.code_blocks_generated);
);
println!("Currently unlocked: {:?}", progress.unlocked); println!("Currently unlocked: {:?}", progress.unlocked);
// Token milestones // Token milestones
@@ -629,8 +617,7 @@ pub fn check_achievements(
// Search and exploration // Search and exploration
let search_tools = ["Glob", "Grep", "search", "Task"]; let search_tools = ["Glob", "Grep", "search", "Task"];
let search_count: u64 = search_tools let search_count: u64 = search_tools.iter()
.iter()
.filter_map(|tool| stats.tools_usage.get(*tool)) .filter_map(|tool| stats.tools_usage.get(*tool))
.sum(); .sum();
if search_count >= 50 && progress.unlock(AchievementId::Explorer) { if search_count >= 50 && progress.unlock(AchievementId::Explorer) {
@@ -642,10 +629,7 @@ pub fn check_achievements(
// Session duration achievements // Session duration achievements
let session_secs = stats.session_duration_seconds; let session_secs = stats.session_duration_seconds;
if session_secs < 300 if session_secs < 300 && stats.session_messages_exchanged >= 5 && progress.unlock(AchievementId::QuickSession) {
&& stats.session_messages_exchanged >= 5
&& progress.unlock(AchievementId::QuickSession)
{
newly_unlocked.push(AchievementId::QuickSession); newly_unlocked.push(AchievementId::QuickSession);
} }
if session_secs >= 1800 && progress.unlock(AchievementId::FocusedWork) { if session_secs >= 1800 && progress.unlock(AchievementId::FocusedWork) {
@@ -732,9 +716,7 @@ pub fn check_achievements(
// Weekend warrior // Weekend warrior
use chrono::Weekday; use chrono::Weekday;
if (weekday == Weekday::Sat || weekday == Weekday::Sun) if (weekday == Weekday::Sat || weekday == Weekday::Sun) && progress.unlock(AchievementId::WeekendWarrior) {
&& progress.unlock(AchievementId::WeekendWarrior)
{
newly_unlocked.push(AchievementId::WeekendWarrior); newly_unlocked.push(AchievementId::WeekendWarrior);
} }
} }
@@ -751,21 +733,16 @@ pub struct AchievementUnlockedEvent {
} }
// Save achievements to persistent store // Save achievements to persistent store
pub async fn save_achievements( pub async fn save_achievements(app: &tauri::AppHandle, progress: &AchievementProgress) -> Result<(), String> {
app: &tauri::AppHandle, let store = app.store("achievements.json")
progress: &AchievementProgress, .map_err(|e| e.to_string())?;
) -> Result<(), String> {
let store = app.store("achievements.json").map_err(|e| e.to_string())?;
// Create a serializable version with just the unlocked achievement IDs // Create a serializable version with just the unlocked achievement IDs
let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect(); let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect();
println!("Saving achievements: {:?}", unlocked_list); println!("Saving achievements: {:?}", unlocked_list);
store.set( store.set("unlocked", serde_json::to_value(unlocked_list).map_err(|e| e.to_string())?);
"unlocked",
serde_json::to_value(unlocked_list).map_err(|e| e.to_string())?,
);
store.save().map_err(|e| e.to_string())?; store.save().map_err(|e| e.to_string())?;
println!("Achievements saved successfully"); println!("Achievements saved successfully");
@@ -789,9 +766,7 @@ pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
// Get unlocked achievements // Get unlocked achievements
if let Some(unlocked_value) = store.get("unlocked") { if let Some(unlocked_value) = store.get("unlocked") {
println!("Found unlocked value in store: {:?}", unlocked_value); println!("Found unlocked value in store: {:?}", unlocked_value);
if let Ok(unlocked_list) = if let Ok(unlocked_list) = serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone()) {
serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone())
{
println!("Loaded {} achievements", unlocked_list.len()); println!("Loaded {} achievements", unlocked_list.len());
for achievement_id in unlocked_list { for achievement_id in unlocked_list {
progress.unlocked.insert(achievement_id); progress.unlocked.insert(achievement_id);
@@ -830,4 +805,4 @@ mod tests {
let newly = progress.take_newly_unlocked(); let newly = progress.take_newly_unlocked();
assert!(newly.is_empty()); assert!(newly.is_empty());
} }
} }
+16 -50
View File
@@ -29,40 +29,30 @@ impl BridgeManager {
conversation_id: &str, conversation_id: &str,
options: ClaudeStartOptions, options: ClaudeStartOptions,
) -> Result<(), String> { ) -> Result<(), String> {
// Check if a bridge already exists and is running for this conversation // Check if a bridge already exists for this conversation
if self if self.bridges.get(conversation_id).map(|b| b.is_running()).unwrap_or(false) {
.bridges
.get(conversation_id)
.map(|b| b.is_running())
.unwrap_or(false)
{
return Err("Claude is already running for this conversation".to_string()); return Err("Claude is already running for this conversation".to_string());
} }
let app = self let app = self.app_handle.as_ref()
.app_handle
.as_ref()
.ok_or_else(|| "App handle not set".to_string())? .ok_or_else(|| "App handle not set".to_string())?
.clone(); .clone();
// Reuse existing bridge if it exists (preserves stats across reconnects) // Create a new bridge for this conversation
// Only create a new bridge if one doesn't exist for this conversation let mut bridge = WslBridge::new_with_conversation_id(conversation_id.to_string());
let bridge = self
.bridges
.entry(conversation_id.to_string())
.or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string()));
// Start the Claude process // Start the Claude process
bridge.start(app, options)?; bridge.start(app, options)?;
// Store the bridge
self.bridges.insert(conversation_id.to_string(), bridge);
Ok(()) Ok(())
} }
pub fn stop_claude(&mut self, conversation_id: &str) -> Result<(), String> { pub fn stop_claude(&mut self, conversation_id: &str) -> Result<(), String> {
if let Some(bridge) = self.bridges.get_mut(conversation_id) { if let Some(bridge) = self.bridges.get_mut(conversation_id) {
let app = self let app = self.app_handle.as_ref()
.app_handle
.as_ref()
.ok_or_else(|| "App handle not set".to_string())?; .ok_or_else(|| "App handle not set".to_string())?;
bridge.stop(app); bridge.stop(app);
Ok(()) Ok(())
@@ -73,9 +63,7 @@ impl BridgeManager {
pub fn interrupt_claude(&mut self, conversation_id: &str) -> Result<(), String> { pub fn interrupt_claude(&mut self, conversation_id: &str) -> Result<(), String> {
if let Some(bridge) = self.bridges.get_mut(conversation_id) { if let Some(bridge) = self.bridges.get_mut(conversation_id) {
let app = self let app = self.app_handle.as_ref()
.app_handle
.as_ref()
.ok_or_else(|| "App handle not set".to_string())?; .ok_or_else(|| "App handle not set".to_string())?;
bridge.interrupt(app) bridge.interrupt(app)
} else { } else {
@@ -91,36 +79,20 @@ impl BridgeManager {
} }
} }
pub fn send_tool_result(
&mut self,
conversation_id: &str,
tool_use_id: &str,
result: serde_json::Value,
) -> Result<(), String> {
if let Some(bridge) = self.bridges.get_mut(conversation_id) {
bridge.send_tool_result(tool_use_id, result)
} else {
Err("No Claude instance found for this conversation".to_string())
}
}
pub fn is_claude_running(&self, conversation_id: &str) -> bool { pub fn is_claude_running(&self, conversation_id: &str) -> bool {
self.bridges self.bridges.get(conversation_id)
.get(conversation_id)
.map(|b| b.is_running()) .map(|b| b.is_running())
.unwrap_or(false) .unwrap_or(false)
} }
pub fn get_working_directory(&self, conversation_id: &str) -> Result<String, String> { pub fn get_working_directory(&self, conversation_id: &str) -> Result<String, String> {
self.bridges self.bridges.get(conversation_id)
.get(conversation_id)
.map(|b| b.get_working_directory().to_string()) .map(|b| b.get_working_directory().to_string())
.ok_or_else(|| "No Claude instance found for this conversation".to_string()) .ok_or_else(|| "No Claude instance found for this conversation".to_string())
} }
pub fn get_usage_stats(&self, conversation_id: &str) -> Result<UsageStats, String> { pub fn get_usage_stats(&self, conversation_id: &str) -> Result<UsageStats, String> {
self.bridges self.bridges.get(conversation_id)
.get(conversation_id)
.map(|b| b.get_stats()) .map(|b| b.get_stats())
.ok_or_else(|| "No Claude instance found for this conversation".to_string()) .ok_or_else(|| "No Claude instance found for this conversation".to_string())
} }
@@ -143,14 +115,8 @@ impl BridgeManager {
#[allow(dead_code)] #[allow(dead_code)]
pub fn get_active_conversations(&self) -> Vec<String> { pub fn get_active_conversations(&self) -> Vec<String> {
self.bridges self.bridges.keys()
.keys() .filter(|id| self.bridges.get(*id).map(|b| b.is_running()).unwrap_or(false))
.filter(|id| {
self.bridges
.get(*id)
.map(|b| b.is_running())
.unwrap_or(false)
})
.cloned() .cloned()
.collect() .collect()
} }
@@ -166,4 +132,4 @@ pub type SharedBridgeManager = Arc<Mutex<BridgeManager>>;
pub fn create_shared_bridge_manager() -> SharedBridgeManager { pub fn create_shared_bridge_manager() -> SharedBridgeManager {
Arc::new(Mutex::new(BridgeManager::new())) Arc::new(Mutex::new(BridgeManager::new()))
} }
+15 -189
View File
@@ -1,11 +1,10 @@
use tauri::{AppHandle, State}; use tauri::{AppHandle, State};
use tauri_plugin_http::reqwest;
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
use crate::achievements::{get_achievement_info, load_achievements, AchievementUnlockedEvent};
use crate::bridge_manager::SharedBridgeManager;
use crate::config::{ClaudeStartOptions, HikariConfig}; use crate::config::{ClaudeStartOptions, HikariConfig};
use crate::stats::UsageStats; use crate::stats::UsageStats;
use crate::bridge_manager::SharedBridgeManager;
use crate::achievements::{load_achievements, get_achievement_info, AchievementUnlockedEvent};
const CONFIG_STORE_KEY: &str = "config"; const CONFIG_STORE_KEY: &str = "config";
@@ -72,17 +71,23 @@ pub async fn select_wsl_directory() -> Result<String, String> {
#[tauri::command] #[tauri::command]
pub async fn get_config(app: AppHandle) -> Result<HikariConfig, String> { pub async fn get_config(app: AppHandle) -> Result<HikariConfig, String> {
let store = app.store("hikari-config.json").map_err(|e| e.to_string())?; let store = app
.store("hikari-config.json")
.map_err(|e| e.to_string())?;
match store.get(CONFIG_STORE_KEY) { match store.get(CONFIG_STORE_KEY) {
Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()), Some(value) => {
serde_json::from_value(value.clone()).map_err(|e| e.to_string())
}
None => Ok(HikariConfig::default()), None => Ok(HikariConfig::default()),
} }
} }
#[tauri::command] #[tauri::command]
pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), String> { pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), String> {
let store = app.store("hikari-config.json").map_err(|e| e.to_string())?; let store = app
.store("hikari-config.json")
.map_err(|e| e.to_string())?;
let value = serde_json::to_value(&config).map_err(|e| e.to_string())?; let value = serde_json::to_value(&config).map_err(|e| e.to_string())?;
store.set(CONFIG_STORE_KEY, value); store.set(CONFIG_STORE_KEY, value);
@@ -101,63 +106,7 @@ pub async fn get_usage_stats(
} }
#[tauri::command] #[tauri::command]
pub async fn validate_directory( pub async fn load_saved_achievements(app: AppHandle) -> Result<Vec<AchievementUnlockedEvent>, String> {
path: String,
current_dir: Option<String>,
) -> Result<String, String> {
use std::path::Path;
let path = Path::new(&path);
// Expand ~ to home directory
let expanded_path = if path.starts_with("~") {
if let Some(home) = std::env::var_os("HOME") {
let home_path = Path::new(&home);
if path == Path::new("~") {
home_path.to_path_buf()
} else {
home_path.join(path.strip_prefix("~").unwrap())
}
} else {
return Err("Could not determine home directory".to_string());
}
} else if path.is_relative() {
// Handle relative paths (., .., or any relative path) by resolving against current_dir
if let Some(ref cwd) = current_dir {
Path::new(cwd).join(path)
} else {
path.to_path_buf()
}
} else {
path.to_path_buf()
};
// Check if the path exists and is a directory
if !expanded_path.exists() {
return Err(format!(
"Directory does not exist: {}",
expanded_path.display()
));
}
if !expanded_path.is_dir() {
return Err(format!(
"Path is not a directory: {}",
expanded_path.display()
));
}
// Return the canonicalized (absolute) path
expanded_path
.canonicalize()
.map(|p| p.to_string_lossy().to_string())
.map_err(|e| format!("Failed to resolve path: {}", e))
}
#[tauri::command]
pub async fn load_saved_achievements(
app: AppHandle,
) -> Result<Vec<AchievementUnlockedEvent>, String> {
use chrono::Utc; use chrono::Utc;
// Load achievements from persistent store // Load achievements from persistent store
@@ -168,133 +117,10 @@ pub async fn load_saved_achievements(
for achievement_id in &progress.unlocked { for achievement_id in &progress.unlocked {
let mut info = get_achievement_info(achievement_id); let mut info = get_achievement_info(achievement_id);
info.unlocked_at = Some(Utc::now()); // We don't store timestamps, so just use now info.unlocked_at = Some(Utc::now()); // We don't store timestamps, so just use now
events.push(AchievementUnlockedEvent { achievement: info }); events.push(AchievementUnlockedEvent {
achievement: info,
});
} }
Ok(events) Ok(events)
} }
#[tauri::command]
pub async fn answer_question(
bridge_manager: State<'_, SharedBridgeManager>,
conversation_id: String,
tool_use_id: String,
answers: serde_json::Value,
) -> Result<(), String> {
let mut manager = bridge_manager.lock();
manager.send_tool_result(&conversation_id, &tool_use_id, answers)
}
#[tauri::command]
pub async fn list_skills() -> Result<Vec<String>, String> {
use std::fs;
use std::path::Path;
// Get the home directory
let home =
std::env::var_os("HOME").ok_or_else(|| "Could not determine home directory".to_string())?;
let skills_dir = Path::new(&home).join(".claude").join("skills");
// If the skills directory doesn't exist, return empty list
if !skills_dir.exists() {
return Ok(Vec::new());
}
// Read the directory and collect skill names
let mut skills = Vec::new();
let entries =
fs::read_dir(&skills_dir).map_err(|e| format!("Failed to read skills directory: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
let path = entry.path();
// Only include directories that contain a SKILL.md file
if path.is_dir() {
let skill_file = path.join("SKILL.md");
if skill_file.exists() {
if let Some(name) = path.file_name() {
skills.push(name.to_string_lossy().to_string());
}
}
}
}
// Sort alphabetically
skills.sort();
Ok(skills)
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct UpdateInfo {
pub current_version: String,
pub latest_version: String,
pub has_update: bool,
pub release_url: String,
pub release_notes: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct GiteaRelease {
tag_name: String,
html_url: String,
body: Option<String>,
prerelease: bool,
}
#[tauri::command]
pub async fn check_for_updates() -> Result<UpdateInfo, String> {
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const RELEASES_API: &str =
"https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/hikari-desktop/releases";
// Fetch releases from Gitea API
let client = reqwest::Client::new();
let response = client
.get(RELEASES_API)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| format!("Failed to fetch releases: {}", e))?;
if !response.status().is_success() {
return Err(format!("API returned status: {}", response.status()));
}
let text = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
let releases: Vec<GiteaRelease> =
serde_json::from_str(&text).map_err(|e| format!("Failed to parse releases: {}", e))?;
// Find the latest non-prerelease, or fall back to latest prerelease
let latest = releases
.iter()
.find(|r| !r.prerelease)
.or_else(|| releases.first());
let latest = match latest {
Some(r) => r,
None => return Err("No releases found".to_string()),
};
// Parse version strings (remove 'v' prefix if present)
let current = semver::Version::parse(CURRENT_VERSION)
.map_err(|e| format!("Failed to parse current version: {}", e))?;
let latest_tag = latest.tag_name.trim_start_matches('v');
let latest_ver = semver::Version::parse(latest_tag)
.map_err(|e| format!("Failed to parse latest version: {}", e))?;
Ok(UpdateInfo {
current_version: CURRENT_VERSION.to_string(),
latest_version: latest.tag_name.clone(),
has_update: latest_ver > current,
release_url: latest.html_url.clone(),
release_notes: latest.body.clone(),
})
}
+1 -39
View File
@@ -22,9 +22,6 @@ pub struct ClaudeStartOptions {
#[serde(default)] #[serde(default)]
pub skip_greeting: bool, pub skip_greeting: bool,
#[serde(default)]
pub resume_session_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -58,18 +55,6 @@ pub struct HikariConfig {
#[serde(default = "default_notification_volume")] #[serde(default = "default_notification_volume")]
pub notification_volume: f32, pub notification_volume: f32,
#[serde(default)]
pub always_on_top: bool,
#[serde(default = "default_update_checks_enabled")]
pub update_checks_enabled: bool,
#[serde(default)]
pub character_panel_width: Option<u32>,
#[serde(default = "default_font_size")]
pub font_size: u32,
} }
impl Default for HikariConfig { impl Default for HikariConfig {
@@ -85,18 +70,10 @@ impl Default for HikariConfig {
greeting_custom_prompt: None, greeting_custom_prompt: None,
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false,
update_checks_enabled: true,
character_panel_width: None,
font_size: 14,
} }
} }
} }
fn default_update_checks_enabled() -> bool {
true
}
fn default_greeting_enabled() -> bool { fn default_greeting_enabled() -> bool {
true true
} }
@@ -109,10 +86,6 @@ fn default_notification_volume() -> f32 {
0.7 0.7
} }
fn default_font_size() -> u32 {
14
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum Theme { pub enum Theme {
@@ -136,10 +109,6 @@ mod tests {
assert_eq!(config.theme, Theme::Dark); assert_eq!(config.theme, Theme::Dark);
assert!(config.greeting_enabled); assert!(config.greeting_enabled);
assert!(config.greeting_custom_prompt.is_none()); assert!(config.greeting_custom_prompt.is_none());
assert!(!config.always_on_top);
assert!(config.update_checks_enabled);
assert!(config.character_panel_width.is_none());
assert_eq!(config.font_size, 14);
} }
#[test] #[test]
@@ -155,10 +124,6 @@ mod tests {
greeting_custom_prompt: Some("Hello!".to_string()), greeting_custom_prompt: Some("Hello!".to_string()),
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: true,
update_checks_enabled: true,
character_panel_width: Some(400),
font_size: 16,
}; };
let json = serde_json::to_string(&config).unwrap(); let json = serde_json::to_string(&config).unwrap();
@@ -169,10 +134,7 @@ mod tests {
assert_eq!(deserialized.auto_granted_tools, config.auto_granted_tools); assert_eq!(deserialized.auto_granted_tools, config.auto_granted_tools);
assert_eq!(deserialized.theme, Theme::Light); assert_eq!(deserialized.theme, Theme::Light);
assert!(deserialized.greeting_enabled); assert!(deserialized.greeting_enabled);
assert_eq!( assert_eq!(deserialized.greeting_custom_prompt, Some("Hello!".to_string()));
deserialized.greeting_custom_prompt,
Some("Hello!".to_string())
);
} }
#[test] #[test]
+5 -10
View File
@@ -5,18 +5,18 @@ mod config;
mod notifications; mod notifications;
mod stats; mod stats;
mod types; mod types;
mod vbs_notification;
mod windows_toast;
mod wsl_bridge; mod wsl_bridge;
mod wsl_notifications; mod wsl_notifications;
mod vbs_notification;
mod windows_toast;
use bridge_manager::create_shared_bridge_manager;
use commands::load_saved_achievements;
use commands::*; use commands::*;
use notifications::*; use notifications::*;
use bridge_manager::create_shared_bridge_manager;
use commands::load_saved_achievements;
use wsl_notifications::*;
use vbs_notification::*; use vbs_notification::*;
use windows_toast::*; use windows_toast::*;
use wsl_notifications::*;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
@@ -29,7 +29,6 @@ pub fn run() {
.plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_http::init())
.manage(bridge_manager.clone()) .manage(bridge_manager.clone())
.setup(move |app| { .setup(move |app| {
// Initialize the app handle in the bridge manager // Initialize the app handle in the bridge manager
@@ -48,16 +47,12 @@ pub fn run() {
save_config, save_config,
get_usage_stats, get_usage_stats,
load_saved_achievements, load_saved_achievements,
answer_question,
send_windows_notification, send_windows_notification,
send_simple_notification, send_simple_notification,
send_windows_toast, send_windows_toast,
send_notify_send, send_notify_send,
send_wsl_notification, send_wsl_notification,
send_vbs_notification, send_vbs_notification,
validate_directory,
list_skills,
check_for_updates,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+3 -8
View File
@@ -1,5 +1,5 @@
use std::process::Command;
use tauri::command; use tauri::command;
use std::process::Command;
#[command] #[command]
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> { pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
@@ -10,12 +10,7 @@ pub async fn send_notify_send(title: String, body: String) -> Result<(), String>
.arg("--urgency=normal") .arg("--urgency=normal")
.arg("--app-name=Hikari Desktop") .arg("--app-name=Hikari Desktop")
.output() .output()
.map_err(|e| { .map_err(|e| format!("Failed to execute notify-send: {}. Make sure libnotify-bin is installed.", e))?;
format!(
"Failed to execute notify-send: {}. Make sure libnotify-bin is installed.",
e
)
})?;
if !output.status.success() { if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr); let error = String::from_utf8_lossy(&output.stderr);
@@ -98,4 +93,4 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(),
.map_err(|e| format!("Failed to send message: {}", e))?; .map_err(|e| format!("Failed to send message: {}", e))?;
Ok(()) Ok(())
} }
+3 -6
View File
@@ -1,7 +1,7 @@
use crate::achievements::{check_achievements, AchievementProgress};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Instant; use std::time::Instant;
use crate::achievements::{AchievementProgress, check_achievements};
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UsageStats { pub struct UsageStats {
@@ -89,10 +89,7 @@ impl UsageStats {
pub fn increment_tool_usage(&mut self, tool_name: &str) { pub fn increment_tool_usage(&mut self, tool_name: &str) {
*self.tools_usage.entry(tool_name.to_string()).or_insert(0) += 1; *self.tools_usage.entry(tool_name.to_string()).or_insert(0) += 1;
*self *self.session_tools_usage.entry(tool_name.to_string()).or_insert(0) += 1;
.session_tools_usage
.entry(tool_name.to_string())
.or_insert(0) += 1;
} }
pub fn get_session_duration(&mut self) -> u64 { pub fn get_session_duration(&mut self) -> u64 {
@@ -216,4 +213,4 @@ mod tests {
assert_eq!(stats.session_cost_usd, 0.0); assert_eq!(stats.session_cost_usd, 0.0);
assert!(stats.total_cost_usd > 0.0); assert!(stats.total_cost_usd > 0.0);
} }
} }
-18
View File
@@ -216,24 +216,6 @@ pub struct WorkingDirectoryEvent {
pub conversation_id: Option<String>, pub conversation_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuestionOption {
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserQuestionEvent {
pub id: String,
pub question: String,
pub header: Option<String>,
pub options: Vec<QuestionOption>,
pub multi_select: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+9 -6
View File
@@ -1,7 +1,7 @@
use std::io::Write;
use std::process::Command; use std::process::Command;
use tauri::command; use std::io::Write;
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
use tauri::command;
#[command] #[command]
pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> { pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> {
@@ -17,8 +17,8 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
); );
// Create a temporary VBS file // Create a temporary VBS file
let mut temp_file = let mut temp_file = NamedTempFile::new()
NamedTempFile::new().map_err(|e| format!("Failed to create temp file: {}", e))?; .map_err(|e| format!("Failed to create temp file: {}", e))?;
temp_file temp_file
.write_all(vbs_content.as_bytes()) .write_all(vbs_content.as_bytes())
@@ -40,7 +40,10 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
} else if temp_path.starts_with("/tmp/") { } else if temp_path.starts_with("/tmp/") {
// WSL temp files might be in a different location // WSL temp files might be in a different location
// Try to use wslpath to convert // Try to use wslpath to convert
let output = Command::new("wslpath").arg("-w").arg(&temp_path).output(); let output = Command::new("wslpath")
.arg("-w")
.arg(&temp_path)
.output();
if let Ok(result) = output { if let Ok(result) = output {
if result.status.success() { if result.status.success() {
@@ -68,4 +71,4 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
} }
Ok(()) Ok(())
} }
+3 -4
View File
@@ -2,7 +2,7 @@ use tauri::command;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
use windows::{ use windows::{
core::{Result as WindowsResult, HSTRING}, core::{HSTRING, Result as WindowsResult},
Data::Xml::Dom::*, Data::Xml::Dom::*,
UI::Notifications::*, UI::Notifications::*,
}; };
@@ -38,8 +38,7 @@ fn show_toast_notification(title: &str, body: &str) -> WindowsResult<()> {
let toast = ToastNotification::CreateToastNotification(&xml_doc)?; let toast = ToastNotification::CreateToastNotification(&xml_doc)?;
// Create a toast notifier with an application ID // Create a toast notifier with an application ID
let notifier = let notifier = ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from("Hikari Desktop"))?;
ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from("Hikari Desktop"))?;
// Show the notification // Show the notification
notifier.Show(&toast)?; notifier.Show(&toast)?;
@@ -61,4 +60,4 @@ fn escape_xml(text: &str) -> String {
#[command] #[command]
pub async fn send_windows_toast(_title: String, _body: String) -> Result<(), String> { pub async fn send_windows_toast(_title: String, _body: String) -> Result<(), String> {
Err("Windows toast notifications are only available on Windows".to_string()) Err("Windows toast notifications are only available on Windows".to_string())
} }
+96 -342
View File
@@ -8,15 +8,11 @@ use tempfile::NamedTempFile;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
use crate::config::ClaudeStartOptions; use crate::config::ClaudeStartOptions;
use crate::stats::{StatsUpdateEvent, UsageStats}; use crate::stats::{UsageStats, StatsUpdateEvent};
use crate::types::{
CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, OutputEvent,
PermissionPromptEvent, QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent,
WorkingDirectoryEvent,
};
use parking_lot::RwLock; use parking_lot::RwLock;
use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent, ConnectionEvent, SessionEvent, WorkingDirectoryEvent};
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"]; const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"]; const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
@@ -107,6 +103,7 @@ impl WslBridge {
} }
} }
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> { pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
if self.process.is_some() { if self.process.is_some() {
return Err("Process already running".to_string()); return Err("Process already running".to_string());
@@ -118,21 +115,14 @@ impl WslBridge {
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
println!("Loading saved achievements..."); println!("Loading saved achievements...");
let achievements = crate::achievements::load_achievements(&app_clone).await; let achievements = crate::achievements::load_achievements(&app_clone).await;
println!( println!("Loaded {} unlocked achievements", achievements.unlocked.len());
"Loaded {} unlocked achievements",
achievements.unlocked.len()
);
stats.write().achievements = achievements; stats.write().achievements = achievements;
}); });
let working_dir = &options.working_dir; let working_dir = &options.working_dir;
self.working_directory = working_dir.clone(); self.working_directory = working_dir.clone();
emit_connection_status( emit_connection_status(&app, ConnectionStatus::Connecting, self.conversation_id.clone());
&app,
ConnectionStatus::Connecting,
self.conversation_id.clone(),
);
// Create temp file for MCP config if provided // Create temp file for MCP config if provided
let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json { let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json {
@@ -168,19 +158,16 @@ impl WslBridge {
let mut command = if is_wsl { let mut command = if is_wsl {
// Running inside WSL - call claude directly // Running inside WSL - call claude directly
// Try to find claude in common locations since GUI apps may not inherit shell PATH // Try to find claude in common locations since GUI apps may not inherit shell PATH
let claude_path = find_claude_binary().ok_or_else(|| { let claude_path = find_claude_binary()
"Could not find claude binary. Is Claude Code installed?".to_string() .ok_or_else(|| "Could not find claude binary. Is Claude Code installed?".to_string())?;
})?;
eprintln!("[DEBUG] Found claude at: {}", claude_path); eprintln!("[DEBUG] Found claude at: {}", claude_path);
eprintln!("[DEBUG] Working dir: {}", working_dir); eprintln!("[DEBUG] Working dir: {}", working_dir);
let mut cmd = Command::new(&claude_path); let mut cmd = Command::new(&claude_path);
cmd.args([ cmd.args([
"--output-format", "--output-format", "stream-json",
"stream-json", "--input-format", "stream-json",
"--input-format",
"stream-json",
"--verbose", "--verbose",
]); ]);
@@ -208,13 +195,6 @@ impl WslBridge {
cmd.args(["--mcp-config", mcp_path]); cmd.args(["--mcp-config", mcp_path]);
} }
// Add resume flag if session ID provided
if let Some(ref session_id) = options.resume_session_id {
if !session_id.is_empty() {
cmd.args(["--resume", session_id]);
}
}
cmd.current_dir(working_dir); cmd.current_dir(working_dir);
// Set API key as environment variable if specified // Set API key as environment variable if specified
@@ -231,7 +211,10 @@ impl WslBridge {
let mut cmd = Command::new("wsl"); let mut cmd = Command::new("wsl");
// Build the claude command with all arguments // Build the claude command with all arguments
let mut claude_cmd = format!("cd '{}' && ", working_dir); let mut claude_cmd = format!(
"cd '{}' && ",
working_dir
);
// Set API key as environment variable if specified // Set API key as environment variable if specified
if let Some(ref api_key) = options.api_key { if let Some(ref api_key) = options.api_key {
@@ -240,9 +223,7 @@ impl WslBridge {
} }
} }
claude_cmd.push_str( claude_cmd.push_str("claude --output-format stream-json --input-format stream-json --verbose");
"claude --output-format stream-json --input-format stream-json --verbose",
);
// Add model if specified // Add model if specified
if let Some(ref model) = options.model { if let Some(ref model) = options.model {
@@ -270,13 +251,6 @@ impl WslBridge {
claude_cmd.push_str(&format!(" --mcp-config '{}'", mcp_path)); claude_cmd.push_str(&format!(" --mcp-config '{}'", mcp_path));
} }
// Add resume flag if session ID provided
if let Some(ref session_id) = options.resume_session_id {
if !session_id.is_empty() {
claude_cmd.push_str(&format!(" --resume '{}'", session_id));
}
}
// Use bash -lc to load login profile (ensures PATH includes claude) // Use bash -lc to load login profile (ensures PATH includes claude)
cmd.args(["-e", "bash", "-lc", &claude_cmd]); cmd.args(["-e", "bash", "-lc", &claude_cmd]);
@@ -304,8 +278,8 @@ impl WslBridge {
self.stdin = stdin; self.stdin = stdin;
self.process = Some(child); self.process = Some(child);
// Note: We no longer reset stats here - stats persist across reconnects // Reset session stats when starting new session
// Stats are only reset when explicitly disconnecting via stop() self.stats.write().reset_session();
// Load saved achievements // Load saved achievements
let app_handle = app.clone(); let app_handle = app.clone();
@@ -332,11 +306,7 @@ impl WslBridge {
}); });
} }
emit_connection_status( emit_connection_status(&app, ConnectionStatus::Connected, self.conversation_id.clone());
&app,
ConnectionStatus::Connected,
self.conversation_id.clone(),
);
Ok(()) Ok(())
} }
@@ -361,44 +331,7 @@ impl WslBridge {
.write_all(format!("{}\n", json_line).as_bytes()) .write_all(format!("{}\n", json_line).as_bytes())
.map_err(|e| format!("Failed to write to stdin: {}", e))?; .map_err(|e| format!("Failed to write to stdin: {}", e))?;
stdin stdin.flush().map_err(|e| format!("Failed to flush stdin: {}", e))?;
.flush()
.map_err(|e| format!("Failed to flush stdin: {}", e))?;
Ok(())
}
pub fn send_tool_result(
&mut self,
tool_use_id: &str,
result: serde_json::Value,
) -> Result<(), String> {
let stdin = self.stdin.as_mut().ok_or("Process not running")?;
// The content should be a JSON string representation of the result
let content_str = serde_json::to_string(&result).map_err(|e| e.to_string())?;
let input = serde_json::json!({
"type": "user",
"message": {
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": content_str
}]
}
});
let json_line = serde_json::to_string(&input).map_err(|e| e.to_string())?;
stdin
.write_all(format!("{}\n", json_line).as_bytes())
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
stdin
.flush()
.map_err(|e| format!("Failed to flush stdin: {}", e))?;
Ok(()) Ok(())
} }
@@ -419,11 +352,7 @@ impl WslBridge {
// The user will see what session was interrupted // The user will see what session was interrupted
// Emit disconnected status // Emit disconnected status
emit_connection_status( emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone());
app,
ConnectionStatus::Disconnected,
self.conversation_id.clone(),
);
Ok(()) Ok(())
} else { } else {
@@ -439,15 +368,7 @@ impl WslBridge {
self.stdin = None; self.stdin = None;
self.session_id = None; self.session_id = None;
self.mcp_config_file = None; // Temp file is automatically deleted when dropped self.mcp_config_file = None; // Temp file is automatically deleted when dropped
emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone());
// Reset session stats on explicit disconnect
self.stats.write().reset_session();
emit_connection_status(
app,
ConnectionStatus::Disconnected,
self.conversation_id.clone(),
);
} }
pub fn is_running(&self) -> bool { pub fn is_running(&self) -> bool {
@@ -461,6 +382,7 @@ impl WslBridge {
pub fn get_stats(&self) -> UsageStats { pub fn get_stats(&self) -> UsageStats {
self.stats.read().clone() self.stats.read().clone()
} }
} }
impl Default for WslBridge { impl Default for WslBridge {
@@ -469,12 +391,7 @@ impl Default for WslBridge {
} }
} }
fn handle_stdout( fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<RwLock<UsageStats>>, conversation_id: Option<String>) {
stdout: std::process::ChildStdout,
app: AppHandle,
stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>,
) {
let reader = BufReader::new(stdout); let reader = BufReader::new(stdout);
for line in reader.lines() { for line in reader.lines() {
@@ -495,25 +412,18 @@ fn handle_stdout(
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id); emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
} }
fn handle_stderr( fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle, conversation_id: Option<String>) {
stderr: std::process::ChildStderr,
app: AppHandle,
conversation_id: Option<String>,
) {
let reader = BufReader::new(stderr); let reader = BufReader::new(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() => {
let _ = app.emit( let _ = app.emit("claude:output", OutputEvent {
"claude:output", line_type: "error".to_string(),
OutputEvent { content: line,
line_type: "error".to_string(), tool_name: None,
content: line, conversation_id: conversation_id.clone(),
tool_name: None, });
conversation_id: conversation_id.clone(),
},
);
} }
Err(_) => break, Err(_) => break,
_ => {} _ => {}
@@ -521,40 +431,24 @@ fn handle_stderr(
} }
} }
fn process_json_line( fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>>, conversation_id: &Option<String>) -> Result<(), String> {
line: &str,
app: &AppHandle,
stats: &Arc<RwLock<UsageStats>>,
conversation_id: &Option<String>,
) -> Result<(), String> {
let message: ClaudeMessage = serde_json::from_str(line) let message: ClaudeMessage = serde_json::from_str(line)
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?; .map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
match &message { match &message {
ClaudeMessage::System { ClaudeMessage::System { subtype, session_id, cwd, .. } => {
subtype,
session_id,
cwd,
..
} => {
if subtype == "init" { if subtype == "init" {
if let Some(id) = session_id { if let Some(id) = session_id {
let _ = app.emit( let _ = app.emit("claude:session", SessionEvent {
"claude:session", session_id: id.clone(),
SessionEvent { conversation_id: conversation_id.clone(),
session_id: id.clone(), });
conversation_id: conversation_id.clone(),
},
);
} }
if let Some(dir) = cwd { if let Some(dir) = cwd {
let _ = app.emit( let _ = app.emit("claude:cwd", WorkingDirectoryEvent {
"claude:cwd", directory: dir.clone(),
WorkingDirectoryEvent { conversation_id: conversation_id.clone(),
directory: dir.clone(), });
conversation_id: conversation_id.clone(),
},
);
} }
emit_state_change(app, CharacterState::Idle, None, conversation_id.clone()); emit_state_change(app, CharacterState::Idle, None, conversation_id.clone());
} }
@@ -606,15 +500,12 @@ fn process_json_line(
} }
let desc = format_tool_description(name, input); let desc = format_tool_description(name, input);
let _ = app.emit( let _ = app.emit("claude:output", OutputEvent {
"claude:output", line_type: "tool".to_string(),
OutputEvent { content: desc,
line_type: "tool".to_string(), tool_name: Some(name.clone()),
content: desc, conversation_id: conversation_id.clone(),
tool_name: Some(name.clone()), });
conversation_id: conversation_id.clone(),
},
);
} }
ContentBlock::Text { text } => { ContentBlock::Text { text } => {
// Count code blocks in the text // Count code blocks in the text
@@ -623,27 +514,21 @@ fn process_json_line(
stats.write().increment_code_blocks(); stats.write().increment_code_blocks();
} }
let _ = app.emit( let _ = app.emit("claude:output", OutputEvent {
"claude:output", line_type: "assistant".to_string(),
OutputEvent { content: text.clone(),
line_type: "assistant".to_string(), tool_name: None,
content: text.clone(), conversation_id: conversation_id.clone(),
tool_name: None, });
conversation_id: conversation_id.clone(),
},
);
} }
ContentBlock::Thinking { thinking } => { ContentBlock::Thinking { thinking } => {
state = CharacterState::Thinking; state = CharacterState::Thinking;
let _ = app.emit( let _ = app.emit("claude:output", OutputEvent {
"claude:output", line_type: "system".to_string(),
OutputEvent { content: format!("[Thinking] {}", thinking),
line_type: "system".to_string(), tool_name: None,
content: format!("[Thinking] {}", thinking), conversation_id: conversation_id.clone(),
tool_name: None, });
conversation_id: conversation_id.clone(),
},
);
} }
_ => {} _ => {}
} }
@@ -678,13 +563,7 @@ fn process_json_line(
} }
} }
ClaudeMessage::Result { ClaudeMessage::Result { subtype, result, permission_denials, usage: _, .. } => {
subtype,
result,
permission_denials,
usage: _,
..
} => {
let state = if subtype == "success" { let state = if subtype == "success" {
CharacterState::Success CharacterState::Success
} else { } else {
@@ -705,10 +584,9 @@ fn process_json_line(
// Emit achievement events for any newly unlocked achievements // Emit achievement events for any newly unlocked achievements
for achievement_id in &newly_unlocked { for achievement_id in &newly_unlocked {
let info = get_achievement_info(achievement_id); let info = get_achievement_info(achievement_id);
let _ = app.emit( let _ = app.emit("achievement:unlocked", AchievementUnlockedEvent {
"achievement:unlocked", achievement: info,
AchievementUnlockedEvent { achievement: info }, });
);
} }
// Save achievements after unlocking new ones // Save achievements after unlocking new ones
@@ -720,10 +598,7 @@ fn process_json_line(
// Use Tauri's async runtime instead of tokio::spawn // Use Tauri's async runtime instead of tokio::spawn
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
println!("Spawned save task for achievements"); println!("Spawned save task for achievements");
if let Err(e) = if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress).await {
crate::achievements::save_achievements(&app_handle, &achievements_progress)
.await
{
eprintln!("Failed to save achievements: {}", e); eprintln!("Failed to save achievements: {}", e);
} else { } else {
println!("Achievement save task completed successfully"); println!("Achievement save task completed successfully");
@@ -740,107 +615,31 @@ fn process_json_line(
// Only emit error results - success content is already sent via Assistant message // Only emit error results - success content is already sent via Assistant message
if subtype != "success" { if subtype != "success" {
if let Some(text) = result { if let Some(text) = result {
let _ = app.emit( let _ = app.emit("claude:output", OutputEvent {
"claude:output", line_type: "error".to_string(),
OutputEvent { content: text.clone(),
line_type: "error".to_string(), tool_name: None,
content: text.clone(), conversation_id: conversation_id.clone(),
tool_name: None, });
conversation_id: conversation_id.clone(),
},
);
} }
} }
// Check for permission denials and emit prompts for each // Check for permission denials and emit prompts for each
if let Some(denials) = permission_denials { if let Some(denials) = permission_denials {
let mut has_regular_denials = false;
for denial in denials { for denial in denials {
// Special handling for AskUserQuestion tool let description = format_tool_description(&denial.tool_name, &denial.tool_input);
if denial.tool_name == "AskUserQuestion" { let _ = app.emit("claude:permission", PermissionPromptEvent {
if let Some(questions) = denial id: denial.tool_use_id.clone(),
.tool_input tool_name: denial.tool_name.clone(),
.get("questions") tool_input: denial.tool_input.clone(),
.and_then(|q| q.as_array()) description,
{ conversation_id: conversation_id.clone(),
// For now, handle the first question (most common case) });
if let Some(first_question) = questions.first() {
let question_text = first_question
.get("question")
.and_then(|q| q.as_str())
.unwrap_or("Claude has a question for you")
.to_string();
let header = first_question
.get("header")
.and_then(|h| h.as_str())
.map(|s| s.to_string());
let multi_select = first_question
.get("multiSelect")
.and_then(|m| m.as_bool())
.unwrap_or(false);
let options: Vec<QuestionOption> = first_question
.get("options")
.and_then(|opts| opts.as_array())
.map(|opts| {
opts.iter()
.filter_map(|opt| {
let label =
opt.get("label").and_then(|l| l.as_str())?;
let description = opt
.get("description")
.and_then(|d| d.as_str())
.map(|s| s.to_string());
Some(QuestionOption {
label: label.to_string(),
description,
})
})
.collect()
})
.unwrap_or_default();
let _ = app.emit(
"claude:question",
UserQuestionEvent {
id: denial.tool_use_id.clone(),
question: question_text,
header,
options,
multi_select,
conversation_id: conversation_id.clone(),
},
);
}
}
} else {
has_regular_denials = true;
let description =
format_tool_description(&denial.tool_name, &denial.tool_input);
let _ = app.emit(
"claude:permission",
PermissionPromptEvent {
id: denial.tool_use_id.clone(),
tool_name: denial.tool_name.clone(),
tool_input: denial.tool_input.clone(),
description,
conversation_id: conversation_id.clone(),
},
);
}
} }
// Show permission state if there were any denials (questions or regular) // Show permission state if there were denials
if has_regular_denials || !denials.is_empty() { if !denials.is_empty() {
emit_state_change( emit_state_change(app, CharacterState::Permission, None, conversation_id.clone());
app,
CharacterState::Permission,
None,
conversation_id.clone(),
);
return Ok(()); return Ok(());
} }
} }
@@ -853,9 +652,7 @@ fn process_json_line(
stats.write().increment_messages(); stats.write().increment_messages();
// Extract text content from the message // Extract text content from the message
let message_text = message let message_text = message.content.iter()
.content
.iter()
.filter_map(|block| match block { .filter_map(|block| match block {
crate::types::ContentBlock::Text { text } => Some(text.clone()), crate::types::ContentBlock::Text { text } => Some(text.clone()),
_ => None, _ => None,
@@ -885,10 +682,9 @@ fn process_json_line(
for achievement_id in &newly_unlocked { for achievement_id in &newly_unlocked {
println!("User message unlocked achievement: {:?}", achievement_id); println!("User message unlocked achievement: {:?}", achievement_id);
let info = get_achievement_info(achievement_id); let info = get_achievement_info(achievement_id);
let _ = app.emit( let _ = app.emit("achievement:unlocked", AchievementUnlockedEvent {
"achievement:unlocked", achievement: info,
AchievementUnlockedEvent { achievement: info }, });
);
} }
// Save achievements after unlocking new ones // Save achievements after unlocking new ones
@@ -897,10 +693,7 @@ fn process_json_line(
let app_handle = app.clone(); let app_handle = app.clone();
let achievements_progress = stats.read().achievements.clone(); let achievements_progress = stats.read().achievements.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
if let Err(e) = if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress).await {
crate::achievements::save_achievements(&app_handle, &achievements_progress)
.await
{
eprintln!("Failed to save achievements: {}", e); eprintln!("Failed to save achievements: {}", e);
} else { } else {
println!("Achievements saved after user message"); println!("Achievements saved after user message");
@@ -975,36 +768,15 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
} }
} }
fn emit_state_change( fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option<String>, conversation_id: Option<String>) {
app: &AppHandle, let _ = app.emit("claude:state", StateChangeEvent { state, tool_name, conversation_id });
state: CharacterState,
tool_name: Option<String>,
conversation_id: Option<String>,
) {
let _ = app.emit(
"claude:state",
StateChangeEvent {
state,
tool_name,
conversation_id,
},
);
} }
fn emit_connection_status( fn emit_connection_status(app: &AppHandle, status: ConnectionStatus, conversation_id: Option<String>) {
app: &AppHandle, let _ = app.emit("claude:connection", ConnectionEvent { status, conversation_id });
status: ConnectionStatus,
conversation_id: Option<String>,
) {
let _ = app.emit(
"claude:connection",
ConnectionEvent {
status,
conversation_id,
},
);
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1014,36 +786,21 @@ mod tests {
assert!(matches!(get_tool_state("Read"), CharacterState::Searching)); assert!(matches!(get_tool_state("Read"), CharacterState::Searching));
assert!(matches!(get_tool_state("Glob"), CharacterState::Searching)); assert!(matches!(get_tool_state("Glob"), CharacterState::Searching));
assert!(matches!(get_tool_state("Grep"), CharacterState::Searching)); assert!(matches!(get_tool_state("Grep"), CharacterState::Searching));
assert!(matches!( assert!(matches!(get_tool_state("WebSearch"), CharacterState::Searching));
get_tool_state("WebSearch"), assert!(matches!(get_tool_state("WebFetch"), CharacterState::Searching));
CharacterState::Searching
));
assert!(matches!(
get_tool_state("WebFetch"),
CharacterState::Searching
));
} }
#[test] #[test]
fn test_get_tool_state_coding_tools() { fn test_get_tool_state_coding_tools() {
assert!(matches!(get_tool_state("Edit"), CharacterState::Coding)); assert!(matches!(get_tool_state("Edit"), CharacterState::Coding));
assert!(matches!(get_tool_state("Write"), CharacterState::Coding)); assert!(matches!(get_tool_state("Write"), CharacterState::Coding));
assert!(matches!( assert!(matches!(get_tool_state("NotebookEdit"), CharacterState::Coding));
get_tool_state("NotebookEdit"),
CharacterState::Coding
));
} }
#[test] #[test]
fn test_get_tool_state_mcp_tools() { fn test_get_tool_state_mcp_tools() {
assert!(matches!( assert!(matches!(get_tool_state("mcp__github__create_issue"), CharacterState::Mcp));
get_tool_state("mcp__github__create_issue"), assert!(matches!(get_tool_state("mcp__notion__search"), CharacterState::Mcp));
CharacterState::Mcp
));
assert!(matches!(
get_tool_state("mcp__notion__search"),
CharacterState::Mcp
));
} }
#[test] #[test]
@@ -1053,10 +810,7 @@ mod tests {
#[test] #[test]
fn test_get_tool_state_unknown() { fn test_get_tool_state_unknown() {
assert!(matches!( assert!(matches!(get_tool_state("SomeUnknownTool"), CharacterState::Typing));
get_tool_state("SomeUnknownTool"),
CharacterState::Typing
));
assert!(matches!(get_tool_state("Bash"), CharacterState::Typing)); assert!(matches!(get_tool_state("Bash"), CharacterState::Typing));
} }
+1 -1
View File
@@ -81,4 +81,4 @@ $notifier.Show($toast)
// If all methods fail, return an error // If all methods fail, return an error
Err("All WSL notification methods failed".to_string()) Err("All WSL notification methods failed".to_string())
} }
+1 -1
View File
@@ -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": "0.3.0", "version": "0.1.0",
"identifier": "com.naomi.hikari-desktop", "identifier": "com.naomi.hikari-desktop",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
-22
View File
@@ -6,7 +6,6 @@
--bg-secondary: #16213e; --bg-secondary: #16213e;
--bg-terminal: #0f0f1a; --bg-terminal: #0f0f1a;
--bg-hover: #2a2a4a; --bg-hover: #2a2a4a;
--bg-code: #1e1e2e;
--accent-primary: #e94560; --accent-primary: #e94560;
--accent-secondary: #ff6b9d; --accent-secondary: #ff6b9d;
--text-primary: #ffffff; --text-primary: #ffffff;
@@ -19,16 +18,6 @@
--terminal-tool: #c084fc; --terminal-tool: #c084fc;
--terminal-tool-name: #ddd6fe; --terminal-tool-name: #ddd6fe;
--terminal-error: #f87171; --terminal-error: #f87171;
/* Syntax highlighting colors (dark) */
--hljs-keyword: #f472b6;
--hljs-string: #a3e635;
--hljs-number: #fbbf24;
--hljs-comment: #6b7280;
--hljs-function: #c084fc;
--hljs-type: #22d3ee;
--hljs-variable: #fb923c;
--hljs-meta: #94a3b8;
} }
[data-theme="light"] { [data-theme="light"] {
@@ -36,7 +25,6 @@
--bg-secondary: #ffffff; --bg-secondary: #ffffff;
--bg-terminal: #f1f3f4; --bg-terminal: #f1f3f4;
--bg-hover: #e8e8e8; --bg-hover: #e8e8e8;
--bg-code: #f5f5f5;
--accent-primary: #e94560; --accent-primary: #e94560;
--accent-secondary: #ff6b9d; --accent-secondary: #ff6b9d;
--text-primary: #1a1a2e; --text-primary: #1a1a2e;
@@ -49,16 +37,6 @@
--terminal-tool: #7c3aed; --terminal-tool: #7c3aed;
--terminal-tool-name: #8b5cf6; --terminal-tool-name: #8b5cf6;
--terminal-error: #dc2626; --terminal-error: #dc2626;
/* Syntax highlighting colors (light) */
--hljs-keyword: #d946ef;
--hljs-string: #16a34a;
--hljs-number: #d97706;
--hljs-comment: #9ca3af;
--hljs-function: #7c3aed;
--hljs-type: #0891b2;
--hljs-variable: #ea580c;
--hljs-meta: #64748b;
} }
html, html,
-280
View File
@@ -1,280 +0,0 @@
import { get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import { setSkipNextGreeting } from "$lib/tauri";
import { searchState } from "$lib/stores/search";
export interface SlashCommand {
name: string;
description: string;
usage: string;
execute: (args: string) => Promise<void> | void;
}
async function changeDirectory(path: string): Promise<void> {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
claudeStore.addLine("error", "No active conversation");
return;
}
if (!path.trim()) {
const currentDir = get(claudeStore.currentWorkingDirectory);
claudeStore.addLine("system", `Current directory: ${currentDir}`);
return;
}
try {
characterState.setState("thinking");
claudeStore.addLine("system", `Changing directory to: ${path}`);
const currentDir = get(claudeStore.currentWorkingDirectory);
const validatedPath = await invoke<string>("validate_directory", { path, currentDir });
// Capture conversation history before disconnecting
const conversationHistory = claudeStore.getConversationHistory();
await invoke("stop_claude", { conversationId });
// Wait for clean shutdown
await new Promise((resolve) => setTimeout(resolve, 500));
claudeStore.setWorkingDirectory(validatedPath);
setSkipNextGreeting(true);
await invoke("start_claude", {
conversationId,
options: {
working_dir: validatedPath,
},
});
// Wait for connection to establish
await new Promise((resolve) => setTimeout(resolve, 1000));
// Restore context if there was conversation history
if (conversationHistory) {
const contextMessage = `[CONTEXT RESTORATION]
I just changed the working directory from ${currentDir} to ${validatedPath}. Here's our conversation so far:
${conversationHistory}
Please continue where we left off. You are now operating in the new directory.`;
await invoke("send_prompt", {
conversationId,
message: contextMessage,
});
}
claudeStore.addLine("system", `Changed directory to: ${validatedPath}`);
characterState.setState("idle");
} catch (error) {
claudeStore.addLine("error", `Failed to change directory: ${error}`);
characterState.setTemporaryState("error", 3000);
}
}
async function startNewConversation(): Promise<void> {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
claudeStore.addLine("error", "No active conversation");
return;
}
try {
const workingDir = await invoke<string>("get_working_directory", {
conversationId,
});
claudeStore.addLine("system", "Starting new conversation...");
characterState.setState("thinking");
await invoke("interrupt_claude", { conversationId });
claudeStore.clearTerminal();
setSkipNextGreeting(true);
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDir,
},
});
claudeStore.addLine("system", "New conversation started!");
characterState.setState("idle");
} catch (error) {
claudeStore.addLine("error", `Failed to start new conversation: ${error}`);
characterState.setTemporaryState("error", 3000);
}
}
export const slashCommands: SlashCommand[] = [
{
name: "cd",
description: "Change the working directory",
usage: "/cd <path>",
execute: changeDirectory,
},
{
name: "clear",
description: "Clear the terminal display (keeps conversation context)",
usage: "/clear",
execute: () => {
claudeStore.clearTerminal();
claudeStore.addLine("system", "Terminal cleared");
},
},
{
name: "new",
description: "Start a fresh conversation (resets context)",
usage: "/new",
execute: startNewConversation,
},
{
name: "help",
description: "Show available slash commands",
usage: "/help",
execute: () => {
const helpText = slashCommands
.map((cmd) => ` ${cmd.usage.padEnd(12)} - ${cmd.description}`)
.join("\n");
claudeStore.addLine("system", `Available commands:\n${helpText}`);
},
},
{
name: "search",
description: "Search within the conversation (use /search to clear)",
usage: "/search [query]",
execute: (args: string) => {
if (!args.trim()) {
searchState.clear();
claudeStore.addLine("system", "Search cleared");
return;
}
searchState.setQuery(args.trim());
claudeStore.addLine("system", `Searching for: "${args.trim()}"`);
},
},
{
name: "summarise",
description: "Get a summary of the entire conversation",
usage: "/summarise",
execute: async () => {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
claudeStore.addLine("error", "No active conversation");
return;
}
try {
claudeStore.addLine("system", "Requesting conversation summary...");
await invoke("send_prompt", {
conversationId,
message:
"Please provide a comprehensive summary of our entire conversation so far, including the key topics we've discussed, decisions made, and any important context.",
});
} catch (error) {
claudeStore.addLine("error", `Failed to request summary: ${error}`);
}
},
},
{
name: "skill",
description: "Invoke a Claude Code skill from ~/.claude/skills/",
usage: "/skill [name] [data]",
execute: async (args: string) => {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
claudeStore.addLine("error", "No active conversation");
return;
}
const parts = args.trim().split(/\s+/);
const skillName = parts[0];
const skillData = parts.slice(1).join(" ");
// If no skill name provided, list available skills
if (!skillName) {
try {
const skills = await invoke<string[]>("list_skills");
if (skills.length === 0) {
claudeStore.addLine(
"system",
"No skills found in ~/.claude/skills/\nCreate a skill by adding a folder with a SKILL.md file."
);
} else {
const skillList = skills.map((s) => `${s}`).join("\n");
claudeStore.addLine(
"system",
`Available skills:\n${skillList}\n\nUsage: /skill <skill-name> [data]`
);
}
} catch (error) {
claudeStore.addLine("error", `Failed to list skills: ${error}`);
}
return;
}
try {
claudeStore.addLine("system", `Invoking skill: ${skillName}`);
characterState.setState("thinking");
const message = skillData
? `Please run the /${skillName} skill with the following data:\n\n${skillData}`
: `Please run the /${skillName} skill.`;
await invoke("send_prompt", {
conversationId,
message,
});
} catch (error) {
claudeStore.addLine("error", `Failed to invoke skill: ${error}`);
characterState.setTemporaryState("error", 3000);
}
},
},
];
export function parseSlashCommand(input: string): {
command: SlashCommand | null;
args: string;
} {
const trimmed = input.trim();
if (!trimmed.startsWith("/")) {
return { command: null, args: "" };
}
const parts = trimmed.slice(1).split(/\s+/);
const commandName = parts[0]?.toLowerCase();
const args = parts.slice(1).join(" ");
const command = slashCommands.find((cmd) => cmd.name.toLowerCase() === commandName);
return { command: command || null, args };
}
export function getMatchingCommands(input: string): SlashCommand[] {
const trimmed = input.trim();
if (!trimmed.startsWith("/")) {
return [];
}
const partial = trimmed.slice(1).toLowerCase();
if (partial === "") {
return slashCommands;
}
return slashCommands.filter((cmd) => cmd.name.toLowerCase().startsWith(partial));
}
export function isSlashCommand(input: string): boolean {
return input.trim().startsWith("/");
}
+18 -18
View File
@@ -34,18 +34,16 @@
<div <div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-md w-full p-6" class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-md w-full p-6"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog" role="dialog"
aria-labelledby="about-title" aria-labelledby="about-title"
tabindex="-1"
> >
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 id="about-title" class="text-xl font-semibold text-[var(--text-primary)]"> <h2 id="about-title" class="text-xl font-semibold text-gray-100">
About Hikari Desktop About Hikari Desktop
</h2> </h2>
<button <button
onclick={onClose} onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors" class="p-1 text-gray-500 hover:text-gray-300 transition-colors"
aria-label="Close" aria-label="Close"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -61,16 +59,16 @@
<div class="space-y-4 text-sm"> <div class="space-y-4 text-sm">
<div> <div>
<h3 class="font-medium text-[var(--text-primary)] mb-2">What is Hikari Desktop?</h3> <h3 class="font-medium text-gray-200 mb-2">What is Hikari Desktop?</h3>
<p class="text-[var(--text-secondary)]"> <p class="text-gray-400">
Hikari Desktop is an AI-powered desktop assistant that brings Claude directly to your Hikari Desktop is an AI-powered desktop assistant that brings Claude directly to your desktop.
desktop. Built with love using Tauri, Svelte, and Rust for a fast, native experience. Built with love using Tauri, Svelte, and Rust for a fast, native experience.
</p> </p>
</div> </div>
<div> <div>
<h3 class="font-medium text-[var(--text-primary)] mb-2">Version</h3> <h3 class="font-medium text-gray-200 mb-2">Version</h3>
<p class="text-[var(--text-secondary)] mb-1"> <p class="text-gray-400 mb-1">
{appVersion || "Loading..."} {appVersion || "Loading..."}
</p> </p>
<button <button
@@ -82,7 +80,7 @@
</div> </div>
<div> <div>
<h3 class="font-medium text-[var(--text-primary)] mb-2">Source Code</h3> <h3 class="font-medium text-gray-200 mb-2">Source Code</h3>
<button <button
onclick={() => openUrl(links.source)} onclick={() => openUrl(links.source)}
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline" class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
@@ -92,8 +90,10 @@
</div> </div>
<div> <div>
<h3 class="font-medium text-[var(--text-primary)] mb-2">Support & Community</h3> <h3 class="font-medium text-gray-200 mb-2">Support & Community</h3>
<p class="text-[var(--text-secondary)] mb-1">Found a bug or have a suggestion?</p> <p class="text-gray-400 mb-1">
Found a bug or have a suggestion?
</p>
<button <button
onclick={() => openUrl(links.discord)} onclick={() => openUrl(links.discord)}
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline" class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
@@ -103,7 +103,7 @@
</div> </div>
<div> <div>
<h3 class="font-medium text-[var(--text-primary)] mb-2">Built with 💕 by</h3> <h3 class="font-medium text-gray-200 mb-2">Built with 💕 by</h3>
<button <button
onclick={() => openUrl(links.website)} onclick={() => openUrl(links.website)}
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline" class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
@@ -113,8 +113,8 @@
</div> </div>
<div> <div>
<h3 class="font-medium text-[var(--text-primary)] mb-2">License</h3> <h3 class="font-medium text-gray-200 mb-2">License</h3>
<p class="text-[var(--text-secondary)] mb-1"> <p class="text-gray-400 mb-1">
This project is open source and available under our license terms. This project is open source and available under our license terms.
</p> </p>
<button <button
@@ -126,7 +126,7 @@
</div> </div>
<div class="pt-4 mt-4 border-t border-[var(--border-color)]"> <div class="pt-4 mt-4 border-t border-[var(--border-color)]">
<p class="text-xs text-[var(--text-tertiary)] text-center"> <p class="text-xs text-gray-500 text-center">
Copyright © {new Date().getFullYear()} Naomi Carrigan. All rights reserved. Copyright © {new Date().getFullYear()} Naomi Carrigan. All rights reserved.
</p> </p>
</div> </div>
@@ -150,4 +150,4 @@
transform: scale(1); transform: scale(1);
} }
} }
</style> </style>
+11 -15
View File
@@ -57,34 +57,30 @@
} }
</script> </script>
<div <div class="anime-girl-container flex flex-col items-center justify-end h-full p-4">
class="anime-girl-container flex flex-col items-center justify-between h-full p-4 overflow-hidden" <div class="character-frame relative {getBackgroundGlow()} w-full max-w-md">
> <div class="sprite-container {getAnimationClass()}">
<div
class="character-frame relative {getBackgroundGlow()} flex-1 flex items-center justify-center min-h-0"
>
<div class="sprite-container {getAnimationClass()} h-full flex items-center justify-center">
<img <img
src="/sprites/{info.spriteFile}" src="/sprites/{info.spriteFile}"
alt="Hikari - {info.label}" alt="Hikari - {info.label}"
class="character-sprite h-full w-auto max-w-full object-contain" class="character-sprite w-full h-auto object-contain"
onerror={(e) => { onerror={(e) => {
const target = e.currentTarget as HTMLImageElement; const target = e.currentTarget as HTMLImageElement;
target.src = "/sprites/placeholder.svg"; target.src = "/sprites/placeholder.svg";
}} }}
/> />
</div> </div>
</div>
<div class="state-indicator mt-2"> <div class="state-indicator absolute -bottom-2 left-1/2 transform -translate-x-1/2">
<div <div
class="px-3 py-1 rounded-full text-xs font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[var(--accent-primary)]" class="px-3 py-1 rounded-full text-xs font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[var(--accent-primary)]"
> >
{info.label} {info.label}
</div>
</div> </div>
</div> </div>
<div class="speech-bubble mt-2 max-w-xs flex-shrink-0"> <div class="speech-bubble mt-4 max-w-xs">
<div <div
class="relative bg-[var(--bg-secondary)] rounded-lg px-4 py-2 border border-[var(--border-color)]" class="relative bg-[var(--bg-secondary)] rounded-lg px-4 py-2 border border-[var(--border-color)]"
> >
@@ -1,107 +0,0 @@
<script lang="ts">
interface Props {
isOpen: boolean;
tabName: string;
onConfirm: () => void;
onCancel: () => void;
}
const { isOpen, tabName, onConfirm, onCancel }: Props = $props();
function handleKeydown(event: KeyboardEvent) {
if (!isOpen) return;
if (event.key === "Enter") {
event.preventDefault();
onConfirm();
} else if (event.key === "Escape") {
event.preventDefault();
onCancel();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onCancel}
role="button"
tabindex="0"
onkeydown={(e) => e.key === " " && onCancel()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-md w-full"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="confirm-title"
aria-describedby="confirm-message"
tabindex="-1"
>
<div class="p-6">
<div class="flex items-start gap-4">
<div
class="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center flex-shrink-0"
>
<svg
class="w-6 h-6 text-yellow-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div class="flex-1">
<h3 id="confirm-title" class="text-lg font-semibold text-[var(--text-primary)] mb-1">
Close Connected Tab?
</h3>
<p id="confirm-message" class="text-sm text-[var(--text-secondary)]">
The tab "{tabName}" is currently connected to Claude. Are you sure you want to close
it? This will disconnect the session.
</p>
</div>
</div>
<div class="flex gap-3 mt-6 justify-end">
<button
onclick={onCancel}
class="px-4 py-2 text-sm font-medium text-gray-300 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Cancel
</button>
<button
onclick={onConfirm}
class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
>
Close Tab
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
</style>
+38 -135
View File
@@ -1,15 +1,6 @@
<script lang="ts"> <script lang="ts">
import { import { configStore, type HikariConfig, type Theme } from "$lib/stores/config";
configStore,
type HikariConfig,
type Theme,
applyFontSize,
MIN_FONT_SIZE,
MAX_FONT_SIZE,
DEFAULT_FONT_SIZE,
} from "$lib/stores/config";
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { getCurrentWindow } from "@tauri-apps/api/window";
let config: HikariConfig = $state({ let config: HikariConfig = $state({
model: null, model: null,
@@ -22,10 +13,6 @@
greeting_custom_prompt: null, greeting_custom_prompt: null,
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false,
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
}); });
let isOpen = $state(false); let isOpen = $state(false);
@@ -74,7 +61,6 @@
saveError = null; saveError = null;
try { try {
await configStore.saveConfig(config); await configStore.saveConfig(config);
configStore.closeSidebar();
} catch { } catch {
// Error is handled by the store // Error is handled by the store
} finally { } finally {
@@ -109,13 +95,6 @@
function importFromSession() { function importFromSession() {
config.auto_granted_tools = [...new Set([...config.auto_granted_tools, ...grantedTools])]; config.auto_granted_tools = [...new Set([...config.auto_granted_tools, ...grantedTools])];
} }
async function handleAlwaysOnTopChange(enabled: boolean) {
config.always_on_top = enabled;
const window = getCurrentWindow();
await window.setAlwaysOnTop(enabled);
await configStore.updateConfig({ always_on_top: enabled });
}
</script> </script>
<!-- Backdrop --> <!-- Backdrop -->
@@ -142,7 +121,7 @@
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Settings</h2> <h2 class="text-lg font-semibold text-[var(--text-primary)]">Settings</h2>
<button <button
onclick={configStore.closeSidebar} onclick={configStore.closeSidebar}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors" class="p-1 text-gray-400 hover:text-white transition-colors"
aria-label="Close settings" aria-label="Close settings"
> >
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -170,7 +149,7 @@
<!-- Model Selection --> <!-- Model Selection -->
<div class="mb-4"> <div class="mb-4">
<label for="model" class="block text-sm text-[var(--text-secondary)] mb-1">Model</label> <label for="model" class="block text-sm text-gray-400 mb-1">Model</label>
<select <select
id="model" id="model"
bind:value={config.model} bind:value={config.model}
@@ -184,8 +163,8 @@
<!-- API Key --> <!-- API Key -->
<div class="mb-4"> <div class="mb-4">
<label for="api-key" class="block text-sm text-[var(--text-secondary)] mb-1"> <label for="api-key" class="block text-sm text-gray-400 mb-1">
API Key <span class="text-[var(--text-tertiary)]">(optional override)</span> API Key <span class="text-gray-600">(optional override)</span>
</label> </label>
<div class="relative"> <div class="relative">
<input <input
@@ -198,7 +177,7 @@
<button <button
type="button" type="button"
onclick={() => (showApiKey = !showApiKey)} onclick={() => (showApiKey = !showApiKey)}
class="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]" class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
aria-label={showApiKey ? "Hide API key" : "Show API key"} aria-label={showApiKey ? "Hide API key" : "Show API key"}
> >
{#if showApiKey} {#if showApiKey}
@@ -232,7 +211,7 @@
<!-- Custom Instructions --> <!-- Custom Instructions -->
<div class="mb-4"> <div class="mb-4">
<label for="instructions" class="block text-sm text-[var(--text-secondary)] mb-1" <label for="instructions" class="block text-sm text-gray-400 mb-1"
>Custom Instructions</label >Custom Instructions</label
> >
<textarea <textarea
@@ -259,9 +238,9 @@
bind:checked={config.greeting_enabled} bind:checked={config.greeting_enabled}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]" class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
/> />
<span class="text-sm text-[var(--text-primary)]">Send greeting on connect</span> <span class="text-sm text-gray-300">Send greeting on connect</span>
</label> </label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7"> <p class="text-xs text-gray-500 mt-1 ml-7">
Automatically greet you when a session starts with time-based messages Automatically greet you when a session starts with time-based messages
</p> </p>
</div> </div>
@@ -269,8 +248,8 @@
<!-- Custom Greeting Prompt --> <!-- Custom Greeting Prompt -->
{#if config.greeting_enabled} {#if config.greeting_enabled}
<div class="mb-4"> <div class="mb-4">
<label for="greeting-prompt" class="block text-sm text-[var(--text-secondary)] mb-1"> <label for="greeting-prompt" class="block text-sm text-gray-400 mb-1">
Custom Greeting Prompt <span class="text-[var(--text-tertiary)]">(optional)</span> Custom Greeting Prompt <span class="text-gray-600">(optional)</span>
</label> </label>
<textarea <textarea
id="greeting-prompt" id="greeting-prompt"
@@ -289,8 +268,8 @@
MCP Servers MCP Servers
</h3> </h3>
<div class="mb-2"> <div class="mb-2">
<label for="mcp-config" class="block text-sm text-[var(--text-secondary)] mb-1"> <label for="mcp-config" class="block text-sm text-gray-400 mb-1">
Server Configuration <span class="text-[var(--text-tertiary)]">(JSON)</span> Server Configuration <span class="text-gray-600">(JSON)</span>
</label> </label>
<textarea <textarea
id="mcp-config" id="mcp-config"
@@ -307,14 +286,14 @@
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3"> <h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Auto-Granted Tools Auto-Granted Tools
</h3> </h3>
<p class="text-xs text-[var(--text-tertiary)] mb-3"> <p class="text-xs text-gray-500 mb-3">
These tools will be pre-approved for every session (no permission prompts). These tools will be pre-approved for every session (no permission prompts).
</p> </p>
<!-- Common tools checkboxes --> <!-- Common tools checkboxes -->
<div class="grid grid-cols-2 gap-2 mb-3"> <div class="grid grid-cols-2 gap-2 mb-3">
{#each commonTools as tool (tool)} {#each commonTools as tool (tool)}
<label class="flex items-center gap-2 text-sm text-[var(--text-primary)] cursor-pointer"> <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={config.auto_granted_tools.includes(tool)} checked={config.auto_granted_tools.includes(tool)}
@@ -330,7 +309,7 @@
{#if grantedTools.length > 0} {#if grantedTools.length > 0}
<div class="mb-3"> <div class="mb-3">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<span class="text-xs text-[var(--text-tertiary)]">Session-granted tools:</span> <span class="text-xs text-gray-500">Session-granted tools:</span>
<button <button
onclick={importFromSession} onclick={importFromSession}
class="text-xs text-[var(--accent-primary)] hover:text-[var(--accent-secondary)] transition-colors" class="text-xs text-[var(--accent-primary)] hover:text-[var(--accent-secondary)] transition-colors"
@@ -353,7 +332,7 @@
<!-- Custom tools list --> <!-- Custom tools list -->
{#if config.auto_granted_tools.filter((t) => !commonTools.includes(t)).length > 0} {#if config.auto_granted_tools.filter((t) => !commonTools.includes(t)).length > 0}
<div class="mb-3"> <div class="mb-3">
<span class="text-xs text-[var(--text-tertiary)] block mb-2">Custom tools:</span> <span class="text-xs text-gray-500 block mb-2">Custom tools:</span>
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#each config.auto_granted_tools.filter((t) => !commonTools.includes(t)) as tool (tool)} {#each config.auto_granted_tools.filter((t) => !commonTools.includes(t)) as tool (tool)}
<span <span
@@ -362,7 +341,7 @@
{tool} {tool}
<button <button
onclick={() => removeTool(tool)} onclick={() => removeTool(tool)}
class="text-[var(--text-tertiary)] hover:text-red-400" class="text-gray-500 hover:text-red-400"
aria-label="Remove {tool}" aria-label="Remove {tool}"
> >
× ×
@@ -397,99 +376,23 @@
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3"> <h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Appearance Appearance
</h3> </h3>
<div class="flex gap-2">
<!-- Theme Selection --> <button
<div class="mb-4"> onclick={() => handleThemeChange("dark")}
<label class="block text-sm text-[var(--text-secondary)] mb-2">Theme</label> class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'dark'
<div class="flex gap-2"> ? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
<button : 'bg-[var(--bg-primary)] border-[var(--border-color)] text-gray-400 hover:border-[var(--accent-primary)]'}"
onclick={() => handleThemeChange("dark")} >
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'dark' Dark
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white' </button>
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}" <button
> onclick={() => handleThemeChange("light")}
Dark class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'light'
</button> ? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
<button : 'bg-[var(--bg-primary)] border-[var(--border-color)] text-gray-400 hover:border-[var(--accent-primary)]'}"
onclick={() => handleThemeChange("light")} >
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'light' Light
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white' </button>
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
>
Light
</button>
</div>
</div>
<!-- Font Size -->
<div class="mb-4">
<label for="font-size" class="block text-sm text-[var(--text-secondary)] mb-2">
Terminal Font Size
</label>
<div class="flex items-center gap-3">
<input
id="font-size"
type="range"
bind:value={config.font_size}
oninput={() => applyFontSize(config.font_size)}
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
step="1"
class="flex-1 h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
/>
<span class="text-sm text-gray-300 w-12 text-right">{config.font_size}px</span>
<button
onclick={() => {
config.font_size = DEFAULT_FONT_SIZE;
applyFontSize(DEFAULT_FONT_SIZE);
}}
class="px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:border-[var(--accent-primary)] text-[var(--text-secondary)] transition-colors"
title="Reset to default (14px)"
>
Reset
</button>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">
Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset
</p>
</div>
</section>
<!-- Window Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Window
</h3>
<!-- Always on Top Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={config.always_on_top}
onchange={(e) => handleAlwaysOnTopChange(e.currentTarget.checked)}
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)]">Always on top</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Keep the window above other windows
</p>
</div>
<!-- Update Checks Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.update_checks_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)]">Check for updates on startup</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Notify when a new version is available
</p>
</div> </div>
</section> </section>
@@ -507,13 +410,13 @@
bind:checked={config.notifications_enabled} bind:checked={config.notifications_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" class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/> />
<span class="text-sm text-[var(--text-primary)]">Enable sound notifications</span> <span class="text-sm text-gray-300">Enable sound notifications</span>
</label> </label>
</div> </div>
<!-- Volume Control --> <!-- Volume Control -->
<div class="mb-4"> <div class="mb-4">
<label for="notification-volume" class="block text-sm text-[var(--text-secondary)] mb-2"> <label for="notification-volume" class="block text-sm text-gray-400 mb-2">
Notification Volume Notification Volume
</label> </label>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -533,7 +436,7 @@
</div> </div>
</div> </div>
<div class="text-xs text-[var(--text-tertiary)]"> <div class="text-xs text-gray-500">
Sound notifications will play when I complete tasks, encounter errors, or need permissions. Sound notifications will play when I complete tasks, encounter errors, or need permissions.
</div> </div>
</section> </section>
+59 -76
View File
@@ -3,35 +3,49 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { Conversation } from "$lib/stores/conversations"; import type { Conversation } from "$lib/stores/conversations";
import { SvelteMap } from "svelte/reactivity"; import { SvelteMap } from "svelte/reactivity";
import CloseTabConfirmModal from "./CloseTabConfirmModal.svelte";
// Use store subscriptions with $ syntax let conversations: Map<string, Conversation> = new Map();
const conversations = $derived(claudeStore.conversations); let activeConversationId: string | null = null;
const activeConversationId = $derived(claudeStore.activeConversationId); let editingTabId: string | null = null;
let editingName = "";
let editingTabId = $state<string | null>(null); // Track which conversation actually has the Claude connection
let editingName = $state(""); let connectedConversationId: string | null = null;
// Track last seen message count for each conversation // Track last seen message count for each conversation
let lastSeenMessageCount = new SvelteMap<string, number>(); let lastSeenMessageCount = new SvelteMap<string, number>();
// Confirmation modal state claudeStore.conversations.subscribe((convs) => {
let showConfirmModal = $state(false); conversations = convs;
let tabToClose = $state<string | null>(null);
let tabToCloseName = $state("");
// Update last seen count when active conversation changes // Update the last seen count for the active conversation
$effect(() => { if (activeConversationId) {
if ($activeConversationId) { const activeConv = convs.get(activeConversationId);
const activeConv = $conversations.get($activeConversationId);
if (activeConv) { if (activeConv) {
lastSeenMessageCount.set($activeConversationId, activeConv.terminalLines.length); lastSeenMessageCount.set(activeConversationId, activeConv.terminalLines.length);
// Trigger reactivity
lastSeenMessageCount = lastSeenMessageCount;
} }
} }
}); });
claudeStore.activeConversationId.subscribe((id) => {
activeConversationId = id;
});
// Find the connected conversation
$: {
let foundConnected = false;
for (const [id, conv] of conversations) {
if (conv.connectionStatus === "connected" || conv.connectionStatus === "connecting") {
connectedConversationId = id;
foundConnected = true;
break;
}
}
if (!foundConnected) {
connectedConversationId = null;
}
}
function createNewTab() { function createNewTab() {
claudeStore.createConversation(); claudeStore.createConversation();
} }
@@ -43,7 +57,7 @@
await claudeStore.switchConversation(id); await claudeStore.switchConversation(id);
// Mark messages as seen when switching to this tab // Mark messages as seen when switching to this tab
const conv = $conversations.get(id); const conv = conversations.get(id);
if (conv) { if (conv) {
lastSeenMessageCount.set(id, conv.terminalLines.length); lastSeenMessageCount.set(id, conv.terminalLines.length);
// Trigger reactivity // Trigger reactivity
@@ -53,35 +67,11 @@
function deleteTab(id: string, event: MouseEvent) { function deleteTab(id: string, event: MouseEvent) {
event.stopPropagation(); event.stopPropagation();
if ($conversations.size > 1) { if (conversations.size > 1) {
const conversation = $conversations.get(id); claudeStore.deleteConversation(id);
if (conversation && conversation.connectionStatus === "connected") {
// Show confirmation modal for connected tabs
tabToClose = id;
tabToCloseName = conversation.name;
showConfirmModal = true;
} else {
// Close disconnected tabs immediately
claudeStore.deleteConversation(id);
}
} }
} }
function confirmCloseTab() {
if (tabToClose) {
claudeStore.deleteConversation(tabToClose);
}
showConfirmModal = false;
tabToClose = null;
tabToCloseName = "";
}
function cancelCloseTab() {
showConfirmModal = false;
tabToClose = null;
tabToCloseName = "";
}
function startEditing(id: string, name: string, event: MouseEvent) { function startEditing(id: string, name: string, event: MouseEvent) {
event.stopPropagation(); event.stopPropagation();
editingTabId = id; editingTabId = id;
@@ -115,7 +105,7 @@
} }
function hasUnreadMessages(id: string, conversation: Conversation): boolean { function hasUnreadMessages(id: string, conversation: Conversation): boolean {
if (id === $activeConversationId) return false; // Active tab never has unread if (id === activeConversationId) return false; // Active tab never has unread
const lastSeen = lastSeenMessageCount.get(id) || 0; const lastSeen = lastSeenMessageCount.get(id) || 0;
return conversation.terminalLines.length > lastSeen; return conversation.terminalLines.length > lastSeen;
} }
@@ -126,8 +116,6 @@
} else if (event.key === "Escape") { } else if (event.key === "Escape") {
editingTabId = null; editingTabId = null;
editingName = ""; editingName = "";
} else if (event.key === " ") {
event.stopPropagation();
} }
} }
@@ -149,24 +137,15 @@
// Ctrl/Cmd + W: Close current tab // Ctrl/Cmd + W: Close current tab
else if ((event.ctrlKey || event.metaKey) && event.key === "w") { else if ((event.ctrlKey || event.metaKey) && event.key === "w") {
event.preventDefault(); event.preventDefault();
if ($activeConversationId && $conversations.size > 1) { if (activeConversationId && conversations.size > 1) {
const conversation = $conversations.get($activeConversationId); claudeStore.deleteConversation(activeConversationId);
if (conversation && conversation.connectionStatus === "connected") {
// Show confirmation modal for connected tabs
tabToClose = $activeConversationId;
tabToCloseName = conversation.name;
showConfirmModal = true;
} else {
// Close disconnected tabs immediately
claudeStore.deleteConversation($activeConversationId);
}
} }
} }
// Ctrl/Cmd + Tab: Next tab // Ctrl/Cmd + Tab: Next tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) { else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
const tabs = Array.from($conversations.keys()); const tabs = Array.from(conversations.keys());
const currentIndex = tabs.findIndex((id) => id === $activeConversationId); const currentIndex = tabs.findIndex((id) => id === activeConversationId);
if (currentIndex !== -1) { if (currentIndex !== -1) {
const nextIndex = (currentIndex + 1) % tabs.length; const nextIndex = (currentIndex + 1) % tabs.length;
claudeStore.switchConversation(tabs[nextIndex]); claudeStore.switchConversation(tabs[nextIndex]);
@@ -175,8 +154,8 @@
// Ctrl/Cmd + Shift + Tab: Previous tab // Ctrl/Cmd + Shift + Tab: Previous tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) { else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
event.preventDefault(); event.preventDefault();
const tabs = Array.from($conversations.keys()); const tabs = Array.from(conversations.keys());
const currentIndex = tabs.findIndex((id) => id === $activeConversationId); const currentIndex = tabs.findIndex((id) => id === activeConversationId);
if (currentIndex !== -1) { if (currentIndex !== -1) {
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length; const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
claudeStore.switchConversation(tabs[prevIndex]); claudeStore.switchConversation(tabs[prevIndex]);
@@ -192,17 +171,17 @@
<div <div
class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]" class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
> >
{#each Array.from($conversations.entries()) as [id, conversation] (id)} {#each Array.from(conversations.entries()) as [id, conversation] (id)}
<div <div
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all
{id === $activeConversationId {id === activeConversationId
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]' ? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}" : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
onclick={() => switchTab(id)} onclick={() => switchTab(id)}
onkeydown={(e) => handleTabKeydown(id, e)} onkeydown={(e) => handleTabKeydown(id, e)}
role="tab" role="tab"
tabindex={0} tabindex={0}
aria-selected={id === $activeConversationId} aria-selected={id === activeConversationId}
> >
{#if editingTabId === id} {#if editingTabId === id}
<input <input
@@ -217,26 +196,37 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
class="w-2 h-2 rounded-full {getConnectionStatusColor(conversation.connectionStatus)}" class="w-2 h-2 rounded-full {getConnectionStatusColor(conversation.connectionStatus)}"
title="Connection: {conversation.connectionStatus}" title="Connection: {conversation.connectionStatus}{id !== connectedConversationId &&
connectedConversationId
? ' (Another tab is connected)'
: ''}"
></div> ></div>
<span <span
class="text-sm pr-2 max-w-[150px] truncate" class="text-sm pr-6 max-w-[150px] truncate"
ondblclick={(e) => startEditing(id, conversation.name, e)} ondblclick={(e) => startEditing(id, conversation.name, e)}
role="button" role="button"
tabindex={-1} tabindex={-1}
> >
{conversation.name} {conversation.name}
</span> </span>
{#if id !== activeConversationId && id === connectedConversationId}
<span
class="text-xs text-[var(--text-tertiary)]"
title="This tab has the Claude connection"
>
(connected)
</span>
{/if}
{#if hasUnreadMessages(id, conversation)} {#if hasUnreadMessages(id, conversation)}
<div <div
class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse pointer-events-none" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse"
title="New messages" title="New messages"
></div> ></div>
{/if} {/if}
</div> </div>
{/if} {/if}
{#if $conversations.size > 1} {#if conversations.size > 1}
<button <button
onclick={(e) => deleteTab(id, e)} onclick={(e) => deleteTab(id, e)}
class="absolute right-1 top-1/2 -translate-y-1/2 w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)] opacity-0 group-hover:opacity-100 transition-opacity" class="absolute right-1 top-1/2 -translate-y-1/2 w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)] opacity-0 group-hover:opacity-100 transition-opacity"
@@ -278,13 +268,6 @@
</button> </button>
</div> </div>
<CloseTabConfirmModal
isOpen={showConfirmModal}
tabName={tabToCloseName}
onConfirm={confirmCloseTab}
onCancel={cancelCloseTab}
/>
<style> <style>
.terminal-tabs { .terminal-tabs {
min-height: 36px; min-height: 36px;
+24 -19
View File
@@ -11,8 +11,8 @@
items: [ items: [
"Enter your Claude API key in Settings (gear icon)", "Enter your Claude API key in Settings (gear icon)",
"Set your working directory and click Connect", "Set your working directory and click Connect",
"Start chatting with Hikari - your AI assistant!", "Start chatting with Hikari - your AI assistant!"
], ]
}, },
{ {
title: "Key Features", title: "Key Features",
@@ -22,8 +22,8 @@
"🔍 Code Search: Find files and search through code", "🔍 Code Search: Find files and search through code",
"🌐 Web Access: Fetch information from the web", "🌐 Web Access: Fetch information from the web",
"📊 MCP Servers: Connect to external tools via Model Context Protocol", "📊 MCP Servers: Connect to external tools via Model Context Protocol",
"📁 Multi-tab Support: Work on multiple conversations simultaneously", "📁 Multi-tab Support: Work on multiple conversations simultaneously"
], ]
}, },
{ {
title: "Available Commands", title: "Available Commands",
@@ -32,8 +32,8 @@
"Ask to read, create, or modify files", "Ask to read, create, or modify files",
"Request code explanations or debugging help", "Request code explanations or debugging help",
"Have Hikari run tests or build commands", "Have Hikari run tests or build commands",
"Search for specific functions or patterns", "Search for specific functions or patterns"
], ]
}, },
{ {
title: "Tips & Tricks", title: "Tips & Tricks",
@@ -42,10 +42,17 @@
"🎯 Be specific about file paths and requirements", "🎯 Be specific about file paths and requirements",
"🔒 Grant tool permissions as needed for security", "🔒 Grant tool permissions as needed for security",
"📌 Pin important conversations for quick access", "📌 Pin important conversations for quick access",
"🎨 Customize your theme and preferences in Settings", "🎨 Customize your theme and preferences in Settings"
"⌨️ Check the keyboard icon for available shortcuts", ]
],
}, },
{
title: "Keyboard Shortcuts",
items: [
"Ctrl/Cmd + Enter: Send message",
"Ctrl/Cmd + K: Clear chat (when supported)",
"Escape: Close modals and panels"
]
}
]; ];
</script> </script>
@@ -59,18 +66,16 @@
<div <div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col" class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog" role="dialog"
aria-labelledby="help-title" aria-labelledby="help-title"
tabindex="-1"
> >
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]"> <div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<h2 id="help-title" class="text-xl font-semibold text-[var(--text-primary)]"> <h2 id="help-title" class="text-xl font-semibold text-gray-100">
How to Use Hikari Desktop How to Use Hikari Desktop
</h2> </h2>
<button <button
onclick={onClose} onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors" class="p-1 text-gray-500 hover:text-gray-300 transition-colors"
aria-label="Close" aria-label="Close"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -85,11 +90,11 @@
</div> </div>
<div class="overflow-y-auto flex-1 p-6 space-y-6"> <div class="overflow-y-auto flex-1 p-6 space-y-6">
{#each sections as section (section.title)} {#each sections as section}
<div> <div>
<h3 class="font-medium text-[var(--text-primary)] mb-3">{section.title}</h3> <h3 class="font-medium text-gray-200 mb-3">{section.title}</h3>
<ul class="space-y-2 text-sm text-[var(--text-secondary)]"> <ul class="space-y-2 text-sm text-gray-400">
{#each section.items as item (item)} {#each section.items as item}
<li class="flex items-start"> <li class="flex items-start">
<span class="text-[var(--accent-primary)] mr-2 mt-0.5"></span> <span class="text-[var(--accent-primary)] mr-2 mt-0.5"></span>
<span>{item}</span> <span>{item}</span>
@@ -100,7 +105,7 @@
{/each} {/each}
<div class="pt-4 border-t border-[var(--border-color)]"> <div class="pt-4 border-t border-[var(--border-color)]">
<p class="text-sm text-[var(--text-tertiary)]"> <p class="text-sm text-gray-500">
<strong>Need more help?</strong> Join our Discord community for support and updates! <strong>Need more help?</strong> Join our Discord community for support and updates!
</p> </p>
</div> </div>
@@ -147,4 +152,4 @@
.overflow-y-auto::-webkit-scrollbar-thumb:hover { .overflow-y-auto::-webkit-scrollbar-thumb:hover {
background-color: var(--accent-primary); background-color: var(--accent-primary);
} }
</style> </style>
-61
View File
@@ -1,61 +0,0 @@
<script lang="ts">
export let content: string;
export let searchQuery: string;
interface TextPart {
text: string;
isMatch: boolean;
}
function getHighlightedParts(text: string, query: string): TextPart[] {
if (!query) {
return [{ text, isMatch: false }];
}
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedQuery})`, "gi");
const parts: TextPart[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
// Add non-matching text before the match
if (match.index > lastIndex) {
parts.push({
text: text.slice(lastIndex, match.index),
isMatch: false,
});
}
// Add the matching text
parts.push({
text: match[1],
isMatch: true,
});
lastIndex = regex.lastIndex;
}
// Add any remaining text after the last match
if (lastIndex < text.length) {
parts.push({
text: text.slice(lastIndex),
isMatch: false,
});
}
return parts;
}
$: parts = getHighlightedParts(content, searchQuery);
</script>
<span class="whitespace-pre-wrap">
{#each parts as part, index (index)}
{#if part.isMatch}
<mark class="search-highlight">{part.text}</mark>
{:else}
{part.text}
{/if}
{/each}
</span>
+34 -306
View File
@@ -13,96 +13,13 @@
clearHistoryRestore, clearHistoryRestore,
} from "$lib/stores/historyRestore"; } from "$lib/stores/historyRestore";
import MessageModeSelector from "$lib/components/MessageModeSelector.svelte"; import MessageModeSelector from "$lib/components/MessageModeSelector.svelte";
import SlashCommandMenu from "$lib/components/SlashCommandMenu.svelte";
import { getCurrentMode } from "$lib/stores/messageMode"; import { getCurrentMode } from "$lib/stores/messageMode";
import { formatMessageWithMode } from "$lib/types/messageMode"; import { formatMessageWithMode } from "$lib/types/messageMode";
import {
parseSlashCommand,
getMatchingCommands,
isSlashCommand,
type SlashCommand,
} from "$lib/commands/slashCommands";
const INPUT_HISTORY_KEY = "hikari-input-history";
const MAX_HISTORY_SIZE = 100;
let inputValue = $state(""); let inputValue = $state("");
let isSubmitting = $state(false); let isSubmitting = $state(false);
let isConnected = $state(false); let isConnected = $state(false);
let isProcessing = $state(false); let isProcessing = $state(false);
let showCommandMenu = $state(false);
let matchingCommands = $state<SlashCommand[]>([]);
let selectedCommandIndex = $state(0);
// Input history state
let inputHistory = $state<string[]>([]);
let historyIndex = $state(-1);
let tempInput = $state("");
let userHasTyped = $state(false); // Track if user manually typed (vs history navigation)
// Textarea resize state
let textareaHeight = $state(48);
const MIN_HEIGHT = 48;
const MAX_HEIGHT = 200;
let isResizing = $state(false);
let startY = 0;
let startHeight = 0;
function handleResizeStart(event: MouseEvent) {
isResizing = true;
startY = event.clientY;
startHeight = textareaHeight;
document.addEventListener("mousemove", handleResizeMove);
document.addEventListener("mouseup", handleResizeEnd);
event.preventDefault();
}
function handleResizeMove(event: MouseEvent) {
if (!isResizing) return;
// Dragging up (negative deltaY) should increase height
const deltaY = startY - event.clientY;
const newHeight = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, startHeight + deltaY));
textareaHeight = newHeight;
}
function handleResizeEnd() {
isResizing = false;
document.removeEventListener("mousemove", handleResizeMove);
document.removeEventListener("mouseup", handleResizeEnd);
}
// Load history from localStorage on init
function loadHistory(): string[] {
try {
const stored = localStorage.getItem(INPUT_HISTORY_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
function saveHistory(history: string[]) {
try {
localStorage.setItem(INPUT_HISTORY_KEY, JSON.stringify(history));
} catch {
// Ignore storage errors
}
}
function addToHistory(input: string) {
const trimmed = input.trim();
if (!trimmed) return;
// Don't add duplicates of the most recent entry
if (inputHistory.length > 0 && inputHistory[0] === trimmed) return;
// Add to front of history
inputHistory = [trimmed, ...inputHistory.slice(0, MAX_HISTORY_SIZE - 1)];
saveHistory(inputHistory);
}
// Initialize history on mount
inputHistory = loadHistory();
claudeStore.connectionStatus.subscribe((status) => { claudeStore.connectionStatus.subscribe((status) => {
isConnected = status === "connected"; isConnected = status === "connected";
@@ -112,79 +29,11 @@
isProcessing = processing; isProcessing = processing;
}); });
function handleInputChange() {
// If input is empty, allow history navigation again
// Otherwise, mark that user has manually typed
if (inputValue === "") {
userHasTyped = false;
} else {
userHasTyped = true;
}
// Reset history navigation when user types
historyIndex = -1;
tempInput = "";
if (isSlashCommand(inputValue)) {
matchingCommands = getMatchingCommands(inputValue);
showCommandMenu = matchingCommands.length > 0;
selectedCommandIndex = 0;
} else {
showCommandMenu = false;
matchingCommands = [];
}
}
function selectCommand(command: SlashCommand) {
inputValue = `/${command.name} `;
showCommandMenu = false;
matchingCommands = [];
}
async function executeSlashCommand(): Promise<boolean> {
const { command, args } = parseSlashCommand(inputValue);
if (command) {
inputValue = "";
showCommandMenu = false;
matchingCommands = [];
await command.execute(args);
return true;
}
return false;
}
async function handleSubmit(event: Event) { async function handleSubmit(event: Event) {
event.preventDefault(); event.preventDefault();
const message = inputValue.trim(); const message = inputValue.trim();
if (!message || isSubmitting) return; if (!message || isSubmitting || !isConnected) return;
// Check for slash commands first (these work even when disconnected)
if (isSlashCommand(message)) {
// Add slash commands to history too
addToHistory(message);
historyIndex = -1;
tempInput = "";
userHasTyped = false;
const wasCommand = await executeSlashCommand();
if (wasCommand) return;
// If it started with / but wasn't a valid command, show error
claudeStore.addLine(
"error",
`Unknown command: ${message.split(" ")[0]}. Type /help for available commands.`
);
inputValue = "";
return;
}
// Regular messages require connection
if (!isConnected) return;
// Add to history before clearing
addToHistory(message);
historyIndex = -1;
tempInput = "";
userHasTyped = false;
isSubmitting = true; isSubmitting = true;
inputValue = ""; inputValue = "";
@@ -290,62 +139,6 @@ User: ${formattedMessage}`;
} }
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
// Handle command menu navigation
if (showCommandMenu && matchingCommands.length > 0) {
if (event.key === "ArrowDown") {
event.preventDefault();
selectedCommandIndex = (selectedCommandIndex + 1) % matchingCommands.length;
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
selectedCommandIndex =
(selectedCommandIndex - 1 + matchingCommands.length) % matchingCommands.length;
return;
}
if (event.key === "Tab") {
event.preventDefault();
const selected = matchingCommands[selectedCommandIndex];
if (selected) {
selectCommand(selected);
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
showCommandMenu = false;
return;
}
}
// Handle input history navigation (when command menu is closed AND user hasn't typed)
// If user has typed something, let arrow keys navigate the cursor instead
if (event.key === "ArrowUp" && inputHistory.length > 0 && !userHasTyped) {
event.preventDefault();
if (historyIndex === -1) {
// Save current input before navigating history
tempInput = inputValue;
}
if (historyIndex < inputHistory.length - 1) {
historyIndex++;
inputValue = inputHistory[historyIndex];
}
return;
}
if (event.key === "ArrowDown" && historyIndex >= 0 && !userHasTyped) {
event.preventDefault();
historyIndex--;
if (historyIndex === -1) {
// Restore the temp input when going back to current
inputValue = tempInput;
userHasTyped = false; // Reset since we're back to empty/temp state
} else {
inputValue = inputHistory[historyIndex];
}
return;
}
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
handleSubmit(event); handleSubmit(event);
} }
@@ -357,57 +150,49 @@ User: ${formattedMessage}`;
<MessageModeSelector /> <MessageModeSelector />
</div> </div>
<div class="input-row"> <div class="input-row flex gap-3 items-end">
<div class="textarea-wrapper"> <div class="flex-1 relative">
<SlashCommandMenu
commands={matchingCommands}
selectedIndex={selectedCommandIndex}
onSelect={selectCommand}
/>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="resize-handle" onmousedown={handleResizeStart} title="Drag to resize"></div>
<textarea <textarea
bind:value={inputValue} bind:value={inputValue}
onkeydown={handleKeyDown} onkeydown={handleKeyDown}
oninput={handleInputChange} placeholder={isConnected ? "Ask Hikari anything..." : "Connect to Claude first..."}
placeholder={isConnected disabled={!isConnected || isSubmitting}
? "Ask Hikari anything... (type / for commands)"
: "Connect to Claude first..."}
disabled={isSubmitting}
rows={1} rows={1}
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px);"
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]
disabled:opacity-50 disabled:cursor-not-allowed" disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200"
></textarea> ></textarea>
</div> </div>
<div class="button-wrapper"> {#if isProcessing}
{#if isProcessing} <button
<button type="button"
type="button" onclick={handleInterrupt}
onclick={handleInterrupt} class="px-6 py-3 bg-red-600 hover:bg-red-700
class="send-button bg-red-600 hover:bg-red-700" text-white font-medium rounded-lg
title="Interrupt the current response (Ctrl+C)" transition-all duration-200 transform hover:scale-105 active:scale-95"
> title="Interrupt the current response (Ctrl+C)"
<span class="font-bold"></span> Stop >
</button> <span class="font-bold"></span> Stop
{:else} </button>
<button {:else}
type="submit" <button
disabled={!isConnected || isSubmitting || !inputValue.trim()} type="submit"
class="send-button bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)] disabled={!isConnected || isSubmitting || !inputValue.trim()}
disabled:opacity-50 disabled:cursor-not-allowed" class="px-6 py-3 bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)]
> text-white font-medium rounded-lg
{#if isSubmitting} disabled:opacity-50 disabled:cursor-not-allowed
<span class="inline-block animate-spin"></span> transition-all duration-200 transform hover:scale-105 active:scale-95"
{:else} >
Send {#if isSubmitting}
{/if} <span class="inline-block animate-spin"></span>
</button> {:else}
{/if} Send
</div> {/if}
</button>
{/if}
</div> </div>
</form> </form>
@@ -429,61 +214,4 @@ User: ${formattedMessage}`;
gap: 12px; gap: 12px;
align-items: flex-end; align-items: flex-end;
} }
.textarea-wrapper {
flex: 1;
position: relative;
display: flex;
flex-direction: column;
}
.resize-handle {
height: 6px;
cursor: ns-resize;
background: transparent;
border-radius: 3px;
margin-bottom: 2px;
display: flex;
justify-content: center;
align-items: center;
}
.resize-handle::before {
content: "";
width: 40px;
height: 3px;
background: var(--border-color);
border-radius: 2px;
opacity: 0.5;
transition: opacity 0.2s;
}
.resize-handle:hover::before {
opacity: 1;
background: var(--accent-primary);
}
.button-wrapper {
display: flex;
align-items: flex-end;
height: 100%;
}
.send-button {
padding: 0 24px;
height: 48px;
color: white;
font-weight: 500;
border-radius: 8px;
transition: all 0.2s;
white-space: nowrap;
}
.send-button:hover:not(:disabled) {
transform: scale(1.05);
}
.send-button:active:not(:disabled) {
transform: scale(0.95);
}
</style> </style>
@@ -1,177 +0,0 @@
<script lang="ts">
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
const shortcuts = [
{
category: "General",
items: [
{ keys: ["Escape"], description: "Close modals and panels" },
{ keys: ["Ctrl", "L"], description: "Clear the terminal" },
{ keys: ["Ctrl", ","], description: "Open settings" },
],
},
{
category: "Chat",
items: [
{ keys: ["Enter"], description: "Send message" },
{ keys: ["Shift", "Enter"], description: "New line in message" },
{ keys: ["Ctrl", "C"], description: "Interrupt/stop response" },
{ keys: ["↑"], description: "Previous input from history" },
{ keys: ["↓"], description: "Next input from history" },
],
},
{
category: "Slash Commands",
items: [
{ keys: ["↑", "↓"], description: "Navigate command menu" },
{ keys: ["Tab"], description: "Complete selected command" },
{ keys: ["Escape"], description: "Close command menu" },
],
},
{
category: "Permission Prompts",
items: [
{ keys: ["Enter"], description: "Allow & reconnect" },
{ keys: ["Escape"], description: "Dismiss" },
],
},
];
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] overflow-hidden flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="shortcuts-title"
tabindex="-1"
>
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-lg bg-[var(--accent-primary)]/20 flex items-center justify-center"
>
<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="M12 3C10.22 3 8.47 3.23 6.86 3.68A2 2 0 005 5.57V18.43a2 2 0 001.86 1.89C8.47 20.77 10.22 21 12 21s3.53-.23 5.14-.68A2 2 0 0019 18.43V5.57a2 2 0 00-1.86-1.89C15.53 3.23 13.78 3 12 3z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7h.01M12 7h.01M16 7h.01M8 11h.01M12 11h.01M16 11h.01M8 15h8"
/>
</svg>
</div>
<h2 id="shortcuts-title" class="text-xl font-semibold text-[var(--text-primary)]">
Keyboard Shortcuts
</h2>
</div>
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<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="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="overflow-y-auto flex-1 p-6 space-y-6">
{#each shortcuts as section (section.category)}
<div>
<h3
class="text-sm font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-3"
>
{section.category}
</h3>
<div class="space-y-2">
{#each section.items as item (item.description)}
<div
class="flex items-center justify-between py-2 px-3 bg-[var(--bg-secondary)] rounded-lg"
>
<span class="text-sm text-[var(--text-primary)]">{item.description}</span>
<div class="flex items-center gap-1">
{#each item.keys as key, i (key)}
{#if i > 0}
<span class="text-[var(--text-secondary)] text-xs">+</span>
{/if}
<kbd
class="px-2 py-1 text-xs font-mono bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] shadow-sm min-w-[24px] text-center"
>
{key}
</kbd>
{/each}
</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.overflow-y-auto {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.overflow-y-auto::-webkit-scrollbar {
width: 8px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 4px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background-color: var(--accent-primary);
}
</style>
-352
View File
@@ -1,352 +0,0 @@
<script lang="ts">
import { marked } from "marked";
import hljs from "highlight.js";
import { onMount } from "svelte";
import { openUrl } from "@tauri-apps/plugin-opener";
interface Props {
content: string;
searchQuery?: string;
}
let { content, searchQuery = "" }: Props = $props();
let containerElement: HTMLDivElement;
const renderer = new marked.Renderer();
renderer.code = ({ text, lang }) => {
const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
const highlighted = hljs.highlight(text, { language }).value;
return `<pre class="hljs-code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`;
};
renderer.codespan = ({ text }) => {
return `<code class="hljs-inline">${text}</code>`;
};
marked.setOptions({
renderer,
gfm: true,
breaks: true,
});
function processSpoilers(html: string): string {
const codeBlockPlaceholders: string[] = [];
// Temporarily replace code blocks and inline code with placeholders
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
codeBlockPlaceholders.push(match);
return `__CODE_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
});
// Apply spoiler transformation only to non-code content
processed = processed.replace(
/\|\|(.+?)\|\|/g,
'<span class="spoiler" role="button" tabindex="0">$1</span>'
);
// Restore code blocks
processed = processed.replace(/__CODE_PLACEHOLDER_(\d+)__/g, (_, index) => {
return codeBlockPlaceholders[parseInt(index)];
});
return processed;
}
function highlightSearchMatches(html: string, query: string): string {
if (!query) return html;
const codeBlockPlaceholders: string[] = [];
const tagPlaceholders: string[] = [];
// Temporarily replace code blocks with placeholders (don't highlight in code)
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
codeBlockPlaceholders.push(match);
return `__CODE_SEARCH_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
});
// Temporarily replace all HTML tags with placeholders
processed = processed.replace(/<[^>]+>/g, (match) => {
tagPlaceholders.push(match);
return `__TAG_PLACEHOLDER_${tagPlaceholders.length - 1}__`;
});
// Apply search highlighting to text content
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedQuery})`, "gi");
processed = processed.replace(regex, '<mark class="search-highlight">$1</mark>');
// Restore HTML tags
processed = processed.replace(/__TAG_PLACEHOLDER_(\d+)__/g, (_, index) => {
return tagPlaceholders[parseInt(index)];
});
// Restore code blocks
processed = processed.replace(/__CODE_SEARCH_PLACEHOLDER_(\d+)__/g, (_, index) => {
return codeBlockPlaceholders[parseInt(index)];
});
return processed;
}
function renderMarkdown(text: string): string {
try {
const html = marked.parse(text) as string;
const withSpoilers = processSpoilers(html);
return highlightSearchMatches(withSpoilers, searchQuery);
} catch {
return text;
}
}
function handleSpoilerClick(event: Event) {
const target = event.target as HTMLElement;
if (target.classList.contains("spoiler")) {
target.classList.toggle("revealed");
}
}
function handleSpoilerKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target.classList.contains("spoiler") && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
target.classList.toggle("revealed");
}
}
function handleLinkClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const anchor = target.closest("a");
if (anchor?.href) {
event.preventDefault();
openUrl(anchor.href);
}
}
onMount(() => {
if (containerElement) {
containerElement.querySelectorAll("pre code:not(.hljs)").forEach((block) => {
hljs.highlightElement(block as HTMLElement);
});
}
});
</script>
<div
bind:this={containerElement}
class="markdown-content"
onclick={(e) => {
handleSpoilerClick(e);
handleLinkClick(e);
}}
onkeydown={handleSpoilerKeydown}
role="presentation"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown rendering requires @html; content is from Claude API -->
{@html renderMarkdown(content)}
</div>
<style>
.markdown-content {
line-height: 1.6;
}
.markdown-content :global(p) {
margin: 0.5em 0;
}
.markdown-content :global(p:first-child) {
margin-top: 0;
}
.markdown-content :global(p:last-child) {
margin-bottom: 0;
}
.markdown-content :global(.hljs-code-block) {
background: var(--bg-code, #1e1e2e);
border-radius: 6px;
padding: 1em;
margin: 0.75em 0;
overflow-x: auto;
border: 1px solid var(--border-color);
}
.markdown-content :global(.hljs-code-block code) {
background: transparent;
padding: 0;
font-size: 0.9em;
font-family: "JetBrains Mono", "Fira Code", monospace;
}
.markdown-content :global(.hljs-inline) {
background: var(--bg-code, #1e1e2e);
padding: 0.2em 0.4em;
border-radius: 4px;
font-size: 0.9em;
font-family: "JetBrains Mono", "Fira Code", monospace;
}
.markdown-content :global(ul),
.markdown-content :global(ol) {
margin: 0.5em 0;
padding-left: 1.5em;
}
.markdown-content :global(li) {
margin: 0.25em 0;
}
.markdown-content :global(blockquote) {
border-left: 3px solid var(--border-color);
margin: 0.75em 0;
padding-left: 1em;
color: var(--text-secondary);
}
.markdown-content :global(a) {
color: var(--accent-primary, #f472b6);
text-decoration: underline;
}
.markdown-content :global(a:hover) {
color: var(--accent-secondary, #e879f9);
}
.markdown-content :global(strong) {
font-weight: 600;
}
.markdown-content :global(h1),
.markdown-content :global(h2),
.markdown-content :global(h3),
.markdown-content :global(h4) {
margin: 1em 0 0.5em 0;
font-weight: 600;
}
.markdown-content :global(h1:first-child),
.markdown-content :global(h2:first-child),
.markdown-content :global(h3:first-child),
.markdown-content :global(h4:first-child) {
margin-top: 0;
}
.markdown-content :global(hr) {
border: none;
border-top: 1px solid var(--border-color);
margin: 1em 0;
}
.markdown-content :global(table) {
border-collapse: collapse;
margin: 0.75em 0;
width: 100%;
}
.markdown-content :global(th),
.markdown-content :global(td) {
border: 1px solid var(--border-color);
padding: 0.5em;
text-align: left;
}
.markdown-content :global(th) {
background: var(--bg-secondary);
font-weight: 600;
}
/* Highlight.js theme colors - using CSS variables for light/dark mode support */
.markdown-content :global(.hljs) {
color: var(--text-primary);
}
.markdown-content :global(.hljs-keyword),
.markdown-content :global(.hljs-selector-tag),
.markdown-content :global(.hljs-built_in),
.markdown-content :global(.hljs-name) {
color: var(--hljs-keyword);
}
.markdown-content :global(.hljs-string),
.markdown-content :global(.hljs-attr),
.markdown-content :global(.hljs-symbol),
.markdown-content :global(.hljs-bullet) {
color: var(--hljs-string);
}
.markdown-content :global(.hljs-number),
.markdown-content :global(.hljs-literal) {
color: var(--hljs-number);
}
.markdown-content :global(.hljs-comment),
.markdown-content :global(.hljs-quote) {
color: var(--hljs-comment);
font-style: italic;
}
.markdown-content :global(.hljs-function),
.markdown-content :global(.hljs-title) {
color: var(--hljs-function);
}
.markdown-content :global(.hljs-type),
.markdown-content :global(.hljs-class) {
color: var(--hljs-type);
}
.markdown-content :global(.hljs-variable),
.markdown-content :global(.hljs-template-variable) {
color: var(--hljs-variable);
}
.markdown-content :global(.hljs-meta) {
color: var(--hljs-meta);
}
.markdown-content :global(.hljs-tag) {
color: var(--hljs-keyword);
}
.markdown-content :global(.hljs-attribute) {
color: var(--hljs-function);
}
.markdown-content :global(.hljs-params) {
color: var(--text-primary);
}
/* Spoiler tag styles */
.markdown-content :global(.spoiler) {
background: var(--text-primary);
color: transparent;
border-radius: 4px;
padding: 0 0.25em;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.markdown-content :global(.spoiler:hover) {
opacity: 0.8;
}
.markdown-content :global(.spoiler:focus) {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.markdown-content :global(.spoiler.revealed) {
background: var(--bg-hover);
color: var(--text-primary);
user-select: text;
}
.markdown-content :global(.search-highlight) {
background-color: var(--search-highlight, #fbbf24);
color: var(--search-highlight-text, #000);
border-radius: 2px;
padding: 0 2px;
}
</style>
+8 -22
View File
@@ -109,22 +109,8 @@ Please continue where we left off and retry that action now that you have permis
function isToolAlreadyGranted(toolName: string): boolean { function isToolAlreadyGranted(toolName: string): boolean {
return grantedToolsList.includes(toolName); return grantedToolsList.includes(toolName);
} }
function handleKeydown(event: KeyboardEvent) {
if (!isVisible || !permission) return;
if (event.key === "Enter") {
event.preventDefault();
handleApproveAndReconnect();
} else if (event.key === "Escape") {
event.preventDefault();
handleDismiss();
}
}
</script> </script>
<svelte:window onkeydown={handleKeydown} />
{#if isVisible && permission} {#if isVisible && 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"
@@ -137,8 +123,8 @@ Please continue where we left off and retry that action now that you have permis
<span class="text-xl">🔐</span> <span class="text-xl">🔐</span>
</div> </div>
<div> <div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Permission Blocked</h2> <h2 class="text-lg font-semibold text-white">Permission Blocked</h2>
<p class="text-sm text-[var(--text-secondary)]">Hikari tried to use a restricted tool</p> <p class="text-sm text-gray-400">Hikari tried to use a restricted tool</p>
</div> </div>
</div> </div>
@@ -149,7 +135,7 @@ Please continue where we left off and retry that action now that you have permis
</div> </div>
<div class="mb-4"> <div class="mb-4">
<div class="text-sm text-[var(--text-secondary)] mb-1">Tool</div> <div class="text-sm text-gray-400 mb-1">Tool</div>
<div <div
class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--accent-primary)] font-mono flex items-center justify-between" class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--accent-primary)] font-mono flex items-center justify-between"
> >
@@ -163,17 +149,17 @@ Please continue where we left off and retry that action now that you have permis
</div> </div>
<div class="mb-4"> <div class="mb-4">
<div class="text-sm text-[var(--text-secondary)] mb-1">Description</div> <div class="text-sm text-gray-400 mb-1">Description</div>
<div class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--text-primary)]"> <div class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-gray-300">
{permission.description} {permission.description}
</div> </div>
</div> </div>
{#if Object.keys(permission.input).length > 0} {#if Object.keys(permission.input).length > 0}
<div class="mb-6"> <div class="mb-6">
<div class="text-sm text-[var(--text-secondary)] mb-1">Details</div> <div class="text-sm text-gray-400 mb-1">Details</div>
<pre <pre
class="px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] text-xs overflow-x-auto max-h-32">{formatInput( class="px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-gray-300 text-xs overflow-x-auto max-h-32">{formatInput(
permission.input permission.input
)}</pre> )}</pre>
</div> </div>
@@ -182,7 +168,7 @@ Please continue where we left off and retry that action now that you have permis
<div class="flex gap-3"> <div class="flex gap-3">
<button <button
onclick={handleDismiss} onclick={handleDismiss}
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium" class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-gray-400 rounded-lg transition-colors font-medium"
> >
Dismiss Dismiss
</button> </button>
@@ -1,86 +0,0 @@
<script lang="ts">
import type { SlashCommand } from "$lib/commands/slashCommands";
interface Props {
commands: SlashCommand[];
selectedIndex: number;
onSelect: (command: SlashCommand) => void;
}
let { commands, selectedIndex, onSelect }: Props = $props();
</script>
{#if commands.length > 0}
<div class="slash-command-menu">
<div class="menu-header">Commands</div>
{#each commands as command, index (command.name)}
<button
type="button"
class="menu-item"
class:selected={index === selectedIndex}
onclick={() => onSelect(command)}
onmouseenter={() => (selectedIndex = index)}
>
<span class="command-name">/{command.name}</span>
<span class="command-description">{command.description}</span>
</button>
{/each}
</div>
{/if}
<style>
.slash-command-menu {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
margin-bottom: 8px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.2);
z-index: 100;
}
.menu-header {
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-tertiary);
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
}
.menu-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 12px;
text-align: left;
background: transparent;
border: none;
cursor: pointer;
transition: background-color 0.15s ease;
}
.menu-item:hover,
.menu-item.selected {
background: var(--bg-tertiary);
}
.command-name {
font-family: monospace;
font-weight: 600;
color: var(--accent-primary);
min-width: 80px;
}
.command-description {
color: var(--text-secondary);
font-size: 13px;
}
</style>
-30
View File
@@ -17,7 +17,6 @@
import StatsDisplay from "./StatsDisplay.svelte"; import StatsDisplay from "./StatsDisplay.svelte";
import AboutPanel from "./AboutPanel.svelte"; import AboutPanel from "./AboutPanel.svelte";
import HelpPanel from "./HelpPanel.svelte"; import HelpPanel from "./HelpPanel.svelte";
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
import { achievementProgress } from "$lib/stores/achievements"; import { achievementProgress } from "$lib/stores/achievements";
const DISCORD_URL = "https://chat.nhcarrigan.com"; const DISCORD_URL = "https://chat.nhcarrigan.com";
@@ -32,7 +31,6 @@
let showStats = $state(false); let showStats = $state(false);
let showAbout = $state(false); let showAbout = $state(false);
let showHelp = $state(false); let showHelp = $state(false);
let showKeyboardShortcuts = $state(false);
const progress = $derived($achievementProgress); const progress = $derived($achievementProgress);
let currentConfig: HikariConfig = $state({ let currentConfig: HikariConfig = $state({
model: null, model: null,
@@ -45,10 +43,6 @@
greeting_custom_prompt: null, greeting_custom_prompt: null,
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.5, notification_volume: 0.5,
always_on_top: false,
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
}); });
onMount(async () => { onMount(async () => {
@@ -277,26 +271,6 @@
/> />
</svg> </svg>
</button> </button>
<button
onclick={() => (showKeyboardShortcuts = true)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
title="Keyboard Shortcuts"
>
<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="M12 3C10.22 3 8.47 3.23 6.86 3.68A2 2 0 005 5.57V18.43a2 2 0 001.86 1.89C8.47 20.77 10.22 21 12 21s3.53-.23 5.14-.68A2 2 0 0019 18.43V5.57a2 2 0 00-1.86-1.89C15.53 3.23 13.78 3 12 3z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7h.01M12 7h.01M16 7h.01M8 11h.01M12 11h.01M16 11h.01M8 15h8"
/>
</svg>
</button>
<button <button
onclick={() => (showHelp = true)} onclick={() => (showHelp = true)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors" class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
@@ -366,7 +340,3 @@
{#if showHelp} {#if showHelp}
<HelpPanel onClose={() => (showHelp = false)} /> <HelpPanel onClose={() => (showHelp = false)} />
{/if} {/if}
{#if showKeyboardShortcuts}
<KeyboardShortcutsModal onClose={() => (showKeyboardShortcuts = false)} />
{/if}
+5 -76
View File
@@ -1,66 +1,24 @@
<script lang="ts"> <script lang="ts">
import { claudeStore, type TerminalLine } from "$lib/stores/claude"; import { claudeStore, type TerminalLine } from "$lib/stores/claude";
import { afterUpdate, tick } from "svelte"; import { afterUpdate } from "svelte";
import ConversationTabs from "./ConversationTabs.svelte"; import ConversationTabs from "./ConversationTabs.svelte";
import Markdown from "./Markdown.svelte";
import HighlightedText from "./HighlightedText.svelte";
import { searchState, searchQuery } from "$lib/stores/search";
let terminalElement: HTMLDivElement; let terminalElement: HTMLDivElement;
let shouldAutoScroll = true; let shouldAutoScroll = true;
let lines: TerminalLine[] = []; let lines: TerminalLine[] = [];
let currentSearchQuery = "";
let currentConversationId: string | null = null;
let isRestoringScroll = false;
searchQuery.subscribe((value) => {
currentSearchQuery = value;
});
claudeStore.terminalLines.subscribe((value) => { claudeStore.terminalLines.subscribe((value) => {
lines = value; lines = value;
}); });
claudeStore.activeConversationId.subscribe(async (newId) => {
if (!newId) return;
// Save current conversation's scroll position before switching
if (currentConversationId && currentConversationId !== newId && terminalElement) {
const position = shouldAutoScroll ? -1 : terminalElement.scrollTop;
claudeStore.saveScrollPosition(currentConversationId, position);
}
currentConversationId = newId;
// Restore scroll position for the new conversation after DOM updates
await tick();
if (terminalElement) {
const savedPosition = claudeStore.getScrollPosition(newId);
isRestoringScroll = true;
if (savedPosition === -1) {
// Auto-scroll to bottom
shouldAutoScroll = true;
terminalElement.scrollTop = terminalElement.scrollHeight;
} else {
// Restore to saved position
shouldAutoScroll = false;
terminalElement.scrollTop = savedPosition;
}
// Small delay to prevent the scroll handler from overriding our restore
setTimeout(() => {
isRestoringScroll = false;
}, 50);
}
});
function handleScroll() { function handleScroll() {
if (!terminalElement || isRestoringScroll) return; if (!terminalElement) return;
const { scrollTop, scrollHeight, clientHeight } = terminalElement; const { scrollTop, scrollHeight, clientHeight } = terminalElement;
shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100; shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100;
} }
afterUpdate(() => { afterUpdate(() => {
if (shouldAutoScroll && terminalElement && !isRestoringScroll) { if (shouldAutoScroll && terminalElement) {
terminalElement.scrollTop = terminalElement.scrollHeight; terminalElement.scrollTop = terminalElement.scrollHeight;
} }
}); });
@@ -105,23 +63,6 @@
minute: "2-digit", minute: "2-digit",
}); });
} }
$: {
if (currentSearchQuery && lines.length > 0) {
const escapedQuery = currentSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(escapedQuery, "gi");
let totalMatches = 0;
for (const line of lines) {
const matches = line.content.match(regex);
if (matches) {
totalMatches += matches.length;
}
}
searchState.setMatchCount(totalMatches);
} else {
searchState.setMatchCount(0);
}
}
</script> </script>
<div <div
@@ -143,8 +84,7 @@
<div <div
bind:this={terminalElement} bind:this={terminalElement}
onscroll={handleScroll} onscroll={handleScroll}
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono" class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono text-sm"
style="font-size: var(--terminal-font-size, 14px);"
> >
{#if lines.length === 0} {#if lines.length === 0}
<div class="terminal-waiting italic"> <div class="terminal-waiting italic">
@@ -160,11 +100,7 @@
{#if line.toolName} {#if line.toolName}
<span class="terminal-tool-name mr-2">[{line.toolName}]</span> <span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if} {/if}
{#if line.type === "assistant"} <span class="whitespace-pre-wrap">{line.content}</span>
<Markdown content={line.content} searchQuery={currentSearchQuery} />
{:else}
<HighlightedText content={line.content} searchQuery={currentSearchQuery} />
{/if}
</div> </div>
{/each} {/each}
{/if} {/if}
@@ -221,11 +157,4 @@
.terminal-header-text { .terminal-header-text {
color: var(--text-secondary); color: var(--text-secondary);
} }
:global(.search-highlight) {
background-color: var(--search-highlight, #fbbf24);
color: var(--search-highlight-text, #000);
border-radius: 2px;
padding: 0 2px;
}
</style> </style>
@@ -1,92 +0,0 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { UpdateInfo } from "$lib/types/messages";
import { configStore } from "$lib/stores/config";
let updateInfo = $state<UpdateInfo | null>(null);
let dismissed = $state(false);
export async function checkForUpdates() {
// Check if update checks are enabled
const config = configStore.getConfig();
if (!config.update_checks_enabled) {
return;
}
try {
const info = await invoke<UpdateInfo>("check_for_updates");
if (info.has_update) {
updateInfo = info;
dismissed = false;
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
console.error("Failed to check for updates:", errorMessage);
}
}
function dismiss() {
dismissed = true;
}
async function openRelease() {
if (updateInfo?.release_url) {
await openUrl(updateInfo.release_url);
}
}
</script>
{#if updateInfo && !dismissed}
<div
class="fixed bottom-4 right-4 max-w-sm bg-[var(--bg-tertiary)] border border-[var(--accent-primary)] rounded-lg shadow-lg p-4 z-50"
>
<div class="flex items-start gap-3">
<div class="text-2xl">🎉</div>
<div class="flex-1">
<h3 class="text-[var(--text-primary)] font-semibold mb-1">Update Available!</h3>
<p class="text-[var(--text-secondary)] text-sm mb-2">
A new version of Hikari Desktop is available:
<span class="text-[var(--accent-primary)] font-mono">{updateInfo.latest_version}</span>
</p>
<p class="text-[var(--text-muted)] text-xs mb-3">
Current version: {updateInfo.current_version}
</p>
<div class="flex gap-2">
<button
onclick={openRelease}
class="px-3 py-1.5 bg-[var(--accent-primary)] text-white rounded text-sm hover:brightness-110 transition-all"
>
View Release
</button>
<button
onclick={dismiss}
class="px-3 py-1.5 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded text-sm hover:bg-[var(--bg-primary)] transition-all"
>
Later
</button>
</div>
</div>
<button
onclick={dismiss}
class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Dismiss"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
</div>
</div>
{/if}
-264
View File
@@ -1,264 +0,0 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { SvelteSet } from "svelte/reactivity";
import { claudeStore, hasQuestionPending } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import type { UserQuestionEvent } from "$lib/types/messages";
let isVisible = $state(false);
let question: UserQuestionEvent | null = $state(null);
let selectedOptions: SvelteSet<string> = new SvelteSet();
let customAnswer = $state("");
let showCustomInput = $state(false);
let grantedToolsList: string[] = $state([]);
let workingDirectory = $state("");
hasQuestionPending.subscribe((pending) => {
isVisible = pending;
if (!pending) {
selectedOptions = new SvelteSet();
customAnswer = "";
showCustomInput = false;
}
});
claudeStore.pendingQuestion.subscribe((q) => {
question = q;
if (q) {
characterState.setState("permission");
}
});
claudeStore.grantedTools.subscribe((tools) => {
grantedToolsList = Array.from(tools);
});
claudeStore.currentWorkingDirectory.subscribe((dir) => {
workingDirectory = dir;
});
function toggleOption(label: string) {
if (!question) return;
if (question.multi_select) {
if (selectedOptions.has(label)) {
selectedOptions.delete(label);
} else {
selectedOptions.add(label);
}
} else {
selectedOptions.clear();
selectedOptions.add(label);
}
showCustomInput = false;
}
function selectCustom() {
showCustomInput = true;
selectedOptions.clear();
}
async function handleSubmitAndReconnect() {
if (!question) return;
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
let answerText: string;
if (showCustomInput && customAnswer.trim()) {
answerText = customAnswer.trim();
} else if (selectedOptions.size > 0) {
if (question.multi_select) {
answerText = Array.from(selectedOptions).join(", ");
} else {
answerText = Array.from(selectedOptions)[0];
}
} else {
return;
}
const questionText = question.question;
const conversationHistory = claudeStore.getConversationHistory();
claudeStore.addLine("system", `Answer: ${answerText}. Reconnecting with context...`);
claudeStore.clearQuestion();
try {
await invoke("stop_claude", { conversationId });
await new Promise((resolve) => setTimeout(resolve, 500));
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDirectory || "/home/naomi",
allowed_tools: grantedToolsList,
},
});
await new Promise((resolve) => setTimeout(resolve, 1000));
if (conversationHistory) {
const contextMessage = `[CONTEXT RESTORATION]
I just answered your question. Here's our conversation so far:
${conversationHistory}
You asked me: "${questionText}"
My answer: "${answerText}"
Please continue where we left off, taking my answer into account.`;
await invoke("send_prompt", {
conversationId,
message: contextMessage,
});
}
characterState.setTemporaryState("success", 2000);
} catch (error) {
console.error("Failed to reconnect:", error);
claudeStore.addLine("error", `Reconnect failed: ${error}`);
characterState.setTemporaryState("error", 3000);
}
}
function handleDismiss() {
claudeStore.clearQuestion();
claudeStore.addLine("system", "Question dismissed");
characterState.setTemporaryState("idle", 1000);
}
function handleKeydown(event: KeyboardEvent) {
if (!isVisible || !question) return;
if (event.key === "Enter" && !showCustomInput) {
event.preventDefault();
if (selectedOptions.size > 0) {
handleSubmitAndReconnect();
}
} else if (event.key === "Escape") {
event.preventDefault();
handleDismiss();
}
}
function canSubmit(): boolean {
return selectedOptions.size > 0 || (showCustomInput && customAnswer.trim().length > 0);
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isVisible && question}
<div
class="question-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
>
<div
class="question-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl"
>
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
<span class="text-xl">?</span>
</div>
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">
{question.header || "Question"}
</h2>
<p class="text-sm text-[var(--text-secondary)]">Hikari needs your input</p>
</div>
</div>
<div class="mb-4">
<p class="text-[var(--text-primary)]">{question.question}</p>
</div>
<div class="mb-4 space-y-2">
{#each question.options as option (option.label)}
<button
onclick={() => toggleOption(option.label)}
class="w-full text-left px-4 py-3 rounded-lg border transition-colors {selectedOptions.has(
option.label
)
? 'bg-[var(--accent-primary)]/20 border-[var(--accent-primary)] text-[var(--text-primary)]'
: 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50'}"
>
<div class="flex items-start gap-3">
<div
class="mt-0.5 w-5 h-5 rounded-{question.multi_select
? 'sm'
: 'full'} border-2 flex items-center justify-center {selectedOptions.has(
option.label
)
? 'border-[var(--accent-primary)] bg-[var(--accent-primary)]'
: 'border-[var(--text-secondary)]'}"
>
{#if selectedOptions.has(option.label)}
<span class="text-white text-xs">{question.multi_select ? "x" : "x"}</span>
{/if}
</div>
<div class="flex-1">
<div class="font-medium">{option.label}</div>
{#if option.description}
<div class="text-sm text-[var(--text-secondary)] mt-1">{option.description}</div>
{/if}
</div>
</div>
</button>
{/each}
<button
onclick={selectCustom}
class="w-full text-left px-4 py-3 rounded-lg border transition-colors {showCustomInput
? 'bg-[var(--accent-primary)]/20 border-[var(--accent-primary)] text-[var(--text-primary)]'
: 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50'}"
>
<div class="flex items-start gap-3">
<div
class="mt-0.5 w-5 h-5 rounded-full border-2 flex items-center justify-center {showCustomInput
? 'border-[var(--accent-primary)] bg-[var(--accent-primary)]'
: 'border-[var(--text-secondary)]'}"
>
{#if showCustomInput}
<span class="text-white text-xs">x</span>
{/if}
</div>
<div class="flex-1">
<div class="font-medium">Other</div>
<div class="text-sm text-[var(--text-secondary)] mt-1">Provide a custom answer</div>
</div>
</div>
</button>
</div>
{#if showCustomInput}
<div class="mb-4">
<textarea
bind:value={customAnswer}
placeholder="Type your answer here..."
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-secondary)] resize-none focus:outline-none focus:border-[var(--accent-primary)]"
rows="3"
></textarea>
</div>
{/if}
<div class="flex gap-3">
<button
onclick={handleDismiss}
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
>
Dismiss
</button>
<button
onclick={handleSubmitAndReconnect}
disabled={!canSubmit()}
class="flex-1 px-4 py-2 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Answer & Reconnect
</button>
</div>
</div>
</div>
{/if}
+2 -16
View File
@@ -21,7 +21,6 @@ export const claudeStore = {
currentWorkingDirectory: conversationsStore.currentWorkingDirectory, currentWorkingDirectory: conversationsStore.currentWorkingDirectory,
terminalLines: conversationsStore.terminalLines, terminalLines: conversationsStore.terminalLines,
pendingPermission: conversationsStore.pendingPermission, pendingPermission: conversationsStore.pendingPermission,
pendingQuestion: conversationsStore.pendingQuestion,
isProcessing: conversationsStore.isProcessing, isProcessing: conversationsStore.isProcessing,
grantedTools: conversationsStore.grantedTools, grantedTools: conversationsStore.grantedTools,
pendingRetryMessage: conversationsStore.pendingRetryMessage, pendingRetryMessage: conversationsStore.pendingRetryMessage,
@@ -48,12 +47,6 @@ export const claudeStore = {
getConversationHistory: conversationsStore.getConversationHistory, getConversationHistory: conversationsStore.getConversationHistory,
requestPermission: conversationsStore.requestPermission, requestPermission: conversationsStore.requestPermission,
clearPermission: conversationsStore.clearPermission, clearPermission: conversationsStore.clearPermission,
requestPermissionForConversation: conversationsStore.requestPermissionForConversation,
clearPermissionForConversation: conversationsStore.clearPermissionForConversation,
requestQuestion: conversationsStore.requestQuestion,
clearQuestion: conversationsStore.clearQuestion,
requestQuestionForConversation: conversationsStore.requestQuestionForConversation,
clearQuestionForConversation: conversationsStore.clearQuestionForConversation,
grantTool: conversationsStore.grantTool, grantTool: conversationsStore.grantTool,
revokeAllTools: conversationsStore.revokeAllTools, revokeAllTools: conversationsStore.revokeAllTools,
isToolGranted: conversationsStore.isToolGranted, isToolGranted: conversationsStore.isToolGranted,
@@ -64,8 +57,6 @@ export const claudeStore = {
deleteConversation: conversationsStore.deleteConversation, deleteConversation: conversationsStore.deleteConversation,
switchConversation: conversationsStore.switchConversation, switchConversation: conversationsStore.switchConversation,
renameConversation: conversationsStore.renameConversation, renameConversation: conversationsStore.renameConversation,
saveScrollPosition: conversationsStore.saveScrollPosition,
getScrollPosition: conversationsStore.getScrollPosition,
getGrantedTools: (): string[] => { getGrantedTools: (): string[] => {
let tools: string[] = []; let tools: string[] = [];
@@ -92,13 +83,8 @@ export const claudeStore = {
}; };
export const hasPermissionPending = derived( export const hasPermissionPending = derived(
claudeStore.activeConversation, claudeStore.pendingPermission,
($conversation) => $conversation?.pendingPermission !== null ($permission) => $permission !== null
);
export const hasQuestionPending = derived(
claudeStore.activeConversation,
($conversation) => $conversation?.pendingQuestion !== null
); );
// Derived store to check if Claude is currently processing (can be interrupted) // Derived store to check if Claude is currently processing (can be interrupted)
-52
View File
@@ -14,10 +14,6 @@ export interface HikariConfig {
greeting_custom_prompt: string | null; greeting_custom_prompt: string | null;
notifications_enabled: boolean; notifications_enabled: boolean;
notification_volume: number; notification_volume: number;
always_on_top: boolean;
update_checks_enabled: boolean;
character_panel_width: number | null;
font_size: number;
} }
const defaultConfig: HikariConfig = { const defaultConfig: HikariConfig = {
@@ -31,10 +27,6 @@ const defaultConfig: HikariConfig = {
greeting_custom_prompt: null, greeting_custom_prompt: null,
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false,
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
}; };
function createConfigStore() { function createConfigStore() {
@@ -95,33 +87,6 @@ function createConfigStore() {
applyTheme(theme); applyTheme(theme);
}, },
setFontSize: async (size: number) => {
const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
await updateConfig({ font_size: clampedSize });
applyFontSize(clampedSize);
},
increaseFontSize: async () => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2);
await updateConfig({ font_size: newSize });
applyFontSize(newSize);
},
decreaseFontSize: async () => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2);
await updateConfig({ font_size: newSize });
applyFontSize(newSize);
},
resetFontSize: async () => {
await updateConfig({ font_size: DEFAULT_FONT_SIZE });
applyFontSize(DEFAULT_FONT_SIZE);
},
addAutoGrantedTool: async (tool: string) => { addAutoGrantedTool: async (tool: string) => {
let currentConfig: HikariConfig = defaultConfig; let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))(); config.subscribe((c) => (currentConfig = c))();
@@ -152,23 +117,6 @@ export function applyTheme(theme: Theme) {
} }
} }
const MIN_FONT_SIZE = 10;
const MAX_FONT_SIZE = 24;
const DEFAULT_FONT_SIZE = 14;
export function applyFontSize(size: number) {
if (typeof document !== "undefined") {
const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
document.documentElement.style.setProperty("--terminal-font-size", `${clampedSize}px`);
}
}
export function clampFontSize(size: number): number {
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
}
export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
export const configStore = createConfigStore(); export const configStore = createConfigStore();
export const isDarkTheme = derived(configStore.config, ($config) => $config.theme === "dark"); export const isDarkTheme = derived(configStore.config, ($config) => $config.theme === "dark");
+4 -128
View File
@@ -1,10 +1,5 @@
import { writable, derived, get } from "svelte/store"; import { writable, derived, get } from "svelte/store";
import type { import type { TerminalLine, ConnectionStatus, PermissionRequest } from "$lib/types/messages";
TerminalLine,
ConnectionStatus,
PermissionRequest,
UserQuestionEvent,
} from "$lib/types/messages";
import type { CharacterState } from "$lib/types/states"; import type { CharacterState } from "$lib/types/states";
import { cleanupConversationTracking } from "$lib/tauri"; import { cleanupConversationTracking } from "$lib/tauri";
import { characterState } from "$lib/stores/character"; import { characterState } from "$lib/stores/character";
@@ -19,9 +14,6 @@ export interface Conversation {
characterState: CharacterState; characterState: CharacterState;
isProcessing: boolean; isProcessing: boolean;
grantedTools: Set<string>; grantedTools: Set<string>;
pendingPermission: PermissionRequest | null;
pendingQuestion: UserQuestionEvent | null;
scrollPosition: number;
createdAt: Date; createdAt: Date;
lastActivityAt: Date; lastActivityAt: Date;
} }
@@ -29,6 +21,7 @@ export interface Conversation {
function createConversationsStore() { function createConversationsStore() {
const conversations = writable<Map<string, Conversation>>(new Map()); const conversations = writable<Map<string, Conversation>>(new Map());
const activeConversationId = writable<string | null>(null); const activeConversationId = writable<string | null>(null);
const pendingPermission = writable<PermissionRequest | null>(null);
const pendingRetryMessage = writable<string | null>(null); const pendingRetryMessage = writable<string | null>(null);
let conversationCounter = 0; let conversationCounter = 0;
@@ -54,9 +47,6 @@ function createConversationsStore() {
characterState: "idle", characterState: "idle",
isProcessing: false, isProcessing: false,
grantedTools: new Set(), grantedTools: new Set(),
pendingPermission: null,
pendingQuestion: null,
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
createdAt: new Date(), createdAt: new Date(),
lastActivityAt: new Date(), lastActivityAt: new Date(),
}; };
@@ -103,12 +93,6 @@ function createConversationsStore() {
activeConversation, activeConversation,
($conv) => $conv?.grantedTools || new Set<string>() ($conv) => $conv?.grantedTools || new Set<string>()
); );
const pendingPermission = derived(
activeConversation,
($conv) => $conv?.pendingPermission || null
);
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
return { return {
// Expose derived stores for compatibility // Expose derived stores for compatibility
@@ -117,11 +101,9 @@ function createConversationsStore() {
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe }, currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
terminalLines: { subscribe: terminalLines.subscribe }, terminalLines: { subscribe: terminalLines.subscribe },
pendingPermission: { subscribe: pendingPermission.subscribe }, pendingPermission: { subscribe: pendingPermission.subscribe },
pendingQuestion: { subscribe: pendingQuestion.subscribe },
isProcessing: { subscribe: isProcessing.subscribe }, isProcessing: { subscribe: isProcessing.subscribe },
grantedTools: { subscribe: grantedTools.subscribe }, grantedTools: { subscribe: grantedTools.subscribe },
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe }, pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
scrollPosition: { subscribe: scrollPosition.subscribe },
// New conversation-specific stores // New conversation-specific stores
conversations: { subscribe: conversations.subscribe }, conversations: { subscribe: conversations.subscribe },
@@ -166,98 +148,8 @@ function createConversationsStore() {
return convs; return convs;
}); });
}, },
requestPermission: (request: PermissionRequest) => { requestPermission: (request: PermissionRequest) => pendingPermission.set(request),
const activeId = get(activeConversationId); clearPermission: () => pendingPermission.set(null),
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingPermission = request;
conv.lastActivityAt = new Date();
}
return convs;
});
},
clearPermission: () => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingPermission = null;
conv.lastActivityAt = new Date();
}
return convs;
});
},
requestPermissionForConversation: (conversationId: string, request: PermissionRequest) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.pendingPermission = request;
conv.lastActivityAt = new Date();
}
return convs;
});
},
clearPermissionForConversation: (conversationId: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.pendingPermission = null;
conv.lastActivityAt = new Date();
}
return convs;
});
},
requestQuestion: (question: UserQuestionEvent) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingQuestion = question;
conv.lastActivityAt = new Date();
}
return convs;
});
},
clearQuestion: () => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingQuestion = null;
conv.lastActivityAt = new Date();
}
return convs;
});
},
requestQuestionForConversation: (conversationId: string, question: UserQuestionEvent) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.pendingQuestion = question;
conv.lastActivityAt = new Date();
}
return convs;
});
},
clearQuestionForConversation: (conversationId: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.pendingQuestion = null;
conv.lastActivityAt = new Date();
}
return convs;
});
},
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message), setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
// Conversation management // Conversation management
@@ -329,22 +221,6 @@ function createConversationsStore() {
}); });
}, },
saveScrollPosition: (id: string, position: number) => {
conversations.update((convs) => {
const conv = convs.get(id);
if (conv) {
conv.scrollPosition = position;
}
return convs;
});
},
getScrollPosition: (id: string): number => {
const convs = get(conversations);
const conv = convs.get(id);
return conv?.scrollPosition ?? -1;
},
// Methods that operate on the active conversation // Methods that operate on the active conversation
setSessionId: (id: string | null) => { setSessionId: (id: string | null) => {
ensureInitialized(); ensureInitialized();
-63
View File
@@ -1,63 +0,0 @@
import { writable, derived } from "svelte/store";
interface SearchState {
query: string;
isActive: boolean;
matchCount: number;
currentMatchIndex: number;
}
const initialState: SearchState = {
query: "",
isActive: false,
matchCount: 0,
currentMatchIndex: 0,
};
const searchStore = writable<SearchState>(initialState);
export const searchState = {
subscribe: searchStore.subscribe,
setQuery: (query: string) => {
searchStore.update((state) => ({
...state,
query,
isActive: query.length > 0,
currentMatchIndex: 0,
}));
},
setMatchCount: (count: number) => {
searchStore.update((state) => ({
...state,
matchCount: count,
}));
},
nextMatch: () => {
searchStore.update((state) => ({
...state,
currentMatchIndex:
state.matchCount > 0 ? (state.currentMatchIndex + 1) % state.matchCount : 0,
}));
},
previousMatch: () => {
searchStore.update((state) => ({
...state,
currentMatchIndex:
state.matchCount > 0
? (state.currentMatchIndex - 1 + state.matchCount) % state.matchCount
: 0,
}));
},
clear: () => {
searchStore.set(initialState);
},
};
export const isSearchActive = derived(searchStore, ($search) => $search.isActive);
export const searchQuery = derived(searchStore, ($search) => $search.query);
+14 -39
View File
@@ -6,11 +6,7 @@ import { characterState } from "$lib/stores/character";
import { configStore } from "$lib/stores/config"; import { configStore } from "$lib/stores/config";
import { initStatsListener, resetSessionStats } from "$lib/stores/stats"; import { initStatsListener, resetSessionStats } from "$lib/stores/stats";
import { initAchievementsListener } from "$lib/stores/achievements"; import { initAchievementsListener } from "$lib/stores/achievements";
import type { import type { ConnectionStatus, PermissionPromptEvent } from "$lib/types/messages";
ConnectionStatus,
PermissionPromptEvent,
UserQuestionEvent,
} from "$lib/types/messages";
import type { CharacterState } from "$lib/types/states"; import type { CharacterState } from "$lib/types/states";
import { import {
initializeNotificationRules, initializeNotificationRules,
@@ -296,50 +292,29 @@ export async function initializeTauriListeners() {
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => { const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
const { id, tool_name, tool_input, description, conversation_id } = event.payload; const { id, tool_name, tool_input, description, conversation_id } = event.payload;
// Store permission request for the specific conversation // Only process permission requests for the active conversation
if (conversation_id) { const activeConversationId = get(claudeStore.activeConversationId);
claudeStore.requestPermissionForConversation(conversation_id, { if (conversation_id === activeConversationId) {
id,
tool: tool_name,
description,
input: tool_input,
});
claudeStore.addLineToConversation(
conversation_id,
"system",
`Permission requested for: ${tool_name}`
);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.requestPermission({ claudeStore.requestPermission({
id, id,
tool: tool_name, tool: tool_name,
description, description,
input: tool_input, input: tool_input,
}); });
}
// Always store the permission message to the correct conversation
if (conversation_id) {
claudeStore.addLineToConversation(
conversation_id,
"system",
`Permission requested for: ${tool_name}`
);
} else if (conversation_id === activeConversationId) {
claudeStore.addLine("system", `Permission requested for: ${tool_name}`); claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
} }
}); });
unlisteners.push(permissionUnlisten); unlisteners.push(permissionUnlisten);
const questionUnlisten = await listen<UserQuestionEvent>("claude:question", (event) => {
const questionEvent = event.payload;
// Store question request for the specific conversation
if (questionEvent.conversation_id) {
claudeStore.requestQuestionForConversation(questionEvent.conversation_id, questionEvent);
claudeStore.addLineToConversation(
questionEvent.conversation_id,
"system",
`Question: ${questionEvent.question}`
);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.requestQuestion(questionEvent);
claudeStore.addLine("system", `Question: ${questionEvent.question}`);
}
});
unlisteners.push(questionUnlisten);
} }
export function cleanupTauriListeners() { export function cleanupTauriListeners() {
-22
View File
@@ -126,26 +126,4 @@ export interface PermissionPromptEvent {
conversation_id?: string; conversation_id?: string;
} }
export interface QuestionOption {
label: string;
description?: string;
}
export interface UserQuestionEvent {
id: string;
question: string;
header?: string;
options: QuestionOption[];
multi_select: boolean;
conversation_id?: string;
}
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"; export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
export interface UpdateInfo {
current_version: string;
latest_version: string;
has_update: boolean;
release_url: string;
release_notes?: string;
}
+5 -159
View File
@@ -1,140 +1,22 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri"; import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
import { configStore, applyTheme, applyFontSize } from "$lib/stores/config"; import { configStore, applyTheme } 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";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
import { getCurrentWindow } from "@tauri-apps/api/window";
import "$lib/notifications/testNotifications"; import "$lib/notifications/testNotifications";
import Terminal from "$lib/components/Terminal.svelte"; import Terminal from "$lib/components/Terminal.svelte";
import InputBar from "$lib/components/InputBar.svelte"; import InputBar from "$lib/components/InputBar.svelte";
import StatusBar from "$lib/components/StatusBar.svelte"; import StatusBar from "$lib/components/StatusBar.svelte";
import AnimeGirl from "$lib/components/AnimeGirl.svelte"; import AnimeGirl from "$lib/components/AnimeGirl.svelte";
import PermissionModal from "$lib/components/PermissionModal.svelte"; import PermissionModal from "$lib/components/PermissionModal.svelte";
import UserQuestionModal from "$lib/components/UserQuestionModal.svelte";
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte"; import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
import AchievementNotification from "$lib/components/AchievementNotification.svelte"; import AchievementNotification from "$lib/components/AchievementNotification.svelte";
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte"; import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
import UpdateNotification from "$lib/components/UpdateNotification.svelte";
let initialized = false; let initialized = false;
let updateNotification: UpdateNotification;
let achievementPanelOpen = $state(false); let achievementPanelOpen = $state(false);
// Resizable panel state
let panelWidth = $state(320); // Default width in pixels
let isResizing = $state(false);
const MIN_PANEL_WIDTH = 200;
const MAX_PANEL_WIDTH = 600;
function startResize(event: MouseEvent) {
isResizing = true;
event.preventDefault();
document.addEventListener("mousemove", handleResize);
document.addEventListener("mouseup", stopResize);
}
function handleResize(event: MouseEvent) {
if (!isResizing) return;
const newWidth = event.clientX;
panelWidth = Math.max(MIN_PANEL_WIDTH, Math.min(MAX_PANEL_WIDTH, newWidth));
}
function stopResize() {
if (isResizing) {
isResizing = false;
document.removeEventListener("mousemove", handleResize);
document.removeEventListener("mouseup", stopResize);
// Save the panel width to config
configStore.updateConfig({ character_panel_width: panelWidth });
}
}
// Global keyboard shortcuts
function handleGlobalKeydown(event: KeyboardEvent) {
// Don't trigger shortcuts when typing in inputs (except for specific ones)
const target = event.target as HTMLElement;
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA";
// Escape closes panels (always works)
if (event.key === "Escape") {
// Check if any panels are open and close them
if (achievementPanelOpen) {
achievementPanelOpen = false;
event.preventDefault();
return;
}
// ConfigSidebar handles its own escape via store
if (get(configStore.isSidebarOpen)) {
configStore.closeSidebar();
event.preventDefault();
return;
}
}
// Skip other shortcuts if user is typing in an input
if (isInputFocused) return;
// Ctrl+L - Clear terminal
if (event.ctrlKey && event.key === "l") {
event.preventDefault();
claudeStore.clearTerminal();
return;
}
// Ctrl+, - Open settings
if (event.ctrlKey && event.key === ",") {
event.preventDefault();
configStore.openSidebar();
return;
}
// Ctrl+C - Interrupt (only when processing)
if (event.ctrlKey && event.key === "c") {
if (get(isClaudeProcessing)) {
event.preventDefault();
handleInterrupt();
return;
}
}
// Ctrl++ or Ctrl+= - Increase font size
if (event.ctrlKey && (event.key === "+" || event.key === "=")) {
event.preventDefault();
configStore.increaseFontSize();
return;
}
// Ctrl+- - Decrease font size
if (event.ctrlKey && event.key === "-") {
event.preventDefault();
configStore.decreaseFontSize();
return;
}
// Ctrl+0 - Reset font size
if (event.ctrlKey && event.key === "0") {
event.preventDefault();
configStore.resetFontSize();
return;
}
}
async function handleInterrupt() {
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted");
} catch (error) {
console.error("Failed to interrupt:", error);
}
}
onMount(async () => { onMount(async () => {
if (!initialized) { if (!initialized) {
initialized = true; initialized = true;
@@ -145,32 +27,12 @@
await initializeTauriListeners(); await initializeTauriListeners();
await configStore.loadConfig(); await configStore.loadConfig();
// Apply saved settings on startup // Apply saved theme on startup
const config = configStore.getConfig(); const config = configStore.getConfig();
applyTheme(config.theme); applyTheme(config.theme);
applyFontSize(config.font_size);
// Apply always-on-top setting
if (config.always_on_top) {
const window = getCurrentWindow();
await window.setAlwaysOnTop(true);
}
// Load saved panel width
if (config.character_panel_width) {
panelWidth = config.character_panel_width;
}
// Initialize notification settings sync // Initialize notification settings sync
initNotificationSync(); initNotificationSync();
// Add global keyboard shortcut listener
window.addEventListener("keydown", handleGlobalKeydown);
// Check for updates on startup
if (config.update_checks_enabled) {
updateNotification?.checkForUpdates();
}
} }
}); });
@@ -178,7 +40,6 @@
if (initialized) { if (initialized) {
cleanupTauriListeners(); cleanupTauriListeners();
cleanupNotificationSync(); cleanupNotificationSync();
window.removeEventListener("keydown", handleGlobalKeydown);
initialized = false; initialized = false;
} }
}); });
@@ -190,36 +51,25 @@
<main class="flex-1 flex overflow-hidden"> <main class="flex-1 flex overflow-hidden">
<!-- Left panel: Character display --> <!-- Left panel: Character display -->
<div <div
class="character-panel flex flex-col items-center justify-center bg-[var(--bg-secondary)]/50" class="character-panel w-1/3 flex flex-col items-center justify-center border-r border-[var(--border-color)] bg-[var(--bg-secondary)]/50"
style="width: {panelWidth}px; min-width: {MIN_PANEL_WIDTH}px; max-width: {MAX_PANEL_WIDTH}px;"
> >
<AnimeGirl /> <AnimeGirl />
</div> </div>
<!-- Resize handle -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="resize-handle w-1 cursor-col-resize bg-[var(--border-color)] hover:bg-[var(--accent-primary)] transition-colors flex-shrink-0"
class:bg-[var(--accent-primary)]={isResizing}
onmousedown={startResize}
></div>
<!-- Right panel: Terminal and input --> <!-- Right panel: Terminal and input -->
<div class="terminal-panel flex-1 flex flex-col min-w-0"> <div class="terminal-panel flex-1 flex flex-col">
<Terminal /> <Terminal />
<InputBar /> <InputBar />
</div> </div>
</main> </main>
<PermissionModal /> <PermissionModal />
<UserQuestionModal />
<ConfigSidebar /> <ConfigSidebar />
<AchievementNotification /> <AchievementNotification />
<AchievementsPanel <AchievementsPanel
bind:isOpen={achievementPanelOpen} bind:isOpen={achievementPanelOpen}
onClose={() => (achievementPanelOpen = false)} onClose={() => (achievementPanelOpen = false)}
/> />
<UpdateNotification bind:this={updateNotification} />
</div> </div>
<style> <style>
@@ -234,11 +84,7 @@
} }
.character-panel { .character-panel {
min-width: 320px;
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%); background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%);
} }
.resize-handle:hover,
.resize-handle:active {
width: 4px;
}
</style> </style>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 688 KiB

After

Width:  |  Height:  |  Size: 690 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 KiB

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 676 KiB

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 775 KiB

After

Width:  |  Height:  |  Size: 778 KiB