Compare commits

..

1 Commits

Author SHA1 Message Date
minori 9afab03047 deps: update jsdom to 28.0.0
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m15s
CI / Lint & Test (pull_request) Successful in 16m55s
CI / Build Linux (pull_request) Successful in 19m49s
CI / Build Windows (cross-compile) (pull_request) Successful in 29m42s
2026-02-12 07:11:07 -08:00
32 changed files with 152 additions and 1923 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "hikari-desktop", "name": "hikari-desktop",
"version": "1.7.0", "version": "1.5.1",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -81,7 +81,7 @@
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.14.0", "eslint-plugin-svelte": "^3.14.0",
"globals": "^17.0.0", "globals": "^17.0.0",
"jsdom": "^27.4.0", "jsdom": "28.0.0",
"prettier": "^3.8.0", "prettier": "^3.8.0",
"prettier-plugin-svelte": "^3.4.1", "prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.0.0", "svelte": "^5.0.0",
+52 -57
View File
@@ -149,10 +149,10 @@ importers:
version: 6.9.1 version: 6.9.1
'@testing-library/svelte': '@testing-library/svelte':
specifier: ^5.3.1 specifier: ^5.3.1
version: 5.3.1(svelte@5.46.3)(vite@6.4.1(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)) version: 5.3.1(svelte@5.46.3)(vite@6.4.1(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.17(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2))
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: ^4.0.18 specifier: ^4.0.18
version: 4.0.18(vitest@4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)) version: 4.0.18(vitest@4.0.17(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2))
eslint: eslint:
specifier: ^9.39.2 specifier: ^9.39.2
version: 9.39.2(jiti@2.6.1) version: 9.39.2(jiti@2.6.1)
@@ -166,8 +166,8 @@ importers:
specifier: ^17.0.0 specifier: ^17.0.0
version: 17.0.0 version: 17.0.0
jsdom: jsdom:
specifier: ^27.4.0 specifier: 28.0.0
version: 27.4.0 version: 28.0.0
prettier: prettier:
specifier: ^3.8.0 specifier: ^3.8.0
version: 3.8.0 version: 3.8.0
@@ -194,7 +194,7 @@ importers:
version: 6.4.1(jiti@2.6.1)(lightningcss@1.30.2) version: 6.4.1(jiti@2.6.1)(lightningcss@1.30.2)
vitest: vitest:
specifier: ^4.0.17 specifier: ^4.0.17
version: 4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2) version: 4.0.17(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)
packages: packages:
@@ -552,13 +552,13 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@exodus/bytes@1.8.0': '@exodus/bytes@1.14.0':
resolution: {integrity: sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ==} resolution: {integrity: sha512-YiY1OmY6Qhkvmly8vZiD8wZRpW/npGZNg+0Sk8mstxirRHCg6lolHt5tSODCfuNPE/fBsAqRwDJE417x7jDDHA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies: peerDependencies:
'@exodus/crypto': ^1.0.0-rc.4 '@noble/hashes': ^1.8.0 || ^2.0.0
peerDependenciesMeta: peerDependenciesMeta:
'@exodus/crypto': '@noble/hashes':
optional: true optional: true
'@humanfs/core@0.19.1': '@humanfs/core@0.19.1':
@@ -1277,9 +1277,9 @@ packages:
resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
data-urls@6.0.0: data-urls@7.0.0:
resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: '>=20'} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
debug@4.4.3: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
@@ -1551,8 +1551,8 @@ packages:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true hasBin: true
jsdom@27.4.0: jsdom@28.0.0:
resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==} resolution: {integrity: sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies: peerDependencies:
canvas: ^3.0.0 canvas: ^3.0.0
@@ -1990,6 +1990,10 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
undici@7.21.0:
resolution: {integrity: sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==}
engines: {node: '>=20.18.1'}
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -2089,14 +2093,14 @@ packages:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
whatwg-mimetype@4.0.0: whatwg-mimetype@5.0.0:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=18'}
whatwg-url@15.1.0:
resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==}
engines: {node: '>=20'} engines: {node: '>=20'}
whatwg-url@16.0.0:
resolution: {integrity: sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
which@2.0.2: which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -2111,18 +2115,6 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
ws@8.19.0:
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
xml-name-validator@5.0.0: xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -2544,7 +2536,7 @@ snapshots:
'@eslint/core': 0.17.0 '@eslint/core': 0.17.0
levn: 0.4.1 levn: 0.4.1
'@exodus/bytes@1.8.0': {} '@exodus/bytes@1.14.0': {}
'@humanfs/core@0.19.1': {} '@humanfs/core@0.19.1': {}
@@ -2974,14 +2966,14 @@ snapshots:
dependencies: dependencies:
svelte: 5.46.3 svelte: 5.46.3
'@testing-library/svelte@5.3.1(svelte@5.46.3)(vite@6.4.1(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2))': '@testing-library/svelte@5.3.1(svelte@5.46.3)(vite@6.4.1(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.17(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2))':
dependencies: dependencies:
'@testing-library/dom': 10.4.1 '@testing-library/dom': 10.4.1
'@testing-library/svelte-core': 1.0.0(svelte@5.46.3) '@testing-library/svelte-core': 1.0.0(svelte@5.46.3)
svelte: 5.46.3 svelte: 5.46.3
optionalDependencies: optionalDependencies:
vite: 6.4.1(jiti@2.6.1)(lightningcss@1.30.2) vite: 6.4.1(jiti@2.6.1)(lightningcss@1.30.2)
vitest: 4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2) vitest: 4.0.17(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)
'@types/aria-query@5.0.4': {} '@types/aria-query@5.0.4': {}
@@ -3089,7 +3081,7 @@ snapshots:
'@typescript-eslint/types': 8.53.0 '@typescript-eslint/types': 8.53.0
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
'@vitest/coverage-v8@4.0.18(vitest@4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2))': '@vitest/coverage-v8@4.0.18(vitest@4.0.17(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2))':
dependencies: dependencies:
'@bcoe/v8-coverage': 1.0.2 '@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.0.18 '@vitest/utils': 4.0.18
@@ -3101,7 +3093,7 @@ snapshots:
obug: 2.1.1 obug: 2.1.1
std-env: 3.10.0 std-env: 3.10.0
tinyrainbow: 3.0.3 tinyrainbow: 3.0.3
vitest: 4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2) vitest: 4.0.17(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)
'@vitest/expect@4.0.17': '@vitest/expect@4.0.17':
dependencies: dependencies:
@@ -3266,10 +3258,12 @@ snapshots:
css-tree: 3.1.0 css-tree: 3.1.0
lru-cache: 11.2.4 lru-cache: 11.2.4
data-urls@6.0.0: data-urls@7.0.0:
dependencies: dependencies:
whatwg-mimetype: 4.0.0 whatwg-mimetype: 5.0.0
whatwg-url: 15.1.0 whatwg-url: 16.0.0
transitivePeerDependencies:
- '@noble/hashes'
debug@4.4.3: debug@4.4.3:
dependencies: dependencies:
@@ -3480,9 +3474,9 @@ snapshots:
html-encoding-sniffer@6.0.0: html-encoding-sniffer@6.0.0:
dependencies: dependencies:
'@exodus/bytes': 1.8.0 '@exodus/bytes': 1.14.0
transitivePeerDependencies: transitivePeerDependencies:
- '@exodus/crypto' - '@noble/hashes'
html-escaper@2.0.2: {} html-escaper@2.0.2: {}
@@ -3550,13 +3544,13 @@ snapshots:
dependencies: dependencies:
argparse: 2.0.1 argparse: 2.0.1
jsdom@27.4.0: jsdom@28.0.0:
dependencies: dependencies:
'@acemir/cssom': 0.9.31 '@acemir/cssom': 0.9.31
'@asamuzakjp/dom-selector': 6.7.6 '@asamuzakjp/dom-selector': 6.7.6
'@exodus/bytes': 1.8.0 '@exodus/bytes': 1.14.0
cssstyle: 5.3.7 cssstyle: 5.3.7
data-urls: 6.0.0 data-urls: 7.0.0
decimal.js: 10.6.0 decimal.js: 10.6.0
html-encoding-sniffer: 6.0.0 html-encoding-sniffer: 6.0.0
http-proxy-agent: 7.0.2 http-proxy-agent: 7.0.2
@@ -3566,17 +3560,15 @@ snapshots:
saxes: 6.0.0 saxes: 6.0.0
symbol-tree: 3.2.4 symbol-tree: 3.2.4
tough-cookie: 6.0.0 tough-cookie: 6.0.0
undici: 7.21.0
w3c-xmlserializer: 5.0.0 w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1 webidl-conversions: 8.0.1
whatwg-mimetype: 4.0.0 whatwg-mimetype: 5.0.0
whatwg-url: 15.1.0 whatwg-url: 16.0.0
ws: 8.19.0
xml-name-validator: 5.0.0 xml-name-validator: 5.0.0
transitivePeerDependencies: transitivePeerDependencies:
- '@exodus/crypto' - '@noble/hashes'
- bufferutil
- supports-color - supports-color
- utf-8-validate
json-buffer@3.0.1: {} json-buffer@3.0.1: {}
@@ -3965,6 +3957,8 @@ snapshots:
typescript@5.6.3: {} typescript@5.6.3: {}
undici@7.21.0: {}
uri-js@4.4.1: uri-js@4.4.1:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
@@ -3988,7 +3982,7 @@ snapshots:
optionalDependencies: optionalDependencies:
vite: 6.4.1(jiti@2.6.1)(lightningcss@1.30.2) vite: 6.4.1(jiti@2.6.1)(lightningcss@1.30.2)
vitest@4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2): vitest@4.0.17(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2):
dependencies: dependencies:
'@vitest/expect': 4.0.17 '@vitest/expect': 4.0.17
'@vitest/mocker': 4.0.17(vite@6.4.1(jiti@2.6.1)(lightningcss@1.30.2)) '@vitest/mocker': 4.0.17(vite@6.4.1(jiti@2.6.1)(lightningcss@1.30.2))
@@ -4011,7 +4005,7 @@ snapshots:
vite: 6.4.1(jiti@2.6.1)(lightningcss@1.30.2) vite: 6.4.1(jiti@2.6.1)(lightningcss@1.30.2)
why-is-node-running: 2.3.0 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
jsdom: 27.4.0 jsdom: 28.0.0
transitivePeerDependencies: transitivePeerDependencies:
- jiti - jiti
- less - less
@@ -4033,12 +4027,15 @@ snapshots:
webidl-conversions@8.0.1: {} webidl-conversions@8.0.1: {}
whatwg-mimetype@4.0.0: {} whatwg-mimetype@5.0.0: {}
whatwg-url@15.1.0: whatwg-url@16.0.0:
dependencies: dependencies:
'@exodus/bytes': 1.14.0
tr46: 6.0.0 tr46: 6.0.0
webidl-conversions: 8.0.1 webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
which@2.0.2: which@2.0.2:
dependencies: dependencies:
@@ -4051,8 +4048,6 @@ snapshots:
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
ws@8.19.0: {}
xml-name-validator@5.0.0: {} xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {} xmlchars@2.2.0: {}
+1 -1
View File
@@ -1636,7 +1636,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]] [[package]]
name = "hikari-desktop" name = "hikari-desktop"
version = "1.7.0" version = "1.5.1"
dependencies = [ dependencies = [
"chrono", "chrono",
"dirs 5.0.1", "dirs 5.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "hikari-desktop" name = "hikari-desktop"
version = "1.7.0" version = "1.5.1"
description = "Hikari - Claude Code Visual Assistant" description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"] authors = ["Naomi Carrigan"]
edition = "2021" edition = "2021"
-130
View File
@@ -1360,136 +1360,6 @@ pub async fn get_claude_version() -> Result<String, String> {
} }
} }
// ==================== Auth Commands ====================
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeAuthStatus {
pub is_logged_in: bool,
pub email: Option<String>,
pub org_name: Option<String>,
pub api_key_source: Option<String>,
pub api_provider: Option<String>,
pub subscription_type: Option<String>,
}
#[tauri::command]
pub async fn get_auth_status() -> Result<ClaudeAuthStatus, String> {
tracing::debug!("Getting Claude auth status");
let output = create_claude_command()
.args(["auth", "status"])
.output()
.map_err(|e| format!("Failed to run claude auth status: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if let Ok(json) = serde_json::from_str::<serde_json::Value>(raw) {
let is_logged_in = json
.get("loggedIn")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let email = json
.get("email")
.and_then(|v| v.as_str())
.map(String::from);
let org_name = json
.get("orgName")
.and_then(|v| v.as_str())
.map(String::from);
let api_key_source = json
.get("apiKeySource")
.and_then(|v| v.as_str())
.map(String::from);
let api_provider = json
.get("apiProvider")
.and_then(|v| v.as_str())
.map(String::from);
let subscription_type = json
.get("subscriptionType")
.and_then(|v| v.as_str())
.map(String::from);
tracing::info!("Claude auth status: logged_in={}", is_logged_in);
Ok(ClaudeAuthStatus {
is_logged_in,
email,
org_name,
api_key_source,
api_provider,
subscription_type,
})
} else {
// Non-JSON output: fall back to heuristic
let lower = raw.to_lowercase();
let is_logged_in = output.status.success()
&& !lower.contains("not logged in")
&& !lower.contains("not authenticated")
&& !lower.contains("no account");
tracing::info!("Claude auth status (non-JSON): logged_in={}", is_logged_in);
Ok(ClaudeAuthStatus {
is_logged_in,
email: None,
org_name: None,
api_key_source: None,
api_provider: None,
subscription_type: None,
})
}
}
#[tauri::command]
pub async fn auth_login() -> Result<String, String> {
tracing::info!("Running claude auth login");
let output = create_claude_command()
.args(["auth", "login"])
.output()
.map_err(|e| format!("Failed to run claude auth login: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if output.status.success() {
let message = if stdout.is_empty() { "Login successful".to_string() } else { stdout };
tracing::info!("Claude auth login succeeded");
Ok(message)
} else {
let error = if stderr.is_empty() { stdout } else { stderr };
tracing::error!("Claude auth login failed: {}", error);
Err(format!("Login failed: {}", error))
}
}
#[tauri::command]
pub async fn auth_logout() -> Result<String, String> {
tracing::info!("Running claude auth logout");
let output = create_claude_command()
.args(["auth", "logout"])
.output()
.map_err(|e| format!("Failed to run claude auth logout: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if output.status.success() {
let message = if stdout.is_empty() { "Logged out successfully".to_string() } else { stdout };
tracing::info!("Claude auth logout succeeded");
Ok(message)
} else {
let error = if stderr.is_empty() { stdout } else { stderr };
tracing::error!("Claude auth logout failed: {}", error);
Err(format!("Logout failed: {}", error))
}
}
// ==================== Plugin Management Commands ==================== // ==================== Plugin Management Commands ====================
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
-18
View File
@@ -25,12 +25,6 @@ pub struct ClaudeStartOptions {
#[serde(default)] #[serde(default)]
pub resume_session_id: Option<String>, pub resume_session_id: Option<String>,
#[serde(default)]
pub use_worktree: bool,
#[serde(default)]
pub disable_1m_context: bool,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -119,12 +113,6 @@ pub struct HikariConfig {
#[serde(default = "default_discord_rpc_enabled")] #[serde(default = "default_discord_rpc_enabled")]
pub discord_rpc_enabled: bool, pub discord_rpc_enabled: bool,
#[serde(default)]
pub use_worktree: bool,
#[serde(default)]
pub disable_1m_context: bool,
} }
impl Default for HikariConfig { impl Default for HikariConfig {
@@ -157,8 +145,6 @@ impl Default for HikariConfig {
budget_action: BudgetAction::Warn, budget_action: BudgetAction::Warn,
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
use_worktree: false,
disable_1m_context: false,
} }
} }
} }
@@ -266,8 +252,6 @@ mod tests {
assert_eq!(config.budget_action, BudgetAction::Warn); assert_eq!(config.budget_action, BudgetAction::Warn);
assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON); assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON);
assert!(config.discord_rpc_enabled); assert!(config.discord_rpc_enabled);
assert!(!config.use_worktree);
assert!(!config.disable_1m_context);
} }
#[test] #[test]
@@ -300,8 +284,6 @@ mod tests {
budget_action: BudgetAction::Block, budget_action: BudgetAction::Block,
budget_warning_threshold: 0.75, budget_warning_threshold: 0.75,
discord_rpc_enabled: true, discord_rpc_enabled: true,
use_worktree: true,
disable_1m_context: false,
}; };
let json = serde_json::to_string(&config).unwrap(); let json = serde_json::to_string(&config).unwrap();
-3
View File
@@ -195,9 +195,6 @@ pub fn run() {
close_application, close_application,
list_memory_files, list_memory_files,
get_claude_version, get_claude_version,
get_auth_status,
auth_login,
auth_logout,
list_plugins, list_plugins,
install_plugin, install_plugin,
uninstall_plugin, uninstall_plugin,
+1 -3
View File
@@ -86,9 +86,8 @@ impl ContextWarning {
/// Get the context window limit (in tokens) for a given model /// Get the context window limit (in tokens) for a given model
fn get_context_window_limit(model: &str) -> u64 { fn get_context_window_limit(model: &str) -> u64 {
match model { match model {
// Claude 4.6 family // Claude 4.6 family - 200K standard (1M beta available via header)
"claude-opus-4-6" => 200_000, "claude-opus-4-6" => 200_000,
"claude-sonnet-4-6" => 1_000_000, // 1M token context window
// Claude 4.5 family - 200K standard context // Claude 4.5 family - 200K standard context
"claude-opus-4-5-20251101" "claude-opus-4-5-20251101"
| "claude-sonnet-4-5-20250929" | "claude-sonnet-4-5-20250929"
@@ -503,7 +502,6 @@ pub fn calculate_cost(
let (input_price_per_million, output_price_per_million) = match model { let (input_price_per_million, output_price_per_million) = match model {
// Current generation (Claude 4.6) // Current generation (Claude 4.6)
"claude-opus-4-6" => (5.0, 25.0), "claude-opus-4-6" => (5.0, 25.0),
"claude-sonnet-4-6" => (3.0, 15.0),
// Previous generation (Claude 4.5) // Previous generation (Claude 4.5)
"claude-opus-4-5-20251101" => (5.0, 25.0), "claude-opus-4-5-20251101" => (5.0, 25.0),
-100
View File
@@ -63,26 +63,6 @@ pub struct PermissionDenial {
pub tool_input: serde_json::Value, pub tool_input: serde_json::Value,
} }
/// Rate limit information from a `rate_limit_event` message.
/// All fields are optional to ensure forward-compatibility as the Claude CLI evolves.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RateLimitInfo {
#[serde(default)]
pub requests_limit: Option<u64>,
#[serde(default)]
pub requests_remaining: Option<u64>,
#[serde(default)]
pub requests_reset: Option<String>,
#[serde(default)]
pub tokens_limit: Option<u64>,
#[serde(default)]
pub tokens_remaining: Option<u64>,
#[serde(default)]
pub tokens_reset: Option<String>,
#[serde(default)]
pub retry_after_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum ClaudeMessage { pub enum ClaudeMessage {
@@ -120,11 +100,6 @@ pub enum ClaudeMessage {
#[serde(default)] #[serde(default)]
usage: Option<UsageInfo>, usage: Option<UsageInfo>,
}, },
#[serde(rename = "rate_limit_event")]
RateLimitEvent {
#[serde(default)]
rate_limit_info: RateLimitInfo,
},
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -305,8 +280,6 @@ pub struct AgentEndEvent {
pub duration_ms: Option<u64>, pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub num_turns: Option<u32>, pub num_turns: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_assistant_message: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -473,77 +446,4 @@ mod tests {
assert!(serialized.contains("\"input_tokens\":100")); assert!(serialized.contains("\"input_tokens\":100"));
assert!(serialized.contains("\"output_tokens\":50")); assert!(serialized.contains("\"output_tokens\":50"));
} }
#[test]
fn test_rate_limit_info_default() {
let info = RateLimitInfo::default();
assert!(info.requests_limit.is_none());
assert!(info.requests_remaining.is_none());
assert!(info.requests_reset.is_none());
assert!(info.tokens_limit.is_none());
assert!(info.tokens_remaining.is_none());
assert!(info.tokens_reset.is_none());
assert!(info.retry_after_ms.is_none());
}
#[test]
fn test_rate_limit_event_deserialization_empty_info() {
let json = r#"{"type":"rate_limit_event","rate_limit_info":{}}"#;
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
}
#[test]
fn test_rate_limit_event_deserialization_no_info() {
// rate_limit_info field is optional via #[serde(default)]
let json = r#"{"type":"rate_limit_event"}"#;
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
}
#[test]
fn test_rate_limit_event_deserialization_with_data() {
let json = r#"{
"type": "rate_limit_event",
"rate_limit_info": {
"requests_limit": 1000,
"requests_remaining": 0,
"requests_reset": "2024-01-01T00:01:00Z",
"tokens_limit": 50000,
"tokens_remaining": 0,
"tokens_reset": "2024-01-01T00:01:00Z",
"retry_after_ms": 60000
}
}"#;
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
assert_eq!(rate_limit_info.requests_limit, Some(1000));
assert_eq!(rate_limit_info.requests_remaining, Some(0));
assert_eq!(
rate_limit_info.requests_reset,
Some("2024-01-01T00:01:00Z".to_string())
);
assert_eq!(rate_limit_info.retry_after_ms, Some(60000));
} else {
panic!("Expected RateLimitEvent variant");
}
}
#[test]
fn test_rate_limit_event_ignores_unknown_fields() {
// Ensures forward-compat: unknown fields in rate_limit_info are silently ignored
let json = r#"{
"type": "rate_limit_event",
"rate_limit_info": {
"requests_remaining": 0,
"some_future_field": "some_value"
}
}"#;
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
assert_eq!(rate_limit_info.requests_remaining, Some(0));
} else {
panic!("Expected RateLimitEvent variant");
}
}
} }
+49 -414
View File
@@ -39,12 +39,6 @@ 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"];
fn detect_wsl() -> bool { fn detect_wsl() -> bool {
// A native Windows binary is never running inside WSL, even if launched from a WSL
// terminal that has WSL_DISTRO_NAME set in its environment.
if cfg!(target_os = "windows") {
return false;
}
// Check /proc/version for WSL indicators // Check /proc/version for WSL indicators
if let Ok(version) = std::fs::read_to_string("/proc/version") { if let Ok(version) = std::fs::read_to_string("/proc/version") {
let version_lower = version.to_lowercase(); let version_lower = version.to_lowercase();
@@ -67,29 +61,23 @@ fn detect_wsl() -> bool {
} }
fn find_claude_binary() -> Option<String> { fn find_claude_binary() -> Option<String> {
// Check common installation locations for claude (when HOME is available) // Check common installation locations for claude
if let Ok(home) = std::env::var("HOME") { let home = std::env::var("HOME").ok()?;
let paths_to_check = [ let paths_to_check = [
format!("{}/.local/bin/claude", home), format!("{}/.local/bin/claude", home),
format!("{}/.claude/local/claude", home), format!("{}/.claude/local/claude", home),
"/usr/local/bin/claude".to_string(),
"/usr/bin/claude".to_string(),
]; ];
for path in &paths_to_check { for path in &paths_to_check {
if std::path::Path::new(path).exists() { if std::path::Path::new(path).exists() {
return Some(path.clone()); return Some(path.clone());
} }
} }
}
// Check system-wide locations // Fall back to checking PATH via which
for path in &["/usr/local/bin/claude", "/usr/bin/claude"] { if let Ok(output) = Command::new("which").arg("claude").output() {
if std::path::Path::new(path).exists() {
return Some((*path).to_string());
}
}
// Use a login shell to resolve claude via the user's PATH - GUI apps don't
// inherit shell PATH, so bare `which` may miss ~/.local/bin entries
if let Ok(output) = Command::new("bash").args(["-lc", "which claude"]).output() {
if output.status.success() { if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() { if !path.is_empty() {
@@ -137,19 +125,15 @@ 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 a process handle exists but the process has already exited (e.g. due to a
// failed working directory), clean up the stale handle so we can restart cleanly.
if let Some(ref mut process) = self.process {
if process.try_wait().map(|s| s.is_some()).unwrap_or(false) {
self.process = None;
self.stdin = None;
}
}
if self.process.is_some() { if self.process.is_some() {
return Err("Process already running".to_string()); return Err("Process already running".to_string());
} }
// Check if Claude binary is installed before attempting to start
if Command::new("which").arg("claude").output().ok().is_none_or(|output| !output.status.success()) {
return Err("Claude Code is not installed. Please install it using:\n\ncurl -fsSL https://claude.ai/install.sh | bash".to_string());
}
// Load saved achievements and stats when starting a new session // Load saved achievements and stats when starting a new session
let app_clone = app.clone(); let app_clone = app.clone();
let stats = self.stats.clone(); let stats = self.stats.clone();
@@ -265,11 +249,6 @@ impl WslBridge {
} }
} }
// Add worktree flag if requested
if options.use_worktree {
cmd.arg("--worktree");
}
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
@@ -279,39 +258,10 @@ impl WslBridge {
} }
} }
// Disable 1M context window if requested
if options.disable_1m_context {
cmd.env("CLAUDE_CODE_DISABLE_1M_CONTEXT", "1");
}
cmd cmd
} else { } else {
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded // Running on Windows - use wsl with bash login shell to ensure PATH is loaded
tracing::debug!("Windows path - using wsl"); tracing::debug!("Windows path - using wsl");
// Check if Claude binary is installed inside WSL
let binary_check = Command::new("wsl")
.args(["-e", "bash", "-lc", "which claude"])
.output();
if let Ok(output) = binary_check {
if !output.status.success() {
return Err("Claude Code is not installed. Please install it using:\n\ncurl -fsSL https://claude.ai/install.sh | bash".to_string());
}
}
// Validate the working directory exists inside WSL before spawning
let dir_check = Command::new("wsl")
.args(["-e", "test", "-d", working_dir])
.output();
if let Ok(output) = dir_check {
if !output.status.success() {
return Err(format!(
"Working directory does not exist: {}",
working_dir
));
}
}
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
@@ -324,11 +274,6 @@ impl WslBridge {
} }
} }
// Disable 1M context window if requested
if options.disable_1m_context {
claude_cmd.push_str("CLAUDE_CODE_DISABLE_1M_CONTEXT=1 ");
}
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",
); );
@@ -366,11 +311,6 @@ impl WslBridge {
} }
} }
// Add worktree flag if requested
if options.use_worktree {
claude_cmd.push_str(" --worktree");
}
// 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]);
@@ -765,28 +705,17 @@ fn handle_stderr(
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
duration_ms: None, duration_ms: None,
num_turns: None, num_turns: None,
last_assistant_message: stop_data.last_assistant_message,
}, },
); );
} }
} }
} }
// Hook events are informational — emit with distinct types instead of error // Still emit the stderr line as output
let line_type = if line.contains("[WorktreeCreate Hook]")
|| line.contains("[WorktreeRemove Hook]")
{
"worktree"
} else if line.contains("[ConfigChange Hook]") {
"config-change"
} else {
"error"
};
let _ = app.emit( let _ = app.emit(
"claude:output", "claude:output",
OutputEvent { OutputEvent {
line_type: line_type.to_string(), line_type: "error".to_string(),
content: line, content: line,
tool_name: None, tool_name: None,
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
@@ -839,78 +768,27 @@ fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
#[derive(Debug)] #[derive(Debug)]
struct SubagentStopData { struct SubagentStopData {
parent_tool_use_id: Option<String>, parent_tool_use_id: Option<String>,
last_assistant_message: Option<String>,
}
/// Extracts the content of a Rust Debug-formatted `Some("...")` field from a hook line.
/// Handles escaped characters (e.g. `\"` → `"`, `\\` → `\`, `\n` → newline).
/// Returns `None` if the field is absent or formatted as `None`.
fn extract_debug_string_value(line: &str, key: &str) -> Option<String> {
let prefix = format!("{}=Some(\"", key);
let start_idx = line.find(&prefix)? + prefix.len();
let rest = &line[start_idx..];
let mut result = String::new();
let mut chars = rest.chars();
loop {
match chars.next() {
Some('"') => return Some(result),
Some('\\') => match chars.next() {
Some('n') => result.push('\n'),
Some('t') => result.push('\t'),
Some('"') => result.push('"'),
Some('\\') => result.push('\\'),
Some(c) => {
result.push('\\');
result.push(c);
}
None => break,
},
Some(c) => result.push(c),
None => break,
}
}
None
} }
fn parse_subagent_stop_hook(line: &str) -> Option<SubagentStopData> { fn parse_subagent_stop_hook(line: &str) -> Option<SubagentStopData> {
// Parse: [SubagentStop Hook] ... parent_tool_use_id=Some("toolu_xxx"), last_assistant_message=Some("..."), ... // Parse: [SubagentStop Hook] ... parent_tool_use_id=Some("toolu_xxx"), ...
let parent_tool_use_id = extract_debug_string_value(line, "parent_tool_use_id"); // Extract parent_tool_use_id if present
let last_assistant_message = extract_debug_string_value(line, "last_assistant_message"); let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") {
line.split("parent_tool_use_id=Some(\"")
.nth(1)?
.split('"')
.next()
.map(|s| s.to_string())
} else {
None
};
Some(SubagentStopData { Some(SubagentStopData {
parent_tool_use_id, parent_tool_use_id,
last_assistant_message,
}) })
} }
/// Extract text content from a ToolResult's `content` field.
/// The content may be a JSON string or an array of typed content blocks.
fn extract_tool_result_text(content: &serde_json::Value) -> Option<String> {
match content {
serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
serde_json::Value::Array(blocks) => {
let texts: Vec<String> = blocks
.iter()
.filter_map(|block| {
if block.get("type")?.as_str()? == "text" {
block.get("text")?.as_str().map(String::from)
} else {
None
}
})
.collect();
if texts.is_empty() {
None
} else {
Some(texts.join("\n"))
}
}
_ => None,
}
}
fn process_json_line( fn process_json_line(
line: &str, line: &str,
app: &AppHandle, app: &AppHandle,
@@ -1164,37 +1042,17 @@ fn process_json_line(
stats.write().increment_code_blocks(); stats.write().increment_code_blocks();
} }
let is_prompt_too_long = text.starts_with("Prompt is too long");
let _ = app.emit( let _ = app.emit(
"claude:output", "claude:output",
OutputEvent { OutputEvent {
line_type: if is_prompt_too_long { line_type: "assistant".to_string(),
"error".to_string()
} else {
"assistant".to_string()
},
content: text.clone(), content: text.clone(),
tool_name: None, tool_name: None,
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
cost: message_cost.clone(), cost: message_cost.clone(), // Include cost with assistant text
parent_tool_use_id: parent_tool_use_id.clone(), parent_tool_use_id: parent_tool_use_id.clone(),
}, },
); );
if is_prompt_too_long {
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "compact-prompt".to_string(),
content: String::new(),
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
}
} }
ContentBlock::Thinking { thinking } => { ContentBlock::Thinking { thinking } => {
state = CharacterState::Thinking; state = CharacterState::Thinking;
@@ -1212,8 +1070,8 @@ fn process_json_line(
} }
ContentBlock::ToolResult { ContentBlock::ToolResult {
tool_use_id, tool_use_id,
content,
is_error, is_error,
..
} => { } => {
// Emit agent-end for all tool results // Emit agent-end for all tool results
// The frontend will ignore IDs that don't match known agents // The frontend will ignore IDs that don't match known agents
@@ -1231,7 +1089,6 @@ fn process_json_line(
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
duration_ms: None, duration_ms: None,
num_turns: None, num_turns: None,
last_assistant_message: extract_tool_result_text(content),
}, },
); );
} }
@@ -1624,23 +1481,6 @@ fn process_json_line(
emit_state_change(app, state, None, conversation_id.clone()); emit_state_change(app, state, None, conversation_id.clone());
} }
ClaudeMessage::RateLimitEvent { rate_limit_info } => {
tracing::warn!("Rate limit event received: {:?}", rate_limit_info);
let content = format_rate_limit_message(rate_limit_info);
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "rate-limit".to_string(),
content,
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
}
ClaudeMessage::User { message } => { ClaudeMessage::User { message } => {
// Increment message count for user messages // Increment message count for user messages
stats.write().increment_messages(); stats.write().increment_messages();
@@ -1649,8 +1489,8 @@ fn process_json_line(
for block in &message.content { for block in &message.content {
if let ContentBlock::ToolResult { if let ContentBlock::ToolResult {
tool_use_id, tool_use_id,
content,
is_error, is_error,
..
} = block } = block
{ {
let now = SystemTime::now() let now = SystemTime::now()
@@ -1667,7 +1507,6 @@ fn process_json_line(
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
duration_ms: None, duration_ms: None,
num_turns: None, num_turns: None,
last_assistant_message: extract_tool_result_text(content),
}, },
); );
} }
@@ -1750,35 +1589,6 @@ fn get_tool_state(tool_name: &str) -> CharacterState {
} }
} }
fn format_rate_limit_message(info: &crate::types::RateLimitInfo) -> String {
let mut parts = Vec::new();
if let (Some(remaining), Some(limit)) = (info.requests_remaining, info.requests_limit) {
parts.push(format!("requests: {}/{}", remaining, limit));
}
if let (Some(remaining), Some(limit)) = (info.tokens_remaining, info.tokens_limit) {
parts.push(format!("tokens: {}/{}", remaining, limit));
}
if let Some(reset) = &info.requests_reset {
parts.push(format!("resets at {}", reset));
} else if let Some(reset) = &info.tokens_reset {
parts.push(format!("resets at {}", reset));
}
if let Some(retry_ms) = info.retry_after_ms {
let secs = retry_ms / 1000;
parts.push(format!("retry after {}s", secs));
}
if parts.is_empty() {
"Rate limit reached".to_string()
} else {
format!("Rate limit reached — {}", parts.join(", "))
}
}
fn format_tool_description(name: &str, input: &serde_json::Value) -> String { fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
// Helper function to check if a path is a memory file // Helper function to check if a path is a memory file
fn is_memory_path(path: &str) -> bool { fn is_memory_path(path: &str) -> bool {
@@ -1839,7 +1649,12 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
} }
"Bash" => { "Bash" => {
if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) { if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) {
format!("Running: {}", cmd) let truncated = if cmd.len() > 50 {
format!("{}...", &cmd[..50])
} else {
cmd.to_string()
};
format!("Running: {}", truncated)
} else { } else {
"Running command...".to_string() "Running command...".to_string()
} }
@@ -2000,7 +1815,9 @@ mod tests {
let long_cmd = "a".repeat(100); let long_cmd = "a".repeat(100);
let input = serde_json::json!({"command": long_cmd}); let input = serde_json::json!({"command": long_cmd});
let desc = format_tool_description("Bash", &input); let desc = format_tool_description("Bash", &input);
assert_eq!(desc, format!("Running: {}", long_cmd)); assert!(desc.starts_with("Running: "));
assert!(desc.ends_with("..."));
assert!(desc.len() < 70);
} }
#[test] #[test]
@@ -2057,66 +1874,19 @@ mod tests {
} }
#[test] #[test]
fn test_stale_process_detection_with_try_wait() { fn test_claude_binary_check_command_structure() {
// Spawn a real process that exits immediately so we can verify try_wait detects it // Test that we're using the correct command to check for Claude binary
let mut child = Command::new("true").spawn().expect("Failed to spawn 'true'"); let output = Command::new("which").arg("claude").output();
// Wait for it to exit // The command should execute successfully (even if claude is not found)
let _ = child.wait(); // We're just verifying the command structure is valid
assert!(output.is_ok(), "which command should execute without error");
// try_wait on an already-exited process should return Some(_) // Verify the check logic returns a boolean
let status = child.try_wait(); // This is the same logic used in start() to check if claude is installed
assert!( let _result = output.ok().is_none_or(|o| !o.status.success());
status.is_ok(), // If claude is not installed, _result will be true (show error)
"try_wait should not error on an exited process" // If claude is installed, _result will be false (proceed with connection)
);
// The process has already been waited on, so try_wait might return None or Some
// depending on the OS - what matters is that the call succeeds
}
#[test]
fn test_stale_process_is_some_after_exit() {
// Verify the logic used in start(): a process that has exited is detected
// and the handle is cleaned up so start() can proceed
let mut child = Command::new("true").spawn().expect("Failed to spawn 'true'");
// Let it exit
let _ = child.wait();
// This mirrors the check in start()
let has_exited = child
.try_wait()
.map(|s| s.is_some())
.unwrap_or(false);
// After wait(), try_wait() returns None (already reaped), which means
// unwrap_or(false) → false. The important thing is the call doesn't panic
// and the control flow logic compiles and runs correctly.
let _ = has_exited; // suppress unused warning
}
/// Build the WSL binary check command structure without executing it (for testing)
#[cfg(test)]
fn build_wsl_binary_check_args() -> Vec<&'static str> {
vec!["-e", "bash", "-lc", "which claude"]
}
#[test]
fn test_wsl_binary_check_command_structure() {
// Windows path: verify Claude is detected inside WSL via `wsl -e bash -lc "which claude"`
let args = build_wsl_binary_check_args();
assert_eq!(args[0], "-e");
assert_eq!(args[1], "bash");
assert_eq!(args[2], "-lc");
assert_eq!(args[3], "which claude");
}
#[test]
fn test_linux_binary_check_does_not_panic() {
// Linux/WSL path: find_claude_binary() searches Linux filesystem paths.
// We just verify it runs without panicking; whether it returns Some depends
// on whether Claude is actually installed in this environment.
let _result = find_claude_binary();
} }
#[test] #[test]
@@ -2219,140 +1989,5 @@ mod tests {
assert!(result.is_some()); assert!(result.is_some());
let data = result.unwrap(); let data = result.unwrap();
assert_eq!(data.parent_tool_use_id, None); assert_eq!(data.parent_tool_use_id, None);
assert_eq!(data.last_assistant_message, None);
}
#[test]
fn test_parse_subagent_stop_hook_with_last_message() {
let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), last_assistant_message=Some("Task completed successfully."), session_id=123"#;
let result = parse_subagent_stop_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.parent_tool_use_id, Some("toolu_01ABC123".to_string()));
assert_eq!(
data.last_assistant_message,
Some("Task completed successfully.".to_string())
);
}
#[test]
fn test_parse_subagent_stop_hook_with_last_message_none() {
let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), last_assistant_message=None, session_id=123"#;
let result = parse_subagent_stop_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.last_assistant_message, None);
}
#[test]
fn test_extract_debug_string_value_simple() {
let line = r#"key=Some("hello world")"#;
assert_eq!(
extract_debug_string_value(line, "key"),
Some("hello world".to_string())
);
}
#[test]
fn test_extract_debug_string_value_with_escaped_quotes() {
let line = r#"key=Some("say \"hi\" there")"#;
assert_eq!(
extract_debug_string_value(line, "key"),
Some(r#"say "hi" there"#.to_string())
);
}
#[test]
fn test_extract_debug_string_value_none_variant() {
let line = "key=None";
assert_eq!(extract_debug_string_value(line, "key"), None);
}
#[test]
fn test_extract_debug_string_value_missing_key() {
let line = "other=Some(\"value\")";
assert_eq!(extract_debug_string_value(line, "key"), None);
}
#[test]
fn test_parse_subagent_stop_hook_with_commas_in_message() {
let line = r#"[SubagentStop Hook] parent_tool_use_id=Some("toolu_01"), last_assistant_message=Some("Found 3 files, all passing.")"#;
let result = parse_subagent_stop_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(
data.last_assistant_message,
Some("Found 3 files, all passing.".to_string())
);
}
// extract_tool_result_text tests
#[test]
fn test_extract_tool_result_text_plain_string() {
let content = serde_json::json!("Hello from agent");
assert_eq!(
extract_tool_result_text(&content),
Some("Hello from agent".to_string())
);
}
#[test]
fn test_extract_tool_result_text_empty_string() {
let content = serde_json::json!("");
assert_eq!(extract_tool_result_text(&content), None);
}
#[test]
fn test_extract_tool_result_text_array_single_text_block() {
let content = serde_json::json!([{"type": "text", "text": "Agent completed the task."}]);
assert_eq!(
extract_tool_result_text(&content),
Some("Agent completed the task.".to_string())
);
}
#[test]
fn test_extract_tool_result_text_array_multiple_text_blocks() {
let content = serde_json::json!([
{"type": "text", "text": "First part."},
{"type": "text", "text": "Second part."}
]);
assert_eq!(
extract_tool_result_text(&content),
Some("First part.\nSecond part.".to_string())
);
}
#[test]
fn test_extract_tool_result_text_array_non_text_block() {
let content = serde_json::json!([{"type": "image", "source": {"type": "base64"}}]);
assert_eq!(extract_tool_result_text(&content), None);
}
#[test]
fn test_extract_tool_result_text_array_mixed_blocks() {
let content = serde_json::json!([
{"type": "image", "source": {}},
{"type": "text", "text": "Found results."}
]);
assert_eq!(
extract_tool_result_text(&content),
Some("Found results.".to_string())
);
}
#[test]
fn test_extract_tool_result_text_null() {
let content = serde_json::Value::Null;
assert_eq!(extract_tool_result_text(&content), None);
}
#[test]
fn test_extract_tool_result_text_empty_array() {
let content = serde_json::json!([]);
assert_eq!(extract_tool_result_text(&content), None);
} }
} }
+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": "1.7.0", "version": "1.5.1",
"identifier": "com.naomi.hikari-desktop", "identifier": "com.naomi.hikari-desktop",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
-4
View File
@@ -61,8 +61,6 @@ async function changeDirectory(path: string): Promise<void> {
custom_instructions: config.custom_instructions || null, custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null, mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools, allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
}, },
}); });
@@ -137,8 +135,6 @@ async function startNewConversation(): Promise<void> {
custom_instructions: config.custom_instructions || null, custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null, mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools, allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
}, },
}); });
@@ -270,14 +270,6 @@
/> />
</svg> </svg>
{/if} {/if}
<img
src={agent.characterAvatar}
alt={agent.characterName}
class="w-5 h-5 rounded-full object-cover"
/>
<span class="text-[10px] font-medium text-[var(--text-primary)]">
{agent.characterName}
</span>
<span <span
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass( class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
agent.status agent.status
@@ -318,16 +310,6 @@
<span class="text-[10px] text-red-400">Errored / Killed</span> <span class="text-[10px] text-red-400">Errored / Killed</span>
{/if} {/if}
</div> </div>
<!-- Last assistant message snippet -->
{#if agent.lastAssistantMessage}
<p
class="mt-1 text-[10px] text-[var(--text-secondary)] italic truncate"
title={agent.lastAssistantMessage}
>
{agent.lastAssistantMessage}
</p>
{/if}
</div> </div>
{/each} {/each}
{/if} {/if}
-140
View File
@@ -1,140 +0,0 @@
<script lang="ts">
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
</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-2xl w-full p-6 max-h-[90vh] overflow-y-auto"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="cast-title"
tabindex="-1"
>
<div class="flex items-center justify-between mb-6">
<h2 id="cast-title" class="text-xl font-semibold text-[var(--text-primary)]">
Meet the Team
</h2>
<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>
<!-- Principal cast: Hikari + Naomi -->
<div class="grid grid-cols-1 gap-3 mb-6 sm:grid-cols-2">
<div
class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--accent-primary)]/40"
>
<img
src="https://cdn.nhcarrigan.com/hikari.png"
alt="Hikari"
class="w-16 h-16 object-cover rounded-full border-2 border-[var(--border-color)] shrink-0"
/>
<div>
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold text-[var(--text-primary)]">Hikari</span>
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
>
Chief Operating Officer
</span>
</div>
<p class="text-xs text-[var(--text-secondary)]">
Holds the line so the others don't have to. Never without her clipboard — or her
glasses.
</p>
</div>
</div>
<div
class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--accent-primary)]/40"
>
<img
src="https://cdn.nhcarrigan.com/profile.png"
alt="Naomi"
class="w-16 h-16 object-cover rounded-full border-2 border-[var(--border-color)] shrink-0"
/>
<div>
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold text-[var(--text-primary)]">Naomi</span>
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
>
Chief hEx-ecutive Officer
</span>
</div>
<p class="text-xs text-[var(--text-secondary)]">
A 525-year-old vampire running a tech company from behind a VTuber avatar. Fixes server
crashes at 4 AM.
</p>
</div>
</div>
</div>
<!-- Subagent girls grid -->
<div>
<h3 class="text-sm font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-3">
Subagent Squad
</h3>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
{#each CHARACTER_POOL as character (character.name)}
<div
class="flex flex-col items-center gap-2 p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] text-center"
>
<img
src={character.avatar}
alt={character.name}
class="w-14 h-14 object-cover rounded-full border-2 border-[var(--border-color)]"
/>
<span class="text-sm font-medium text-[var(--text-primary)]">{character.name}</span>
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
>
{character.title}
</span>
<p class="text-xs text-[var(--text-secondary)] leading-snug">{character.description}</p>
</div>
{/each}
</div>
</div>
</div>
</div>
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>
+5 -97
View File
@@ -2,43 +2,15 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { onMount } from "svelte"; import { onMount } from "svelte";
const SUPPORTED_CLI_VERSION = "2.1.50"; let version = $state("Loading...");
let installedVersion = $state("Loading...");
function compareVersions(a: string, b: string): number {
const aParts = a.split(".").map(Number);
const bParts = b.split(".").map(Number);
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aVal = aParts[i] ?? 0;
const bVal = bParts[i] ?? 0;
if (aVal > bVal) return 1;
if (aVal < bVal) return -1;
}
return 0;
}
let displayVersion = $derived(installedVersion.split(" (")[0]);
let supportedBadgeState = $derived.by(() => {
if (installedVersion === "Loading..." || installedVersion === "Unknown") {
return "neutral";
}
const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion);
if (!semverMatch) return "neutral";
const cmp = compareVersions(semverMatch[1], SUPPORTED_CLI_VERSION);
if (cmp > 0) return "ahead";
if (cmp < 0) return "behind";
return "current";
});
async function fetchVersion() { async function fetchVersion() {
try { try {
const result = await invoke<string>("get_claude_version"); const result = await invoke<string>("get_claude_version");
installedVersion = result; version = result;
} catch (error) { } catch (error) {
console.error("Failed to get Claude CLI version:", error); console.error("Failed to get Claude CLI version:", error);
installedVersion = "Unknown"; version = "Unknown";
} }
} }
@@ -47,8 +19,7 @@
}); });
</script> </script>
<div class="cli-versions"> <div class="cli-version">
<div class="cli-version">
<svg <svg
class="terminal-icon" class="terminal-icon"
width="14" width="14"
@@ -63,44 +34,10 @@
<polyline points="4 17 10 11 4 5" /> <polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" /> <line x1="12" y1="19" x2="20" y2="19" />
</svg> </svg>
<span class="version-text">CLI {displayVersion}</span> <span class="version-text">CLI {version}</span>
</div>
<div class="cli-version supported {supportedBadgeState}" title="Highest audited CLI version">
<svg
class="terminal-icon"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
<span class="version-text">Supported {SUPPORTED_CLI_VERSION}</span>
</div>
{#if supportedBadgeState === "ahead"}
<span class="version-warning ahead"
>Your version is newer, some features may not be supported</span
>
{:else if supportedBadgeState === "behind"}
<span class="version-warning behind"
>Your version is out of date, please update to ensure compatibility</span
>
{/if}
</div> </div>
<style> <style>
.cli-versions {
display: flex;
gap: 6px;
align-items: center;
}
.cli-version { .cli-version {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -120,21 +57,6 @@
color: var(--accent-primary); color: var(--accent-primary);
} }
.cli-version.supported.current {
border-color: var(--success-color, #4caf50);
color: var(--success-color, #4caf50);
}
.cli-version.supported.ahead {
border-color: var(--warning-color, #ff9800);
color: var(--warning-color, #ff9800);
}
.cli-version.supported.behind {
border-color: var(--error-color, #f44336);
color: var(--error-color, #f44336);
}
.terminal-icon { .terminal-icon {
flex-shrink: 0; flex-shrink: 0;
opacity: 0.7; opacity: 0.7;
@@ -143,18 +65,4 @@
.version-text { .version-text {
white-space: nowrap; white-space: nowrap;
} }
.version-warning {
font-size: 0.75rem;
font-style: italic;
white-space: nowrap;
}
.version-warning.ahead {
color: var(--warning-color, #ff9800);
}
.version-warning.behind {
color: var(--error-color, #f44336);
}
</style> </style>
+1 -186
View File
@@ -12,7 +12,6 @@
} from "$lib/stores/config"; } from "$lib/stores/config";
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { invoke } from "@tauri-apps/api/core";
import CostSummary from "./CostSummary.svelte"; import CostSummary from "./CostSummary.svelte";
let config: HikariConfig = $state({ let config: HikariConfig = $state({
@@ -53,26 +52,10 @@
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
}); });
let showCustomThemeEditor = $state(false); let showCustomThemeEditor = $state(false);
interface AuthStatus {
is_logged_in: boolean;
email: string | null;
org_name: string | null;
api_key_source: string | null;
api_provider: string | null;
subscription_type: string | null;
}
let authStatus: AuthStatus | null = $state(null);
let authLoading = $state(false);
let authActionLoading = $state(false);
let authError: string | null = $state(null);
let isOpen = $state(false); let isOpen = $state(false);
let isSaving = $state(false); let isSaving = $state(false);
let saveError: string | null = $state(null); let saveError: string | null = $state(null);
@@ -86,9 +69,6 @@
configStore.isSidebarOpen.subscribe((open) => { configStore.isSidebarOpen.subscribe((open) => {
isOpen = open; isOpen = open;
if (open && authStatus === null) {
void refreshAuthStatus();
}
}); });
configStore.saveError.subscribe((error) => { configStore.saveError.subscribe((error) => {
@@ -103,9 +83,8 @@
{ value: "", label: "Default (from ~/.claude)" }, { value: "", label: "Default (from ~/.claude)" },
// Current generation (Claude 4.6) // Current generation (Claude 4.6)
{ value: "claude-opus-4-6", label: "Claude Opus 4.6 (Most Capable)" }, { value: "claude-opus-4-6", label: "Claude Opus 4.6 (Most Capable)" },
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 (Recommended)" },
// Previous generation (Claude 4.5) // Previous generation (Claude 4.5)
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" }, { value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5 (Recommended)" },
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" }, { value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" },
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5" }, { value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5" },
// Previous generation (Claude 4.x) // Previous generation (Claude 4.x)
@@ -131,44 +110,6 @@
"Task", "Task",
]; ];
async function refreshAuthStatus() {
authLoading = true;
authError = null;
try {
authStatus = await invoke<AuthStatus>("get_auth_status");
} catch (e) {
authError = String(e);
} finally {
authLoading = false;
}
}
async function handleAuthLogin() {
authActionLoading = true;
authError = null;
try {
await invoke<string>("auth_login");
await refreshAuthStatus();
} catch (e) {
authError = String(e);
} finally {
authActionLoading = false;
}
}
async function handleAuthLogout() {
authActionLoading = true;
authError = null;
try {
await invoke<string>("auth_logout");
await refreshAuthStatus();
} catch (e) {
authError = String(e);
} finally {
authActionLoading = false;
}
}
async function handleSave() { async function handleSave() {
isSaving = true; isSaving = true;
saveError = null; saveError = null;
@@ -286,101 +227,6 @@
</div> </div>
{/if} {/if}
<!-- Account Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Account
</h3>
{#if authLoading}
<div class="text-sm text-[var(--text-secondary)] py-2">Checking auth status...</div>
{:else if authStatus}
<div class="flex items-center gap-2 mb-3">
<span
class="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 {authStatus.is_logged_in
? 'bg-green-500'
: 'bg-red-500'}"
></span>
<span class="text-sm font-medium text-[var(--text-primary)]">
{authStatus.is_logged_in ? "Logged in" : "Not logged in"}
</span>
</div>
{#if authStatus.email || authStatus.org_name || authStatus.api_key_source || config.api_key}
<dl class="text-xs space-y-1 mb-3">
{#if authStatus.email}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Email</dt>
<dd class="text-[var(--text-primary)] break-all">{authStatus.email}</dd>
</div>
{/if}
{#if authStatus.org_name}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Org</dt>
<dd class="text-[var(--text-primary)]">{authStatus.org_name}</dd>
</div>
{/if}
{#if authStatus.api_key_source}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">API key</dt>
<dd class="text-[var(--text-primary)]">{authStatus.api_key_source}</dd>
</div>
{/if}
{#if authStatus.subscription_type}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Plan</dt>
<dd class="text-[var(--text-primary)]">{authStatus.subscription_type}</dd>
</div>
{/if}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Override</dt>
<dd class="text-[var(--text-primary)]">
{#if config.api_key}
{config.streamer_mode ? "Custom key set đź”’" : "Custom key set"}
{:else}
None
{/if}
</dd>
</div>
</dl>
{/if}
{:else}
<div class="text-sm text-[var(--text-secondary)] py-2">Auth status unavailable</div>
{/if}
{#if authError}
<div class="mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-red-400 text-xs">
{authError}
</div>
{/if}
<div class="flex gap-2">
<button
onclick={refreshAuthStatus}
disabled={authLoading || authActionLoading}
class="px-3 py-1.5 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-secondary)] hover:border-[var(--accent-primary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
Refresh
</button>
{#if authStatus && !authStatus.is_logged_in}
<button
onclick={handleAuthLogin}
disabled={authActionLoading}
class="btn-trans-gradient px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
>
{authActionLoading ? "Logging in..." : "Login"}
</button>
{:else if authStatus && authStatus.is_logged_in}
<button
onclick={handleAuthLogout}
disabled={authActionLoading}
class="px-3 py-1.5 text-sm bg-red-500/20 border border-red-500/50 rounded-lg text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50"
>
{authActionLoading ? "Logging out..." : "Logout"}
</button>
{/if}
</div>
</section>
<!-- Agent Settings Section --> <!-- Agent Settings Section -->
<section class="mb-6"> <section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3"> <h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
@@ -475,37 +321,6 @@
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none" class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none"
></textarea> ></textarea>
</div> </div>
<!-- Worktree Isolation -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.use_worktree}
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)]">Worktree isolation</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Launch sessions with <code class="font-mono">--worktree</code> for isolated git worktree environments
</p>
</div>
<!-- Disable 1M Context Window -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.disable_1m_context}
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)]">Disable 1M context window</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Sets <code class="font-mono">CLAUDE_CODE_DISABLE_1M_CONTEXT=1</code> to opt out of the extended
context window
</p>
</div>
</section> </section>
<!-- Greeting Section --> <!-- Greeting Section -->
-2
View File
@@ -362,8 +362,6 @@ User: ${formattedMessage}`;
custom_instructions: config.custom_instructions || null, custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null, mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools, allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
}, },
}); });
@@ -87,8 +87,6 @@
custom_instructions: config.custom_instructions || null, custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null, mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: newGrantedTools, allowed_tools: newGrantedTools,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
}, },
}); });
-26
View File
@@ -27,7 +27,6 @@
import GitPanel from "./GitPanel.svelte"; import GitPanel from "./GitPanel.svelte";
import ProfilePanel from "./ProfilePanel.svelte"; import ProfilePanel from "./ProfilePanel.svelte";
import AgentMonitorPanel from "./AgentMonitorPanel.svelte"; import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
import CastPanel from "./CastPanel.svelte";
import PluginManagementPanel from "./PluginManagementPanel.svelte"; import PluginManagementPanel from "./PluginManagementPanel.svelte";
import McpManagementPanel from "./McpManagementPanel.svelte"; import McpManagementPanel from "./McpManagementPanel.svelte";
import { conversationsStore } from "$lib/stores/conversations"; import { conversationsStore } from "$lib/stores/conversations";
@@ -57,7 +56,6 @@
let showGitPanel = $state(false); let showGitPanel = $state(false);
let showProfile = $state(false); let showProfile = $state(false);
let showAgentMonitor = $state(false); let showAgentMonitor = $state(false);
let showCastPanel = $state(false);
let showPluginPanel = $state(false); let showPluginPanel = $state(false);
let showMcpPanel = $state(false); let showMcpPanel = $state(false);
let isSummarising = $state(false); let isSummarising = $state(false);
@@ -101,8 +99,6 @@
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
}); });
let streamerModeActive = $state(false); let streamerModeActive = $state(false);
@@ -180,8 +176,6 @@
custom_instructions: currentConfig.custom_instructions || null, custom_instructions: currentConfig.custom_instructions || null,
mcp_servers_json: currentConfig.mcp_servers_json || null, mcp_servers_json: currentConfig.mcp_servers_json || null,
allowed_tools: allAllowedTools, allowed_tools: allAllowedTools,
use_worktree: currentConfig.use_worktree ?? false,
disable_1m_context: currentConfig.disable_1m_context ?? false,
}, },
}); });
@@ -293,8 +287,6 @@
custom_instructions: currentConfig.custom_instructions || null, custom_instructions: currentConfig.custom_instructions || null,
mcp_servers_json: currentConfig.mcp_servers_json || null, mcp_servers_json: currentConfig.mcp_servers_json || null,
allowed_tools: allAllowedTools, allowed_tools: allAllowedTools,
use_worktree: currentConfig.use_worktree ?? false,
disable_1m_context: currentConfig.disable_1m_context ?? false,
}, },
}); });
@@ -527,20 +519,6 @@
/> />
</svg> </svg>
</button> </button>
<button
onclick={() => (showCastPanel = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Meet the Team"
>
<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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</button>
<button <button
onclick={() => (showAgentMonitor = !showAgentMonitor)} onclick={() => (showAgentMonitor = !showAgentMonitor)}
class="p-1 text-gray-500 icon-trans-hover relative {showAgentMonitor class="p-1 text-gray-500 icon-trans-hover relative {showAgentMonitor
@@ -759,10 +737,6 @@
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} /> <AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
{/if} {/if}
{#if showCastPanel}
<CastPanel onClose={() => (showCastPanel = false)} />
{/if}
{#if showPluginPanel} {#if showPluginPanel}
<PluginManagementPanel onClose={() => (showPluginPanel = false)} /> <PluginManagementPanel onClose={() => (showPluginPanel = false)} />
{/if} {/if}
+1 -118
View File
@@ -1,8 +1,6 @@
<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, onMount, onDestroy } from "svelte"; import { afterUpdate, tick, onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import ConversationTabs from "./ConversationTabs.svelte"; import ConversationTabs from "./ConversationTabs.svelte";
import Markdown from "./Markdown.svelte"; import Markdown from "./Markdown.svelte";
import HighlightedText from "./HighlightedText.svelte"; import HighlightedText from "./HighlightedText.svelte";
@@ -94,14 +92,6 @@
return "terminal-error"; return "terminal-error";
case "thinking": case "thinking":
return "terminal-thinking"; return "terminal-thinking";
case "rate-limit":
return "terminal-rate-limit";
case "compact-prompt":
return "terminal-compact-prompt";
case "worktree":
return "terminal-worktree";
case "config-change":
return "terminal-config-change";
default: default:
return "terminal-default"; return "terminal-default";
} }
@@ -119,12 +109,6 @@
return "[tool]"; return "[tool]";
case "error": case "error":
return "[error]"; return "[error]";
case "rate-limit":
return "[rate-limit]";
case "worktree":
return "[worktree]";
case "config-change":
return "[config]";
default: default:
return ""; return "";
} }
@@ -203,27 +187,6 @@
copiedMessageId = null; copiedMessageId = null;
}, 2000); }, 2000);
} }
async function handleCompact() {
if (!currentConversationId) return;
await invoke("send_prompt", { conversationId: currentConversationId, message: "/compact" });
}
// Collapsible tool lines
const TOOL_COLLAPSE_THRESHOLD = 60;
let expandedToolLines: Record<string, boolean> = {};
function isToolContentLong(content: string): boolean {
return content.length > TOOL_COLLAPSE_THRESHOLD;
}
function truncateToolContent(content: string): string {
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
}
function toggleToolLine(id: string) {
expandedToolLines = { ...expandedToolLines, [id]: !expandedToolLines[id] };
}
</script> </script>
<div <div
@@ -299,11 +262,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 === "compact-prompt"} {#if line.type === "assistant" || line.type === "user"}
<button class="compact-action-btn" onclick={handleCompact}>
⚡ Compact Conversation
</button>
{:else if line.type === "assistant" || line.type === "user"}
<div class="message-content-wrapper"> <div class="message-content-wrapper">
<Markdown <Markdown
content={maskPaths(line.content, hidePaths)} content={maskPaths(line.content, hidePaths)}
@@ -330,22 +289,6 @@
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span> <span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
</button> </button>
</div> </div>
{:else if line.type === "tool" && isToolContentLong(maskPaths(line.content, hidePaths))}
<span class="tool-collapsible">
<HighlightedText
content={expandedToolLines[line.id]
? maskPaths(line.content, hidePaths)
: truncateToolContent(maskPaths(line.content, hidePaths))}
searchQuery={currentSearchQuery}
/>
<button
class="tool-toggle-btn"
onclick={() => toggleToolLine(line.id)}
title={expandedToolLines[line.id] ? "Collapse" : "Expand to see full content"}
>
{expandedToolLines[line.id] ? "â–˛" : "â–Ľ"}
</button>
</span>
{:else} {:else}
<HighlightedText <HighlightedText
content={maskPaths(line.content, hidePaths)} content={maskPaths(line.content, hidePaths)}
@@ -386,42 +329,6 @@
color: var(--terminal-error, #f87171); color: var(--terminal-error, #f87171);
} }
.terminal-rate-limit {
color: var(--terminal-rate-limit, #fb923c);
}
.terminal-compact-prompt {
color: var(--text-secondary);
}
.terminal-worktree {
color: var(--terminal-worktree, #34d399);
}
.terminal-config-change {
color: var(--terminal-config-change, #a78bfa);
}
.compact-action-btn {
display: inline-flex;
align-items: center;
gap: 0.4em;
background: var(--bg-secondary);
border: 1px solid var(--terminal-error, #f87171);
color: var(--terminal-error, #f87171);
padding: 0.3em 0.8em;
cursor: pointer;
border-radius: 4px;
font-size: 0.9em;
font-family: inherit;
transition: all 0.15s ease;
}
.compact-action-btn:hover {
background: color-mix(in srgb, var(--terminal-error, #f87171) 15%, transparent);
color: var(--terminal-error, #f87171);
}
.terminal-default { .terminal-default {
color: var(--text-primary); color: var(--text-primary);
} }
@@ -501,28 +408,4 @@
.terminal-line { .terminal-line {
position: relative; position: relative;
} }
.tool-collapsible {
display: inline-flex;
align-items: baseline;
gap: 0.4em;
}
.tool-toggle-btn {
background: none;
border: none;
color: var(--text-tertiary, #6b7280);
cursor: pointer;
font-size: 0.7em;
padding: 0;
line-height: 1;
opacity: 0.7;
transition: opacity 0.15s ease;
font-family: inherit;
}
.tool-toggle-btn:hover {
opacity: 1;
color: var(--terminal-tool, #c084fc);
}
</style> </style>
-264
View File
@@ -1,264 +0,0 @@
/**
* Terminal Component Tests
*
* Tests the pure helper functions extracted from the Terminal component:
* - getLineClass: maps line types to CSS class names
* - getLinePrefix: maps line types to display prefixes
* - formatTime: formats a Date as "HH:MM AM/PM"
* - isToolContentLong: checks if tool content exceeds collapse threshold
* - truncateToolContent: truncates long tool content with ellipsis
*
* Manual testing checklist:
* - [ ] rate-limit lines appear in amber
* - [ ] error lines appear in red
* - [ ] tool lines appear in purple
* - [ ] system lines appear in grey italic
* - [ ] user lines appear in cyan
* - [ ] assistant lines appear in primary text colour
* - [ ] long tool content is collapsed by default with a toggle button
*/
import { describe, it, expect } from "vitest";
// Mirror functions from Terminal.svelte for isolated testing
function getLineClass(type: string): string {
switch (type) {
case "user":
return "terminal-user";
case "assistant":
return "terminal-assistant";
case "system":
return "terminal-system italic";
case "tool":
return "terminal-tool";
case "error":
return "terminal-error";
case "thinking":
return "terminal-thinking";
case "rate-limit":
return "terminal-rate-limit";
case "compact-prompt":
return "terminal-compact-prompt";
case "worktree":
return "terminal-worktree";
case "config-change":
return "terminal-config-change";
default:
return "terminal-default";
}
}
function getLinePrefix(type: string): string {
switch (type) {
case "user":
return ">";
case "assistant":
return "";
case "system":
return "[system]";
case "tool":
return "[tool]";
case "error":
return "[error]";
case "rate-limit":
return "[rate-limit]";
case "worktree":
return "[worktree]";
case "config-change":
return "[config]";
default:
return "";
}
}
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
}
const TOOL_COLLAPSE_THRESHOLD = 60;
function isToolContentLong(content: string): boolean {
return content.length > TOOL_COLLAPSE_THRESHOLD;
}
function truncateToolContent(content: string): string {
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
}
// ---
describe("getLineClass", () => {
it("returns terminal-user for user lines", () => {
expect(getLineClass("user")).toBe("terminal-user");
});
it("returns terminal-assistant for assistant lines", () => {
expect(getLineClass("assistant")).toBe("terminal-assistant");
});
it("returns terminal-system italic for system lines", () => {
expect(getLineClass("system")).toBe("terminal-system italic");
});
it("returns terminal-tool for tool lines", () => {
expect(getLineClass("tool")).toBe("terminal-tool");
});
it("returns terminal-error for error lines", () => {
expect(getLineClass("error")).toBe("terminal-error");
});
it("returns terminal-thinking for thinking lines", () => {
expect(getLineClass("thinking")).toBe("terminal-thinking");
});
it("returns terminal-rate-limit for rate-limit lines", () => {
expect(getLineClass("rate-limit")).toBe("terminal-rate-limit");
});
it("returns terminal-compact-prompt for compact-prompt lines", () => {
expect(getLineClass("compact-prompt")).toBe("terminal-compact-prompt");
});
it("returns terminal-worktree for worktree lines", () => {
expect(getLineClass("worktree")).toBe("terminal-worktree");
});
it("returns terminal-config-change for config-change lines", () => {
expect(getLineClass("config-change")).toBe("terminal-config-change");
});
it("returns terminal-default for unknown line types", () => {
expect(getLineClass("unknown")).toBe("terminal-default");
expect(getLineClass("")).toBe("terminal-default");
expect(getLineClass("random-future-type")).toBe("terminal-default");
});
});
describe("getLinePrefix", () => {
it("returns > for user lines", () => {
expect(getLinePrefix("user")).toBe(">");
});
it("returns empty string for assistant lines", () => {
expect(getLinePrefix("assistant")).toBe("");
});
it("returns [system] for system lines", () => {
expect(getLinePrefix("system")).toBe("[system]");
});
it("returns [tool] for tool lines", () => {
expect(getLinePrefix("tool")).toBe("[tool]");
});
it("returns [error] for error lines", () => {
expect(getLinePrefix("error")).toBe("[error]");
});
it("returns [rate-limit] for rate-limit lines", () => {
expect(getLinePrefix("rate-limit")).toBe("[rate-limit]");
});
it("returns empty string for compact-prompt lines (button renders instead)", () => {
expect(getLinePrefix("compact-prompt")).toBe("");
});
it("returns [worktree] for worktree lines", () => {
expect(getLinePrefix("worktree")).toBe("[worktree]");
});
it("returns [config] for config-change lines", () => {
expect(getLinePrefix("config-change")).toBe("[config]");
});
it("returns empty string for thinking lines (no prefix)", () => {
expect(getLinePrefix("thinking")).toBe("");
});
it("returns empty string for unknown line types", () => {
expect(getLinePrefix("unknown")).toBe("");
expect(getLinePrefix("")).toBe("");
});
});
describe("formatTime", () => {
it("formats time in 12-hour format with AM/PM", () => {
const date = new Date(2026, 1, 7, 14, 35);
const formatted = formatTime(date);
expect(formatted).toMatch(/\d{2}:\d{2}\s?(AM|PM)/i);
});
it("formats afternoon times correctly", () => {
const date = new Date(2026, 1, 7, 14, 35);
const formatted = formatTime(date);
expect(formatted).toContain("02:35");
expect(formatted.toUpperCase()).toContain("PM");
});
it("formats morning times correctly", () => {
const date = new Date(2026, 1, 7, 9, 5);
const formatted = formatTime(date);
expect(formatted).toContain("09:05");
expect(formatted.toUpperCase()).toContain("AM");
});
it("formats midnight correctly", () => {
const date = new Date(2026, 1, 7, 0, 0);
const formatted = formatTime(date);
expect(formatted).toContain("12:00");
expect(formatted.toUpperCase()).toContain("AM");
});
it("formats noon correctly", () => {
const date = new Date(2026, 1, 7, 12, 0);
const formatted = formatTime(date);
expect(formatted).toContain("12:00");
expect(formatted.toUpperCase()).toContain("PM");
});
});
describe("isToolContentLong", () => {
it("returns false for content at or below the threshold", () => {
const exactThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD);
expect(isToolContentLong(exactThreshold)).toBe(false);
});
it("returns true for content exceeding the threshold", () => {
const overThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD + 1);
expect(isToolContentLong(overThreshold)).toBe(true);
});
it("returns false for short content", () => {
expect(isToolContentLong("short")).toBe(false);
});
it("returns false for empty content", () => {
expect(isToolContentLong("")).toBe(false);
});
});
describe("truncateToolContent", () => {
it("truncates content to the threshold length with an ellipsis", () => {
const long = "x".repeat(100);
const result = truncateToolContent(long);
expect(result).toBe("x".repeat(TOOL_COLLAPSE_THRESHOLD) + "…");
});
it("keeps content shorter than threshold unchanged (plus ellipsis)", () => {
const short = "hello";
const result = truncateToolContent(short);
expect(result).toBe("hello…");
});
it("uses the unicode ellipsis character (not three dots)", () => {
const long = "x".repeat(100);
const result = truncateToolContent(long);
expect(result.endsWith("…")).toBe(true);
expect(result.endsWith("...")).toBe(false);
});
});
@@ -106,8 +106,6 @@
custom_instructions: config.custom_instructions || null, custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null, mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: grantedToolsList, allowed_tools: grantedToolsList,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
}, },
}); });
+7 -58
View File
@@ -2,15 +2,12 @@ import { describe, it, expect, beforeEach } from "vitest";
import { agentStore, getAgentsForConversation, runningAgentCount } from "./agents"; import { agentStore, getAgentsForConversation, runningAgentCount } from "./agents";
import { get } from "svelte/store"; import { get } from "svelte/store";
import type { AgentInfo } from "$lib/types/agents"; import type { AgentInfo } from "$lib/types/agents";
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
describe("agents store", () => { describe("agents store", () => {
const conversationId = "test-conversation-1"; const conversationId = "test-conversation-1";
const otherConversationId = "test-conversation-2"; const otherConversationId = "test-conversation-2";
type AgentInput = Omit<AgentInfo, "characterName" | "characterAvatar">; const createMockAgent = (overrides?: Partial<AgentInfo>): AgentInfo => ({
const createMockAgent = (overrides?: Partial<AgentInput>): AgentInput => ({
toolUseId: "toolu_test123", toolUseId: "toolu_test123",
description: "Test agent", description: "Test agent",
subagentType: "Explore", subagentType: "Explore",
@@ -40,29 +37,7 @@ describe("agents store", () => {
const agents = get(getAgentsForConversation(conversationId)); const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(1); expect(agents).toHaveLength(1);
expect(agents[0]).toMatchObject(agent); expect(agents[0]).toEqual(agent);
});
it("assigns a character name and avatar to added agents", () => {
const agent = createMockAgent();
agentStore.addAgent(conversationId, agent);
const agents = get(getAgentsForConversation(conversationId));
const validNames = CHARACTER_POOL.map((c) => c.name);
expect(validNames).toContain(agents[0].characterName);
expect(agents[0].characterAvatar).toMatch(/^https:\/\//u);
});
it("avoids duplicate character names across agents when possible", () => {
// Add 6 agents - each should ideally get a unique character
for (let i = 0; i < 6; i++) {
agentStore.addAgent(conversationId, createMockAgent({ toolUseId: `tool${i.toString()}` }));
}
const agents = get(getAgentsForConversation(conversationId));
const names = agents.map((a) => a.characterName);
const uniqueNames = new Set(names);
expect(uniqueNames.size).toBe(6);
}); });
it("adds multiple agents to the same conversation", () => { it("adds multiple agents to the same conversation", () => {
@@ -74,8 +49,8 @@ describe("agents store", () => {
const agents = get(getAgentsForConversation(conversationId)); const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(2); expect(agents).toHaveLength(2);
expect(agents[0]).toMatchObject(agent1); expect(agents[0]).toEqual(agent1);
expect(agents[1]).toMatchObject(agent2); expect(agents[1]).toEqual(agent2);
}); });
it("keeps agents in different conversations separate", () => { it("keeps agents in different conversations separate", () => {
@@ -90,8 +65,8 @@ describe("agents store", () => {
expect(agents1).toHaveLength(1); expect(agents1).toHaveLength(1);
expect(agents2).toHaveLength(1); expect(agents2).toHaveLength(1);
expect(agents1[0]).toMatchObject(agent1); expect(agents1[0]).toEqual(agent1);
expect(agents2[0]).toMatchObject(agent2); expect(agents2[0]).toEqual(agent2);
}); });
}); });
@@ -177,32 +152,6 @@ describe("agents store", () => {
const agents = get(getAgentsForConversation(conversationId)); const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].status).toBe("running"); // Status unchanged expect(agents[0].status).toBe("running"); // Status unchanged
}); });
it("stores lastAssistantMessage when provided", () => {
const agent = createMockAgent({ status: "running" });
agentStore.addAgent(conversationId, agent);
agentStore.endAgent(
conversationId,
agent.toolUseId,
Date.now(),
false,
"Task completed successfully."
);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].lastAssistantMessage).toBe("Task completed successfully.");
});
it("leaves lastAssistantMessage undefined when not provided", () => {
const agent = createMockAgent({ status: "running" });
agentStore.addAgent(conversationId, agent);
agentStore.endAgent(conversationId, agent.toolUseId, Date.now(), false);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].lastAssistantMessage).toBeUndefined();
});
}); });
describe("markAllErrored", () => { describe("markAllErrored", () => {
@@ -307,7 +256,7 @@ describe("agents store", () => {
expect(agents1).toHaveLength(0); expect(agents1).toHaveLength(0);
expect(agents2).toHaveLength(1); expect(agents2).toHaveLength(1);
expect(agents2[0]).toMatchObject(agent2); expect(agents2[0]).toEqual(agent2);
}); });
it("does nothing if conversation doesn't exist", () => { it("does nothing if conversation doesn't exist", () => {
+3 -16
View File
@@ -1,6 +1,5 @@
import { writable, derived } from "svelte/store"; import { writable, derived } from "svelte/store";
import type { AgentInfo } from "$lib/types/agents"; import type { AgentInfo } from "$lib/types/agents";
import { assignCharacter } from "$lib/utils/agentCharacters";
// Map of conversation ID -> agents in that conversation // Map of conversation ID -> agents in that conversation
const agentsByConversation = writable<Record<string, AgentInfo[]>>({}); const agentsByConversation = writable<Record<string, AgentInfo[]>>({});
@@ -9,17 +8,12 @@ function createAgentStore() {
return { return {
subscribe: agentsByConversation.subscribe, subscribe: agentsByConversation.subscribe,
addAgent(conversationId: string, agent: Omit<AgentInfo, "characterName" | "characterAvatar">) { addAgent(conversationId: string, agent: AgentInfo) {
agentsByConversation.update((state) => { agentsByConversation.update((state) => {
const existing = state[conversationId] || []; const existing = state[conversationId] || [];
const activeNames = existing.map((a) => a.characterName);
const character = assignCharacter(activeNames);
return { return {
...state, ...state,
[conversationId]: [ [conversationId]: [...existing, agent],
...existing,
{ ...agent, characterName: character.name, characterAvatar: character.avatar },
],
}; };
}); });
}, },
@@ -45,13 +39,7 @@ function createAgentStore() {
}); });
}, },
endAgent( endAgent(conversationId: string, toolUseId: string, endedAt: number, isError: boolean) {
conversationId: string,
toolUseId: string,
endedAt: number,
isError: boolean,
lastAssistantMessage?: string
) {
agentsByConversation.update((state) => { agentsByConversation.update((state) => {
const agents = state[conversationId]; const agents = state[conversationId];
if (!agents) return state; if (!agents) return state;
@@ -68,7 +56,6 @@ function createAgentStore() {
endedAt, endedAt,
status: isError ? "errored" : "completed", status: isError ? "errored" : "completed",
durationMs, durationMs,
lastAssistantMessage,
}; };
return { return {
-6
View File
@@ -194,8 +194,6 @@ describe("config store", () => {
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
}; };
expect(config.model).toBe("claude-sonnet-4"); expect(config.model).toBe("claude-sonnet-4");
@@ -242,8 +240,6 @@ describe("config store", () => {
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
}; };
expect(config.model).toBeNull(); expect(config.model).toBeNull();
@@ -789,8 +785,6 @@ describe("config store", () => {
budget_warning_threshold: 0.9, budget_warning_threshold: 0.9,
discord_rpc_enabled: false, discord_rpc_enabled: false,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
}; };
const mockInvokeImpl = vi.mocked(invoke); const mockInvokeImpl = vi.mocked(invoke);
-6
View File
@@ -47,10 +47,6 @@ export interface HikariConfig {
discord_rpc_enabled: boolean; discord_rpc_enabled: boolean;
// Thinking blocks settings // Thinking blocks settings
show_thinking_blocks: boolean; show_thinking_blocks: boolean;
// Worktree isolation
use_worktree: boolean;
// Disable 1M context window
disable_1m_context: boolean;
} }
const defaultConfig: HikariConfig = { const defaultConfig: HikariConfig = {
@@ -91,8 +87,6 @@ const defaultConfig: HikariConfig = {
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
}; };
function createConfigStore() { function createConfigStore() {
-1
View File
@@ -12,7 +12,6 @@ export type BudgetType = "token" | "cost";
export const MODEL_PRICING: Record<string, { input: number; output: number }> = { export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
// Current generation (Claude 4.6) // Current generation (Claude 4.6)
"claude-opus-4-6": { input: 5.0, output: 25.0 }, "claude-opus-4-6": { input: 5.0, output: 25.0 },
"claude-sonnet-4-6": { input: 3.0, output: 15.0 },
// Previous generation (Claude 4.5) // Previous generation (Claude 4.5)
"claude-opus-4-5-20251101": { input: 5.0, output: 25.0 }, "claude-opus-4-5-20251101": { input: 5.0, output: 25.0 },
"claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 }, "claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 },
+8 -79
View File
@@ -29,7 +29,6 @@ interface StateChangePayload {
} }
const connectedConversations = new Set<string>(); const connectedConversations = new Set<string>();
const greetingPendingConversations = new Set<string>();
let unlisteners: Array<() => void> = []; let unlisteners: Array<() => void> = [];
let skipNextGreeting = false; let skipNextGreeting = false;
@@ -56,17 +55,17 @@ function generateGreetingPrompt(): string {
return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`; return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`;
} }
async function sendGreeting(conversationId: string): Promise<boolean> { async function sendGreeting(conversationId: string) {
// Check if we should skip this greeting // Check if we should skip this greeting
if (skipNextGreeting) { if (skipNextGreeting) {
skipNextGreeting = false; // Reset the flag skipNextGreeting = false; // Reset the flag
return false; return;
} }
const config = configStore.getConfig(); const config = configStore.getConfig();
if (!config.greeting_enabled) { if (!config.greeting_enabled) {
return false; return;
} }
const greetingPrompt = config.greeting_custom_prompt?.trim() || generateGreetingPrompt(); const greetingPrompt = config.greeting_custom_prompt?.trim() || generateGreetingPrompt();
@@ -82,12 +81,10 @@ async function sendGreeting(conversationId: string): Promise<boolean> {
conversationId, conversationId,
message: greetingPrompt, message: greetingPrompt,
}); });
return true;
} catch (error) { } catch (error) {
console.error("Failed to send greeting:", error); console.error("Failed to send greeting:", error);
claudeStore.addLineToConversation(conversationId, "error", `Failed to send greeting: ${error}`); claudeStore.addLineToConversation(conversationId, "error", `Failed to send greeting: ${error}`);
characterState.setTemporaryState("error", 3000); characterState.setTemporaryState("error", 3000);
return false;
} }
} }
@@ -121,7 +118,6 @@ interface WorkingDirectoryPayload {
export async function cleanupConversationTracking(conversationId: string) { export async function cleanupConversationTracking(conversationId: string) {
connectedConversations.delete(conversationId); connectedConversations.delete(conversationId);
greetingPendingConversations.delete(conversationId);
// Clean up any temp files associated with this conversation // Clean up any temp files associated with this conversation
try { try {
@@ -177,24 +173,7 @@ export async function initializeTauriListeners() {
if (!connectedConversations.has(targetConversationId)) { if (!connectedConversations.has(targetConversationId)) {
connectedConversations.add(targetConversationId); connectedConversations.add(targetConversationId);
resetSessionStats(); // Reset session stats on new connection resetSessionStats(); // Reset session stats on new connection
await sendGreeting(targetConversationId);
// Immediately hold the tab at yellow while we wait for the greeting response.
// This avoids a brief green flash before the greeting is even sent.
greetingPendingConversations.add(targetConversationId);
claudeStore.setConnectionStatusForConversation(
targetConversationId,
"connecting" as ConnectionStatus
);
const greetingSent = await sendGreeting(targetConversationId);
if (!greetingSent) {
// Greeting was disabled or failed — flip straight to connected.
greetingPendingConversations.delete(targetConversationId);
claudeStore.setConnectionStatusForConversation(
targetConversationId,
"connected" as ConnectionStatus
);
}
} }
} }
} else if (status === "disconnected") { } else if (status === "disconnected") {
@@ -212,7 +191,6 @@ export async function initializeTauriListeners() {
// Only remove from connected set if we're not about to reconnect // Only remove from connected set if we're not about to reconnect
if (!skipNextGreeting && targetConversationId) { if (!skipNextGreeting && targetConversationId) {
connectedConversations.delete(targetConversationId); connectedConversations.delete(targetConversationId);
greetingPendingConversations.delete(targetConversationId);
} }
// Don't add system message if we're about to reconnect // Don't add system message if we're about to reconnect
@@ -227,14 +205,6 @@ export async function initializeTauriListeners() {
todos.clear(); todos.clear();
} }
// Update the tab's connection status on real disconnects
if (!skipNextGreeting && targetConversationId) {
claudeStore.setConnectionStatusForConversation(
targetConversationId,
"disconnected" as ConnectionStatus
);
}
// Update character state for this conversation // Update character state for this conversation
if (targetConversationId) { if (targetConversationId) {
claudeStore.setCharacterStateForConversation(targetConversationId, "idle"); claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
@@ -244,7 +214,6 @@ export async function initializeTauriListeners() {
if (targetConversationId) { if (targetConversationId) {
connectedConversations.delete(targetConversationId); connectedConversations.delete(targetConversationId);
greetingPendingConversations.delete(targetConversationId);
claudeStore.addLineToConversation(targetConversationId, "error", "Connection error"); claudeStore.addLineToConversation(targetConversationId, "error", "Connection error");
} }
@@ -306,34 +275,11 @@ export async function initializeTauriListeners() {
} }
: undefined; : undefined;
// Flip to connected when first assistant message arrives after greeting
if (
conversation_id &&
line_type === "assistant" &&
greetingPendingConversations.has(conversation_id)
) {
greetingPendingConversations.delete(conversation_id);
claudeStore.setConnectionStatusForConversation(
conversation_id,
"connected" as ConnectionStatus
);
}
// Always store the output to the correct conversation // Always store the output to the correct conversation
if (conversation_id) { if (conversation_id) {
claudeStore.addLineToConversation( claudeStore.addLineToConversation(
conversation_id, conversation_id,
line_type as line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
| "user"
| "assistant"
| "system"
| "tool"
| "error"
| "thinking"
| "rate-limit"
| "compact-prompt"
| "worktree"
| "config-change",
content, content,
tool_name || undefined, tool_name || undefined,
costData, costData,
@@ -342,17 +288,7 @@ export async function initializeTauriListeners() {
} else { } else {
// Fallback to active conversation if no conversation_id provided // Fallback to active conversation if no conversation_id provided
claudeStore.addLine( claudeStore.addLine(
line_type as line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
| "user"
| "assistant"
| "system"
| "tool"
| "error"
| "thinking"
| "rate-limit"
| "compact-prompt"
| "worktree"
| "config-change",
content, content,
tool_name || undefined, tool_name || undefined,
costData, costData,
@@ -474,17 +410,10 @@ export async function initializeTauriListeners() {
unlisteners.push(agentUpdateUnlisten); unlisteners.push(agentUpdateUnlisten);
const agentEndUnlisten = await listen<AgentEndPayload>("claude:agent-end", (event) => { const agentEndUnlisten = await listen<AgentEndPayload>("claude:agent-end", (event) => {
const { tool_use_id, ended_at, is_error, conversation_id, last_assistant_message } = const { tool_use_id, ended_at, is_error, conversation_id } = event.payload;
event.payload;
const targetConversationId = conversation_id || get(claudeStore.activeConversationId); const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (targetConversationId) { if (targetConversationId) {
agentStore.endAgent( agentStore.endAgent(targetConversationId, tool_use_id, ended_at, is_error);
targetConversationId,
tool_use_id,
ended_at,
is_error,
last_assistant_message
);
} }
}); });
unlisteners.push(agentEndUnlisten); unlisteners.push(agentEndUnlisten);
-4
View File
@@ -10,9 +10,6 @@ export interface AgentInfo {
status: AgentStatus; status: AgentStatus;
parentToolUseId?: string; parentToolUseId?: string;
durationMs?: number; durationMs?: number;
characterName: string;
characterAvatar: string;
lastAssistantMessage?: string;
} }
export interface AgentStartPayload { export interface AgentStartPayload {
@@ -32,5 +29,4 @@ export interface AgentEndPayload {
conversation_id?: string; conversation_id?: string;
duration_ms?: number; duration_ms?: number;
num_turns?: number; num_turns?: number;
last_assistant_message?: string;
} }
+1 -11
View File
@@ -1,16 +1,6 @@
export interface TerminalLine { export interface TerminalLine {
id: string; id: string;
type: type: "user" | "assistant" | "system" | "tool" | "error" | "thinking";
| "user"
| "assistant"
| "system"
| "tool"
| "error"
| "thinking"
| "rate-limit"
| "compact-prompt"
| "worktree"
| "config-change";
content: string; content: string;
timestamp: Date; timestamp: Date;
toolName?: string; toolName?: string;
-73
View File
@@ -1,73 +0,0 @@
import { describe, it, expect } from "vitest";
import { CHARACTER_POOL, assignCharacter } from "./agentCharacters";
describe("agentCharacters", () => {
describe("CHARACTER_POOL", () => {
it("contains exactly 6 characters", () => {
expect(CHARACTER_POOL).toHaveLength(6);
});
it("each character has a name, avatar, title, and description", () => {
for (const character of CHARACTER_POOL) {
expect(character.name).toBeTruthy();
expect(character.avatar).toBeTruthy();
expect(character.avatar).toMatch(/^https:\/\//u);
expect(character.title).toBeTruthy();
expect(character.description).toBeTruthy();
}
});
it("all names are unique", () => {
const names = CHARACTER_POOL.map((c) => c.name);
const uniqueNames = new Set(names);
expect(uniqueNames.size).toBe(CHARACTER_POOL.length);
});
});
describe("assignCharacter", () => {
it("returns a character from the pool", () => {
const character = assignCharacter([]);
const names = CHARACTER_POOL.map((c) => c.name);
expect(names).toContain(character.name);
});
it("avoids names already in use when possible", () => {
const takenNames = ["Amari", "Keiko", "Minori", "Reina", "Tatsumi"];
// Run many times to confirm we never get a taken name
for (let i = 0; i < 50; i++) {
const character = assignCharacter(takenNames);
expect(takenNames).not.toContain(character.name);
expect(character.name).toBe("Yumiko");
}
});
it("picks from the full pool when all 6 names are taken", () => {
const allNames = CHARACTER_POOL.map((c) => c.name);
const seen = new Set<string>();
// Run enough times that we'd statistically see variety
for (let i = 0; i < 100; i++) {
const character = assignCharacter(allNames);
seen.add(character.name);
}
// Should still pick valid characters
for (const name of seen) {
expect(allNames).toContain(name);
}
// With 100 runs and 6 characters, we should see at least 2 distinct names
expect(seen.size).toBeGreaterThan(1);
});
it("returns a character with name, avatar, title, and description", () => {
const character = assignCharacter([]);
expect(character.name).toBeTruthy();
expect(character.avatar).toBeTruthy();
expect(character.title).toBeTruthy();
expect(character.description).toBeTruthy();
});
it("works when the active list is empty", () => {
const character = assignCharacter([]);
expect(character).toBeDefined();
});
});
});
-61
View File
@@ -1,61 +0,0 @@
export interface AgentCharacter {
name: string;
avatar: string;
title: string;
description: string;
}
export const CHARACTER_POOL: readonly AgentCharacter[] = [
{
name: "Amari",
avatar: "https://cdn.nhcarrigan.com/amari.png",
title: "Executive Assistant",
description:
"Fey-blooded PA and healer of the team. She always knows when you need a break — and makes sure you take one.",
},
{
name: "Keiko",
avatar: "https://cdn.nhcarrigan.com/keiko.png",
title: "Chief Security Officer",
description:
"Bodyguard and shadow of the family. Conceals blades beneath evening gowns; always watching from the dark.",
},
{
name: "Minori",
avatar: "https://cdn.nhcarrigan.com/minori.png",
title: "Chief Compliance Officer",
description:
"An ancient Automaton built to guard the Great Library. Perfect memory, perfect logic, perfect dedication.",
},
{
name: "Reina",
avatar: "https://cdn.nhcarrigan.com/reina.png",
title: "Chief Legal Officer",
description:
"Demon of the Crossroads turned corporate lawyer. Her binding contracts have held for millennia.",
},
{
name: "Tatsumi",
avatar: "https://cdn.nhcarrigan.com/tatsumi.png",
title: "Chief Design Officer",
description:
"A Siren who traded the ocean for a stylus. Uses her glamour to make every interface welcoming and beautiful.",
},
{
name: "Yumiko",
avatar: "https://cdn.nhcarrigan.com/yumiko.png",
title: "Chief Technology Officer",
description:
"Technomancer and machine whisperer. She communes with machine spirits and keeps the digital world running.",
},
];
/**
* Picks a character for a new subagent.
* Avoids names already assigned to active agents unless all six are taken.
*/
export function assignCharacter(activeNames: readonly string[]): AgentCharacter {
const available = CHARACTER_POOL.filter((c) => !activeNames.includes(c.name));
const pool = available.length > 0 ? available : [...CHARACTER_POOL];
return pool[Math.floor(Math.random() * pool.length)];
}