Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
bebf1552a6
|
|||
| b3d79a82ef | |||
| 4c46d4c8fd | |||
| 852a4d6661 | |||
|
bbeff7ae2e
|
|||
| 3f30997f0e | |||
| 06810537a9 | |||
| 94991796be | |||
| 947e56ef41 | |||
| 9fe4e8a48a |
@@ -49,13 +49,13 @@ jobs:
|
||||
- name: Run Svelte Check
|
||||
run: pnpm check
|
||||
|
||||
- name: Run frontend tests
|
||||
run: pnpm test
|
||||
- name: Run frontend tests with coverage
|
||||
run: pnpm test:coverage
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
components: clippy, llvm-tools-preview
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
@@ -68,13 +68,16 @@ jobs:
|
||||
src-tauri/target/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
run: cargo install cargo-llvm-cov --locked
|
||||
|
||||
- name: Run Clippy
|
||||
working-directory: src-tauri
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
- name: Run Rust tests
|
||||
- name: Run Rust tests with coverage
|
||||
working-directory: src-tauri
|
||||
run: cargo test
|
||||
run: cargo llvm-cov --fail-under-lines 50
|
||||
|
||||
build-linux:
|
||||
name: Build Linux
|
||||
|
||||
@@ -8,3 +8,6 @@ node_modules
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Coverage reports
|
||||
/coverage
|
||||
|
||||
@@ -2,6 +2,7 @@ build/
|
||||
.svelte-kit/
|
||||
dist/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
pnpm-lock.yaml
|
||||
|
||||
@@ -27,6 +27,6 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ["build/", ".svelte-kit/", "dist/", "src-tauri/target/", "node_modules/"],
|
||||
ignores: ["build/", ".svelte-kit/", "dist/", "src-tauri/target/", "node_modules/", "coverage/"],
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hikari-desktop",
|
||||
"version": "0.2.0",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -16,6 +16,10 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:backend": "cd src-tauri && cargo test",
|
||||
"test:backend:coverage": "cd src-tauri && cargo llvm-cov --text",
|
||||
"test:all": "pnpm test && pnpm test:backend",
|
||||
"coverage:all": "pnpm test:coverage && pnpm test:backend:coverage",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
@@ -24,12 +28,16 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-os": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2.3.4",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-os": "^2"
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^17.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
@@ -40,6 +48,7 @@
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.14.0",
|
||||
|
||||
@@ -11,9 +11,15 @@ importers:
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2
|
||||
version: 2.9.1
|
||||
'@tauri-apps/plugin-clipboard-manager':
|
||||
specifier: ^2.3.2
|
||||
version: 2.3.2
|
||||
'@tauri-apps/plugin-dialog':
|
||||
specifier: ^2
|
||||
version: 2.6.0
|
||||
'@tauri-apps/plugin-fs':
|
||||
specifier: ^2.4.5
|
||||
version: 2.4.5
|
||||
'@tauri-apps/plugin-notification':
|
||||
specifier: ^2
|
||||
version: 2.3.3
|
||||
@@ -29,6 +35,12 @@ importers:
|
||||
'@tauri-apps/plugin-store':
|
||||
specifier: ^2
|
||||
version: 2.4.2
|
||||
highlight.js:
|
||||
specifier: ^11.11.1
|
||||
version: 11.11.1
|
||||
marked:
|
||||
specifier: ^17.0.1
|
||||
version: 17.0.1
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.39.2
|
||||
@@ -54,6 +66,9 @@ importers:
|
||||
'@testing-library/svelte':
|
||||
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))
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.0.18
|
||||
version: 4.0.18(vitest@4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2))
|
||||
eslint:
|
||||
specifier: ^9.39.2
|
||||
version: 9.39.2(jiti@2.6.1)
|
||||
@@ -118,14 +133,31 @@ packages:
|
||||
resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-string-parser@7.27.1':
|
||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5':
|
||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.28.6':
|
||||
resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/runtime@7.28.6':
|
||||
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.28.6':
|
||||
resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2':
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -729,9 +761,15 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@tauri-apps/plugin-clipboard-manager@2.3.2':
|
||||
resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==}
|
||||
|
||||
'@tauri-apps/plugin-dialog@2.6.0':
|
||||
resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
|
||||
|
||||
'@tauri-apps/plugin-fs@2.4.5':
|
||||
resolution: {integrity: sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==}
|
||||
|
||||
'@tauri-apps/plugin-notification@2.3.3':
|
||||
resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==}
|
||||
|
||||
@@ -851,6 +889,15 @@ packages:
|
||||
resolution: {integrity: sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@vitest/coverage-v8@4.0.18':
|
||||
resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 4.0.18
|
||||
vitest: 4.0.18
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
|
||||
'@vitest/expect@4.0.17':
|
||||
resolution: {integrity: sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==}
|
||||
|
||||
@@ -868,6 +915,9 @@ packages:
|
||||
'@vitest/pretty-format@4.0.17':
|
||||
resolution: {integrity: sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==}
|
||||
|
||||
'@vitest/pretty-format@4.0.18':
|
||||
resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==}
|
||||
|
||||
'@vitest/runner@4.0.17':
|
||||
resolution: {integrity: sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==}
|
||||
|
||||
@@ -880,6 +930,9 @@ packages:
|
||||
'@vitest/utils@4.0.17':
|
||||
resolution: {integrity: sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==}
|
||||
|
||||
'@vitest/utils@4.0.18':
|
||||
resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
|
||||
|
||||
acorn-jsx@5.3.2:
|
||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||
peerDependencies:
|
||||
@@ -923,6 +976,9 @@ packages:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ast-v8-to-istanbul@0.3.10:
|
||||
resolution: {integrity: sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==}
|
||||
|
||||
axobject-query@4.1.0:
|
||||
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1185,10 +1241,17 @@ packages:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
highlight.js@11.11.1:
|
||||
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
html-encoding-sniffer@6.0.0:
|
||||
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -1234,6 +1297,18 @@ packages:
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
istanbul-lib-coverage@3.2.2:
|
||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
jiti@2.6.1:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
@@ -1241,6 +1316,9 @@ packages:
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
js-tokens@9.0.1:
|
||||
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
hasBin: true
|
||||
@@ -1372,6 +1450,18 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
magicast@0.5.1:
|
||||
resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==}
|
||||
|
||||
make-dir@4.0.0:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
marked@17.0.1:
|
||||
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
mdn-data@2.12.2:
|
||||
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
|
||||
|
||||
@@ -1842,10 +1932,23 @@ snapshots:
|
||||
js-tokens: 4.0.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5': {}
|
||||
|
||||
'@babel/parser@7.28.6':
|
||||
dependencies:
|
||||
'@babel/types': 7.28.6
|
||||
|
||||
'@babel/runtime@7.28.6': {}
|
||||
|
||||
'@babel/types@7.28.6':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@csstools/color-helpers@5.1.0': {}
|
||||
|
||||
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
@@ -2271,10 +2374,18 @@ snapshots:
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.9.6
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.9.6
|
||||
|
||||
'@tauri-apps/plugin-clipboard-manager@2.3.2':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.9.1
|
||||
|
||||
'@tauri-apps/plugin-dialog@2.6.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.9.1
|
||||
|
||||
'@tauri-apps/plugin-fs@2.4.5':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.9.1
|
||||
|
||||
'@tauri-apps/plugin-notification@2.3.3':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.9.1
|
||||
@@ -2434,6 +2545,20 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.53.0
|
||||
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))':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.0.18
|
||||
ast-v8-to-istanbul: 0.3.10
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-reports: 3.2.0
|
||||
magicast: 0.5.1
|
||||
obug: 2.1.1
|
||||
std-env: 3.10.0
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)
|
||||
|
||||
'@vitest/expect@4.0.17':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
@@ -2455,6 +2580,10 @@ snapshots:
|
||||
dependencies:
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@vitest/pretty-format@4.0.18':
|
||||
dependencies:
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@vitest/runner@4.0.17':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.0.17
|
||||
@@ -2473,6 +2602,11 @@ snapshots:
|
||||
'@vitest/pretty-format': 4.0.17
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@vitest/utils@4.0.18':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.15.0):
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
@@ -2506,6 +2640,12 @@ snapshots:
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-v8-to-istanbul@0.3.10:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
estree-walker: 3.0.3
|
||||
js-tokens: 9.0.1
|
||||
|
||||
axobject-query@4.1.0: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
@@ -2780,12 +2920,16 @@ snapshots:
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
highlight.js@11.11.1: {}
|
||||
|
||||
html-encoding-sniffer@6.0.0:
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.8.0
|
||||
transitivePeerDependencies:
|
||||
- '@exodus/crypto'
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
@@ -2827,10 +2971,25 @@ snapshots:
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
istanbul-lib-coverage@3.2.2: {}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
dependencies:
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
make-dir: 4.0.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
dependencies:
|
||||
html-escaper: 2.0.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-tokens@9.0.1: {}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
@@ -2949,6 +3108,18 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magicast@0.5.1:
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.6
|
||||
'@babel/types': 7.28.6
|
||||
source-map-js: 1.2.1
|
||||
|
||||
make-dir@4.0.0:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
|
||||
marked@17.0.1: {}
|
||||
|
||||
mdn-data@2.12.2: {}
|
||||
|
||||
min-indent@1.0.1: {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hikari-desktop"
|
||||
version = "0.2.0"
|
||||
version = "1.0.0"
|
||||
description = "Hikari - Claude Code Visual Assistant"
|
||||
authors = ["Naomi Carrigan"]
|
||||
edition = "2021"
|
||||
@@ -13,7 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
@@ -25,7 +25,11 @@ uuid = { version = "1", features = ["v4"] }
|
||||
tauri-plugin-store = "2.4.2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-os = "2"
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tempfile = "3"
|
||||
semver = "1"
|
||||
chrono = { version = "0.4.43", features = ["serde"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
|
||||
@@ -13,6 +13,23 @@
|
||||
"notification:default",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify"
|
||||
"notification:allow-notify",
|
||||
"clipboard-manager:default",
|
||||
"clipboard-manager:allow-read-image",
|
||||
"core:tray:default",
|
||||
"fs:default",
|
||||
"fs:allow-read-text-file",
|
||||
"fs:allow-write-text-file",
|
||||
{
|
||||
"identifier": "fs:allow-read-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-write-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
},
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-set-always-on-top",
|
||||
"core:window:allow-inner-size"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -29,30 +29,40 @@ impl BridgeManager {
|
||||
conversation_id: &str,
|
||||
options: ClaudeStartOptions,
|
||||
) -> Result<(), String> {
|
||||
// Check if a bridge already exists for this conversation
|
||||
if self.bridges.get(conversation_id).map(|b| b.is_running()).unwrap_or(false) {
|
||||
// Check if a bridge already exists and is running for this conversation
|
||||
if self
|
||||
.bridges
|
||||
.get(conversation_id)
|
||||
.map(|b| b.is_running())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err("Claude is already running for this conversation".to_string());
|
||||
}
|
||||
|
||||
let app = self.app_handle.as_ref()
|
||||
let app = self
|
||||
.app_handle
|
||||
.as_ref()
|
||||
.ok_or_else(|| "App handle not set".to_string())?
|
||||
.clone();
|
||||
|
||||
// Create a new bridge for this conversation
|
||||
let mut bridge = WslBridge::new_with_conversation_id(conversation_id.to_string());
|
||||
// Reuse existing bridge if it exists (preserves stats across reconnects)
|
||||
// Only create a new bridge if one doesn't exist for this conversation
|
||||
let bridge = self
|
||||
.bridges
|
||||
.entry(conversation_id.to_string())
|
||||
.or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string()));
|
||||
|
||||
// Start the Claude process
|
||||
bridge.start(app, options)?;
|
||||
|
||||
// Store the bridge
|
||||
self.bridges.insert(conversation_id.to_string(), bridge);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop_claude(&mut self, conversation_id: &str) -> Result<(), String> {
|
||||
if let Some(bridge) = self.bridges.get_mut(conversation_id) {
|
||||
let app = self.app_handle.as_ref()
|
||||
let app = self
|
||||
.app_handle
|
||||
.as_ref()
|
||||
.ok_or_else(|| "App handle not set".to_string())?;
|
||||
bridge.stop(app);
|
||||
Ok(())
|
||||
@@ -63,7 +73,9 @@ impl BridgeManager {
|
||||
|
||||
pub fn interrupt_claude(&mut self, conversation_id: &str) -> Result<(), String> {
|
||||
if let Some(bridge) = self.bridges.get_mut(conversation_id) {
|
||||
let app = self.app_handle.as_ref()
|
||||
let app = self
|
||||
.app_handle
|
||||
.as_ref()
|
||||
.ok_or_else(|| "App handle not set".to_string())?;
|
||||
bridge.interrupt(app)
|
||||
} else {
|
||||
@@ -79,20 +91,36 @@ impl BridgeManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_tool_result(
|
||||
&mut self,
|
||||
conversation_id: &str,
|
||||
tool_use_id: &str,
|
||||
result: serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
if let Some(bridge) = self.bridges.get_mut(conversation_id) {
|
||||
bridge.send_tool_result(tool_use_id, result)
|
||||
} else {
|
||||
Err("No Claude instance found for this conversation".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_claude_running(&self, conversation_id: &str) -> bool {
|
||||
self.bridges.get(conversation_id)
|
||||
self.bridges
|
||||
.get(conversation_id)
|
||||
.map(|b| b.is_running())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn get_working_directory(&self, conversation_id: &str) -> Result<String, String> {
|
||||
self.bridges.get(conversation_id)
|
||||
self.bridges
|
||||
.get(conversation_id)
|
||||
.map(|b| b.get_working_directory().to_string())
|
||||
.ok_or_else(|| "No Claude instance found for this conversation".to_string())
|
||||
}
|
||||
|
||||
pub fn get_usage_stats(&self, conversation_id: &str) -> Result<UsageStats, String> {
|
||||
self.bridges.get(conversation_id)
|
||||
self.bridges
|
||||
.get(conversation_id)
|
||||
.map(|b| b.get_stats())
|
||||
.ok_or_else(|| "No Claude instance found for this conversation".to_string())
|
||||
}
|
||||
@@ -115,8 +143,14 @@ impl BridgeManager {
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_active_conversations(&self) -> Vec<String> {
|
||||
self.bridges.keys()
|
||||
.filter(|id| self.bridges.get(*id).map(|b| b.is_running()).unwrap_or(false))
|
||||
self.bridges
|
||||
.keys()
|
||||
.filter(|id| {
|
||||
self.bridges
|
||||
.get(*id)
|
||||
.map(|b| b.is_running())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
@@ -132,4 +166,4 @@ pub type SharedBridgeManager = Arc<Mutex<BridgeManager>>;
|
||||
|
||||
pub fn create_shared_bridge_manager() -> SharedBridgeManager {
|
||||
Arc::new(Mutex::new(BridgeManager::new()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,724 @@
|
||||
// Clipboard history module for tracking and managing copied code snippets
|
||||
// Implements issue #25 - Clipboard History feature
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
const STORE_FILE: &str = "hikari-clipboard.json";
|
||||
const HISTORY_KEY: &str = "clipboard_history";
|
||||
const MAX_HISTORY_SIZE: usize = 100;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClipboardEntry {
|
||||
pub id: String,
|
||||
pub content: String,
|
||||
pub language: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub timestamp: String,
|
||||
pub is_pinned: bool,
|
||||
}
|
||||
|
||||
impl ClipboardEntry {
|
||||
pub fn new(content: String, language: Option<String>, source: Option<String>) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
content,
|
||||
language,
|
||||
source,
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
is_pinned: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
struct ClipboardHistory {
|
||||
entries: Vec<ClipboardEntry>,
|
||||
}
|
||||
|
||||
// Track last clipboard content to avoid duplicates
|
||||
#[derive(Default)]
|
||||
struct ClipboardState {
|
||||
last_content: Option<String>,
|
||||
}
|
||||
|
||||
static CLIPBOARD_STATE: Mutex<ClipboardState> = Mutex::new(ClipboardState { last_content: None });
|
||||
|
||||
fn load_history(app: &tauri::AppHandle) -> ClipboardHistory {
|
||||
let store = app.store(STORE_FILE).ok();
|
||||
store
|
||||
.and_then(|s| s.get(HISTORY_KEY))
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_history(app: &tauri::AppHandle, history: &ClipboardHistory) -> Result<(), String> {
|
||||
let store = app.store(STORE_FILE).map_err(|e| e.to_string())?;
|
||||
store.set(
|
||||
HISTORY_KEY,
|
||||
serde_json::to_value(history).map_err(|e| e.to_string())?,
|
||||
);
|
||||
store.save().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all clipboard entries, optionally filtered by language
|
||||
#[tauri::command]
|
||||
pub fn list_clipboard_entries(
|
||||
app: tauri::AppHandle,
|
||||
language: Option<String>,
|
||||
) -> Result<Vec<ClipboardEntry>, String> {
|
||||
let history = load_history(&app);
|
||||
let entries = if let Some(lang) = language {
|
||||
history
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|e| e.language.as_ref() == Some(&lang))
|
||||
.collect()
|
||||
} else {
|
||||
history.entries
|
||||
};
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Capture current clipboard content and add to history
|
||||
#[tauri::command]
|
||||
pub fn capture_clipboard(
|
||||
app: tauri::AppHandle,
|
||||
content: String,
|
||||
language: Option<String>,
|
||||
source: Option<String>,
|
||||
) -> Result<ClipboardEntry, String> {
|
||||
// Check for duplicate (same content as last capture)
|
||||
{
|
||||
let mut state = CLIPBOARD_STATE.lock().map_err(|e| e.to_string())?;
|
||||
if state.last_content.as_ref() == Some(&content) {
|
||||
// Return existing entry if content is the same
|
||||
let history = load_history(&app);
|
||||
if let Some(entry) = history.entries.first() {
|
||||
if entry.content == content {
|
||||
return Ok(entry.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
state.last_content = Some(content.clone());
|
||||
}
|
||||
|
||||
let entry = ClipboardEntry::new(content, language, source);
|
||||
let mut history = load_history(&app);
|
||||
|
||||
// Add to front of history
|
||||
history.entries.insert(0, entry.clone());
|
||||
|
||||
// Enforce max size (keep pinned entries)
|
||||
let mut pinned: Vec<ClipboardEntry> = history
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| e.is_pinned)
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut unpinned: Vec<ClipboardEntry> = history
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|e| !e.is_pinned)
|
||||
.collect();
|
||||
|
||||
// Trim unpinned entries if over max size
|
||||
if unpinned.len() + pinned.len() > MAX_HISTORY_SIZE {
|
||||
let max_unpinned = MAX_HISTORY_SIZE.saturating_sub(pinned.len());
|
||||
unpinned.truncate(max_unpinned);
|
||||
}
|
||||
|
||||
// Merge back, pinned first then unpinned
|
||||
pinned.extend(unpinned);
|
||||
history.entries = pinned;
|
||||
|
||||
// Sort by timestamp descending (newest first), pinned entries stay at top
|
||||
history.entries.sort_by(|a, b| {
|
||||
if a.is_pinned && !b.is_pinned {
|
||||
std::cmp::Ordering::Less
|
||||
} else if !a.is_pinned && b.is_pinned {
|
||||
std::cmp::Ordering::Greater
|
||||
} else {
|
||||
b.timestamp.cmp(&a.timestamp)
|
||||
}
|
||||
});
|
||||
|
||||
save_history(&app, &history)?;
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Delete a clipboard entry by ID
|
||||
#[tauri::command]
|
||||
pub fn delete_clipboard_entry(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
||||
let mut history = load_history(&app);
|
||||
history.entries.retain(|e| e.id != id);
|
||||
save_history(&app, &history)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Toggle pin status of an entry
|
||||
#[tauri::command]
|
||||
pub fn toggle_pin_clipboard_entry(
|
||||
app: tauri::AppHandle,
|
||||
id: String,
|
||||
) -> Result<ClipboardEntry, String> {
|
||||
let mut history = load_history(&app);
|
||||
let entry = history
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|e| e.id == id)
|
||||
.ok_or("Entry not found")?;
|
||||
|
||||
entry.is_pinned = !entry.is_pinned;
|
||||
let updated_entry = entry.clone();
|
||||
|
||||
// Re-sort to move pinned entries to top
|
||||
history.entries.sort_by(|a, b| {
|
||||
if a.is_pinned && !b.is_pinned {
|
||||
std::cmp::Ordering::Less
|
||||
} else if !a.is_pinned && b.is_pinned {
|
||||
std::cmp::Ordering::Greater
|
||||
} else {
|
||||
b.timestamp.cmp(&a.timestamp)
|
||||
}
|
||||
});
|
||||
|
||||
save_history(&app, &history)?;
|
||||
Ok(updated_entry)
|
||||
}
|
||||
|
||||
/// Clear all non-pinned entries
|
||||
#[tauri::command]
|
||||
pub fn clear_clipboard_history(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let mut history = load_history(&app);
|
||||
history.entries.retain(|e| e.is_pinned);
|
||||
save_history(&app, &history)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Search clipboard entries by content
|
||||
#[tauri::command]
|
||||
pub fn search_clipboard_entries(
|
||||
app: tauri::AppHandle,
|
||||
query: String,
|
||||
) -> Result<Vec<ClipboardEntry>, String> {
|
||||
let history = load_history(&app);
|
||||
let query_lower = query.to_lowercase();
|
||||
let entries = history
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
e.content.to_lowercase().contains(&query_lower)
|
||||
|| e.language
|
||||
.as_ref()
|
||||
.is_some_and(|l| l.to_lowercase().contains(&query_lower))
|
||||
|| e.source
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.to_lowercase().contains(&query_lower))
|
||||
})
|
||||
.collect();
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Get all unique languages from history
|
||||
#[tauri::command]
|
||||
pub fn get_clipboard_languages(app: tauri::AppHandle) -> Result<Vec<String>, String> {
|
||||
let history = load_history(&app);
|
||||
let mut languages: Vec<String> = history
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|e| e.language.clone())
|
||||
.collect();
|
||||
languages.sort();
|
||||
languages.dedup();
|
||||
Ok(languages)
|
||||
}
|
||||
|
||||
/// Update the language of an entry
|
||||
#[tauri::command]
|
||||
pub fn update_clipboard_language(
|
||||
app: tauri::AppHandle,
|
||||
id: String,
|
||||
language: Option<String>,
|
||||
) -> Result<ClipboardEntry, String> {
|
||||
let mut history = load_history(&app);
|
||||
let entry = history
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|e| e.id == id)
|
||||
.ok_or("Entry not found")?;
|
||||
|
||||
entry.language = language;
|
||||
let updated_entry = entry.clone();
|
||||
|
||||
save_history(&app, &history)?;
|
||||
Ok(updated_entry)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ==================== ClipboardEntry tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_entry_new() {
|
||||
let entry = ClipboardEntry::new(
|
||||
"let x = 42;".to_string(),
|
||||
Some("rust".to_string()),
|
||||
Some("main.rs".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(entry.content, "let x = 42;");
|
||||
assert_eq!(entry.language, Some("rust".to_string()));
|
||||
assert_eq!(entry.source, Some("main.rs".to_string()));
|
||||
assert!(!entry.is_pinned);
|
||||
assert!(!entry.id.is_empty());
|
||||
assert!(!entry.timestamp.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_entry_new_without_optional_fields() {
|
||||
let entry = ClipboardEntry::new("some content".to_string(), None, None);
|
||||
|
||||
assert_eq!(entry.content, "some content");
|
||||
assert!(entry.language.is_none());
|
||||
assert!(entry.source.is_none());
|
||||
assert!(!entry.is_pinned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_entry_unique_ids() {
|
||||
let entry1 = ClipboardEntry::new("content1".to_string(), None, None);
|
||||
let entry2 = ClipboardEntry::new("content2".to_string(), None, None);
|
||||
|
||||
assert_ne!(entry1.id, entry2.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_entry_serialization() {
|
||||
let entry = ClipboardEntry::new(
|
||||
"fn main() {}".to_string(),
|
||||
Some("rust".to_string()),
|
||||
Some("lib.rs".to_string()),
|
||||
);
|
||||
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
assert!(json.contains("fn main() {}"));
|
||||
assert!(json.contains("rust"));
|
||||
assert!(json.contains("lib.rs"));
|
||||
assert!(json.contains("is_pinned"));
|
||||
|
||||
let deserialized: ClipboardEntry = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.content, entry.content);
|
||||
assert_eq!(deserialized.language, entry.language);
|
||||
assert_eq!(deserialized.source, entry.source);
|
||||
assert_eq!(deserialized.id, entry.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_entry_clone() {
|
||||
let entry = ClipboardEntry::new(
|
||||
"original".to_string(),
|
||||
Some("python".to_string()),
|
||||
None,
|
||||
);
|
||||
|
||||
let cloned = entry.clone();
|
||||
assert_eq!(cloned.content, entry.content);
|
||||
assert_eq!(cloned.id, entry.id);
|
||||
assert_eq!(cloned.language, entry.language);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_entry_timestamp_is_rfc3339() {
|
||||
let entry = ClipboardEntry::new("test".to_string(), None, None);
|
||||
|
||||
// RFC3339 timestamp should parse successfully
|
||||
let parsed = chrono::DateTime::parse_from_rfc3339(&entry.timestamp);
|
||||
assert!(parsed.is_ok());
|
||||
}
|
||||
|
||||
// ==================== ClipboardHistory tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_history_default() {
|
||||
let history = ClipboardHistory::default();
|
||||
assert!(history.entries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_history_serialization() {
|
||||
let mut history = ClipboardHistory::default();
|
||||
history.entries.push(ClipboardEntry::new(
|
||||
"entry1".to_string(),
|
||||
Some("js".to_string()),
|
||||
None,
|
||||
));
|
||||
history.entries.push(ClipboardEntry::new(
|
||||
"entry2".to_string(),
|
||||
None,
|
||||
Some("file.txt".to_string()),
|
||||
));
|
||||
|
||||
let json = serde_json::to_string(&history).unwrap();
|
||||
assert!(json.contains("entry1"));
|
||||
assert!(json.contains("entry2"));
|
||||
assert!(json.contains("js"));
|
||||
assert!(json.contains("file.txt"));
|
||||
|
||||
let deserialized: ClipboardHistory = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.entries.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_history_entries_order() {
|
||||
let mut history = ClipboardHistory::default();
|
||||
|
||||
history.entries.push(ClipboardEntry::new("first".to_string(), None, None));
|
||||
history.entries.push(ClipboardEntry::new("second".to_string(), None, None));
|
||||
history.entries.push(ClipboardEntry::new("third".to_string(), None, None));
|
||||
|
||||
assert_eq!(history.entries[0].content, "first");
|
||||
assert_eq!(history.entries[1].content, "second");
|
||||
assert_eq!(history.entries[2].content, "third");
|
||||
}
|
||||
|
||||
// ==================== ClipboardState tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_state_default() {
|
||||
let state = ClipboardState::default();
|
||||
assert!(state.last_content.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_state_with_content() {
|
||||
let state = ClipboardState {
|
||||
last_content: Some("cached content".to_string()),
|
||||
};
|
||||
assert_eq!(state.last_content, Some("cached content".to_string()));
|
||||
}
|
||||
|
||||
// ==================== MAX_HISTORY_SIZE constant test ====================
|
||||
|
||||
#[test]
|
||||
fn test_max_history_size_is_reasonable() {
|
||||
assert_eq!(MAX_HISTORY_SIZE, 100);
|
||||
// Compile-time assertions for constant bounds
|
||||
const _: () = assert!(MAX_HISTORY_SIZE > 0);
|
||||
const _: () = assert!(MAX_HISTORY_SIZE <= 1000); // Sanity check
|
||||
}
|
||||
|
||||
// ==================== Pinned entry sorting tests ====================
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::useless_vec)]
|
||||
fn test_pinned_entries_sorting() {
|
||||
let mut entries = vec![
|
||||
ClipboardEntry {
|
||||
id: "1".to_string(),
|
||||
content: "unpinned older".to_string(),
|
||||
language: None,
|
||||
source: None,
|
||||
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
||||
is_pinned: false,
|
||||
},
|
||||
ClipboardEntry {
|
||||
id: "2".to_string(),
|
||||
content: "pinned".to_string(),
|
||||
language: None,
|
||||
source: None,
|
||||
timestamp: "2024-01-02T00:00:00Z".to_string(),
|
||||
is_pinned: true,
|
||||
},
|
||||
ClipboardEntry {
|
||||
id: "3".to_string(),
|
||||
content: "unpinned newer".to_string(),
|
||||
language: None,
|
||||
source: None,
|
||||
timestamp: "2024-01-03T00:00:00Z".to_string(),
|
||||
is_pinned: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Apply the same sorting logic as used in the module
|
||||
entries.sort_by(|a, b| {
|
||||
if a.is_pinned && !b.is_pinned {
|
||||
std::cmp::Ordering::Less
|
||||
} else if !a.is_pinned && b.is_pinned {
|
||||
std::cmp::Ordering::Greater
|
||||
} else {
|
||||
b.timestamp.cmp(&a.timestamp)
|
||||
}
|
||||
});
|
||||
|
||||
// Pinned should be first
|
||||
assert!(entries[0].is_pinned);
|
||||
assert_eq!(entries[0].id, "2");
|
||||
|
||||
// Then unpinned sorted by timestamp descending (newest first)
|
||||
assert_eq!(entries[1].id, "3"); // newer unpinned
|
||||
assert_eq!(entries[2].id, "1"); // older unpinned
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::useless_vec)]
|
||||
fn test_multiple_pinned_entries_sorting() {
|
||||
let mut entries = vec![
|
||||
ClipboardEntry {
|
||||
id: "1".to_string(),
|
||||
content: "pinned older".to_string(),
|
||||
language: None,
|
||||
source: None,
|
||||
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
||||
is_pinned: true,
|
||||
},
|
||||
ClipboardEntry {
|
||||
id: "2".to_string(),
|
||||
content: "unpinned".to_string(),
|
||||
language: None,
|
||||
source: None,
|
||||
timestamp: "2024-01-02T00:00:00Z".to_string(),
|
||||
is_pinned: false,
|
||||
},
|
||||
ClipboardEntry {
|
||||
id: "3".to_string(),
|
||||
content: "pinned newer".to_string(),
|
||||
language: None,
|
||||
source: None,
|
||||
timestamp: "2024-01-03T00:00:00Z".to_string(),
|
||||
is_pinned: true,
|
||||
},
|
||||
];
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
if a.is_pinned && !b.is_pinned {
|
||||
std::cmp::Ordering::Less
|
||||
} else if !a.is_pinned && b.is_pinned {
|
||||
std::cmp::Ordering::Greater
|
||||
} else {
|
||||
b.timestamp.cmp(&a.timestamp)
|
||||
}
|
||||
});
|
||||
|
||||
// Both pinned first, sorted by timestamp
|
||||
assert!(entries[0].is_pinned);
|
||||
assert_eq!(entries[0].id, "3"); // pinned newer
|
||||
assert!(entries[1].is_pinned);
|
||||
assert_eq!(entries[1].id, "1"); // pinned older
|
||||
// Then unpinned
|
||||
assert!(!entries[2].is_pinned);
|
||||
assert_eq!(entries[2].id, "2");
|
||||
}
|
||||
|
||||
// ==================== Entry filtering tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_filter_entries_by_language() {
|
||||
let history = ClipboardHistory {
|
||||
entries: vec![
|
||||
ClipboardEntry {
|
||||
id: "1".to_string(),
|
||||
content: "rust code".to_string(),
|
||||
language: Some("rust".to_string()),
|
||||
source: None,
|
||||
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
||||
is_pinned: false,
|
||||
},
|
||||
ClipboardEntry {
|
||||
id: "2".to_string(),
|
||||
content: "js code".to_string(),
|
||||
language: Some("javascript".to_string()),
|
||||
source: None,
|
||||
timestamp: "2024-01-02T00:00:00Z".to_string(),
|
||||
is_pinned: false,
|
||||
},
|
||||
ClipboardEntry {
|
||||
id: "3".to_string(),
|
||||
content: "more rust".to_string(),
|
||||
language: Some("rust".to_string()),
|
||||
source: None,
|
||||
timestamp: "2024-01-03T00:00:00Z".to_string(),
|
||||
is_pinned: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let filtered: Vec<_> = history
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| e.language.as_ref() == Some(&"rust".to_string()))
|
||||
.collect();
|
||||
|
||||
assert_eq!(filtered.len(), 2);
|
||||
assert!(filtered.iter().all(|e| e.language == Some("rust".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_entries_by_content() {
|
||||
let history = ClipboardHistory {
|
||||
entries: vec![
|
||||
ClipboardEntry {
|
||||
id: "1".to_string(),
|
||||
content: "fn hello_world()".to_string(),
|
||||
language: Some("rust".to_string()),
|
||||
source: None,
|
||||
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
||||
is_pinned: false,
|
||||
},
|
||||
ClipboardEntry {
|
||||
id: "2".to_string(),
|
||||
content: "function hello()".to_string(),
|
||||
language: Some("javascript".to_string()),
|
||||
source: None,
|
||||
timestamp: "2024-01-02T00:00:00Z".to_string(),
|
||||
is_pinned: false,
|
||||
},
|
||||
ClipboardEntry {
|
||||
id: "3".to_string(),
|
||||
content: "def goodbye()".to_string(),
|
||||
language: Some("python".to_string()),
|
||||
source: None,
|
||||
timestamp: "2024-01-03T00:00:00Z".to_string(),
|
||||
is_pinned: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let query = "hello";
|
||||
let query_lower = query.to_lowercase();
|
||||
let filtered: Vec<_> = history
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| e.content.to_lowercase().contains(&query_lower))
|
||||
.collect();
|
||||
|
||||
assert_eq!(filtered.len(), 2);
|
||||
assert!(filtered[0].content.contains("hello"));
|
||||
assert!(filtered[1].content.contains("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_entries_case_insensitive() {
|
||||
let history = ClipboardHistory {
|
||||
entries: vec![
|
||||
ClipboardEntry {
|
||||
id: "1".to_string(),
|
||||
content: "HELLO WORLD".to_string(),
|
||||
language: None,
|
||||
source: None,
|
||||
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
||||
is_pinned: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let query = "hello";
|
||||
let query_lower = query.to_lowercase();
|
||||
let filtered: Vec<_> = history
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| e.content.to_lowercase().contains(&query_lower))
|
||||
.collect();
|
||||
|
||||
assert_eq!(filtered.len(), 1);
|
||||
}
|
||||
|
||||
// ==================== Unique languages extraction test ====================
|
||||
|
||||
#[test]
|
||||
fn test_extract_unique_languages() {
|
||||
let history = ClipboardHistory {
|
||||
entries: vec![
|
||||
ClipboardEntry {
|
||||
id: "1".to_string(),
|
||||
content: "".to_string(),
|
||||
language: Some("rust".to_string()),
|
||||
source: None,
|
||||
timestamp: "".to_string(),
|
||||
is_pinned: false,
|
||||
},
|
||||
ClipboardEntry {
|
||||
id: "2".to_string(),
|
||||
content: "".to_string(),
|
||||
language: Some("javascript".to_string()),
|
||||
source: None,
|
||||
timestamp: "".to_string(),
|
||||
is_pinned: false,
|
||||
},
|
||||
ClipboardEntry {
|
||||
id: "3".to_string(),
|
||||
content: "".to_string(),
|
||||
language: Some("rust".to_string()), // Duplicate
|
||||
source: None,
|
||||
timestamp: "".to_string(),
|
||||
is_pinned: false,
|
||||
},
|
||||
ClipboardEntry {
|
||||
id: "4".to_string(),
|
||||
content: "".to_string(),
|
||||
language: None, // No language
|
||||
source: None,
|
||||
timestamp: "".to_string(),
|
||||
is_pinned: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let mut languages: Vec<String> = history
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|e| e.language.clone())
|
||||
.collect();
|
||||
languages.sort();
|
||||
languages.dedup();
|
||||
|
||||
assert_eq!(languages.len(), 2);
|
||||
assert!(languages.contains(&"rust".to_string()));
|
||||
assert!(languages.contains(&"javascript".to_string()));
|
||||
}
|
||||
|
||||
// ==================== Retain pinned entries test ====================
|
||||
|
||||
#[test]
|
||||
fn test_retain_pinned_on_clear() {
|
||||
let mut history = ClipboardHistory {
|
||||
entries: vec![
|
||||
ClipboardEntry {
|
||||
id: "1".to_string(),
|
||||
content: "pinned".to_string(),
|
||||
language: None,
|
||||
source: None,
|
||||
timestamp: "".to_string(),
|
||||
is_pinned: true,
|
||||
},
|
||||
ClipboardEntry {
|
||||
id: "2".to_string(),
|
||||
content: "unpinned".to_string(),
|
||||
language: None,
|
||||
source: None,
|
||||
timestamp: "".to_string(),
|
||||
is_pinned: false,
|
||||
},
|
||||
ClipboardEntry {
|
||||
id: "3".to_string(),
|
||||
content: "another pinned".to_string(),
|
||||
language: None,
|
||||
source: None,
|
||||
timestamp: "".to_string(),
|
||||
is_pinned: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Simulate clear (keep only pinned)
|
||||
history.entries.retain(|e| e.is_pinned);
|
||||
|
||||
assert_eq!(history.entries.len(), 2);
|
||||
assert!(history.entries.iter().all(|e| e.is_pinned));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_http::reqwest;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
use crate::achievements::{get_achievement_info, load_achievements, AchievementUnlockedEvent};
|
||||
use crate::bridge_manager::SharedBridgeManager;
|
||||
use crate::config::{ClaudeStartOptions, HikariConfig};
|
||||
use crate::stats::UsageStats;
|
||||
use crate::bridge_manager::SharedBridgeManager;
|
||||
use crate::achievements::{load_achievements, get_achievement_info, AchievementUnlockedEvent};
|
||||
use crate::temp_manager::SharedTempFileManager;
|
||||
|
||||
const CONFIG_STORE_KEY: &str = "config";
|
||||
|
||||
@@ -71,23 +74,17 @@ pub async fn select_wsl_directory() -> Result<String, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_config(app: AppHandle) -> Result<HikariConfig, String> {
|
||||
let store = app
|
||||
.store("hikari-config.json")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let store = app.store("hikari-config.json").map_err(|e| e.to_string())?;
|
||||
|
||||
match store.get(CONFIG_STORE_KEY) {
|
||||
Some(value) => {
|
||||
serde_json::from_value(value.clone()).map_err(|e| e.to_string())
|
||||
}
|
||||
Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()),
|
||||
None => Ok(HikariConfig::default()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), String> {
|
||||
let store = app
|
||||
.store("hikari-config.json")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let store = app.store("hikari-config.json").map_err(|e| e.to_string())?;
|
||||
|
||||
let value = serde_json::to_value(&config).map_err(|e| e.to_string())?;
|
||||
store.set(CONFIG_STORE_KEY, value);
|
||||
@@ -105,8 +102,77 @@ pub async fn get_usage_stats(
|
||||
manager.get_usage_stats(&conversation_id)
|
||||
}
|
||||
|
||||
/// Load persisted lifetime stats from store (no bridge required)
|
||||
#[tauri::command]
|
||||
pub async fn load_saved_achievements(app: AppHandle) -> Result<Vec<AchievementUnlockedEvent>, String> {
|
||||
pub async fn get_persisted_stats(app: AppHandle) -> Result<UsageStats, String> {
|
||||
let mut stats = UsageStats::new();
|
||||
|
||||
// Load persisted stats if available
|
||||
if let Some(persisted) = crate::stats::load_stats(&app).await {
|
||||
stats.apply_persisted(persisted);
|
||||
}
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn validate_directory(
|
||||
path: String,
|
||||
current_dir: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
use std::path::Path;
|
||||
|
||||
let path = Path::new(&path);
|
||||
|
||||
// Expand ~ to home directory
|
||||
let expanded_path = if path.starts_with("~") {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
let home_path = Path::new(&home);
|
||||
if path == Path::new("~") {
|
||||
home_path.to_path_buf()
|
||||
} else {
|
||||
home_path.join(path.strip_prefix("~").unwrap())
|
||||
}
|
||||
} else {
|
||||
return Err("Could not determine home directory".to_string());
|
||||
}
|
||||
} else if path.is_relative() {
|
||||
// Handle relative paths (., .., or any relative path) by resolving against current_dir
|
||||
if let Some(ref cwd) = current_dir {
|
||||
Path::new(cwd).join(path)
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
}
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
|
||||
// Check if the path exists and is a directory
|
||||
if !expanded_path.exists() {
|
||||
return Err(format!(
|
||||
"Directory does not exist: {}",
|
||||
expanded_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
if !expanded_path.is_dir() {
|
||||
return Err(format!(
|
||||
"Path is not a directory: {}",
|
||||
expanded_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Return the canonicalized (absolute) path
|
||||
expanded_path
|
||||
.canonicalize()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.map_err(|e| format!("Failed to resolve path: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_saved_achievements(
|
||||
app: AppHandle,
|
||||
) -> Result<Vec<AchievementUnlockedEvent>, String> {
|
||||
use chrono::Utc;
|
||||
|
||||
// Load achievements from persistent store
|
||||
@@ -117,10 +183,473 @@ pub async fn load_saved_achievements(app: AppHandle) -> Result<Vec<AchievementUn
|
||||
for achievement_id in &progress.unlocked {
|
||||
let mut info = get_achievement_info(achievement_id);
|
||||
info.unlocked_at = Some(Utc::now()); // We don't store timestamps, so just use now
|
||||
events.push(AchievementUnlockedEvent {
|
||||
achievement: info,
|
||||
});
|
||||
events.push(AchievementUnlockedEvent { achievement: info });
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn answer_question(
|
||||
bridge_manager: State<'_, SharedBridgeManager>,
|
||||
conversation_id: String,
|
||||
tool_use_id: String,
|
||||
answers: serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
let mut manager = bridge_manager.lock();
|
||||
manager.send_tool_result(&conversation_id, &tool_use_id, answers)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_skills() -> Result<Vec<String>, String> {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
// Get the home directory
|
||||
let home =
|
||||
std::env::var_os("HOME").ok_or_else(|| "Could not determine home directory".to_string())?;
|
||||
|
||||
let skills_dir = Path::new(&home).join(".claude").join("skills");
|
||||
|
||||
// If the skills directory doesn't exist, return empty list
|
||||
if !skills_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Read the directory and collect skill names
|
||||
let mut skills = Vec::new();
|
||||
let entries =
|
||||
fs::read_dir(&skills_dir).map_err(|e| format!("Failed to read skills directory: {}", e))?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
|
||||
let path = entry.path();
|
||||
|
||||
// Only include directories that contain a SKILL.md file
|
||||
if path.is_dir() {
|
||||
let skill_file = path.join("SKILL.md");
|
||||
if skill_file.exists() {
|
||||
if let Some(name) = path.file_name() {
|
||||
skills.push(name.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort alphabetically
|
||||
skills.sort();
|
||||
|
||||
Ok(skills)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UpdateInfo {
|
||||
pub current_version: String,
|
||||
pub latest_version: String,
|
||||
pub has_update: bool,
|
||||
pub release_url: String,
|
||||
pub release_notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct GiteaRelease {
|
||||
tag_name: String,
|
||||
html_url: String,
|
||||
body: Option<String>,
|
||||
prerelease: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_updates() -> Result<UpdateInfo, String> {
|
||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const RELEASES_API: &str =
|
||||
"https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/hikari-desktop/releases";
|
||||
|
||||
// Fetch releases from Gitea API
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(RELEASES_API)
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch releases: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("API returned status: {}", response.status()));
|
||||
}
|
||||
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response: {}", e))?;
|
||||
|
||||
let releases: Vec<GiteaRelease> =
|
||||
serde_json::from_str(&text).map_err(|e| format!("Failed to parse releases: {}", e))?;
|
||||
|
||||
// Find the latest non-prerelease, or fall back to latest prerelease
|
||||
let latest = releases
|
||||
.iter()
|
||||
.find(|r| !r.prerelease)
|
||||
.or_else(|| releases.first());
|
||||
|
||||
let latest = match latest {
|
||||
Some(r) => r,
|
||||
None => return Err("No releases found".to_string()),
|
||||
};
|
||||
|
||||
// Parse version strings (remove 'v' prefix if present)
|
||||
let current = semver::Version::parse(CURRENT_VERSION)
|
||||
.map_err(|e| format!("Failed to parse current version: {}", e))?;
|
||||
|
||||
let latest_tag = latest.tag_name.trim_start_matches('v');
|
||||
let latest_ver = semver::Version::parse(latest_tag)
|
||||
.map_err(|e| format!("Failed to parse latest version: {}", e))?;
|
||||
|
||||
Ok(UpdateInfo {
|
||||
current_version: CURRENT_VERSION.to_string(),
|
||||
latest_version: latest.tag_name.clone(),
|
||||
has_update: latest_ver > current,
|
||||
release_url: latest.html_url.clone(),
|
||||
release_notes: latest.body.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct SavedFileInfo {
|
||||
pub path: String,
|
||||
pub filename: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_temp_file(
|
||||
temp_manager: State<'_, SharedTempFileManager>,
|
||||
conversation_id: String,
|
||||
data: Vec<u8>,
|
||||
filename: Option<String>,
|
||||
) -> Result<SavedFileInfo, String> {
|
||||
let mut manager = temp_manager.lock();
|
||||
let path = manager.save_file(&conversation_id, &data, filename.as_deref())?;
|
||||
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
Ok(SavedFileInfo {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
filename,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn register_temp_file(
|
||||
temp_manager: State<'_, SharedTempFileManager>,
|
||||
conversation_id: String,
|
||||
file_path: String,
|
||||
) -> Result<(), String> {
|
||||
let mut manager = temp_manager.lock();
|
||||
manager.register_file(&conversation_id, PathBuf::from(file_path));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_temp_files(
|
||||
temp_manager: State<'_, SharedTempFileManager>,
|
||||
conversation_id: String,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let manager = temp_manager.lock();
|
||||
let files = manager.get_files_for_conversation(&conversation_id);
|
||||
Ok(files.iter().map(|p| p.to_string_lossy().to_string()).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cleanup_temp_files(
|
||||
temp_manager: State<'_, SharedTempFileManager>,
|
||||
conversation_id: String,
|
||||
) -> Result<(), String> {
|
||||
let mut manager = temp_manager.lock();
|
||||
manager.cleanup_conversation(&conversation_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cleanup_all_temp_files(
|
||||
temp_manager: State<'_, SharedTempFileManager>,
|
||||
) -> Result<(), String> {
|
||||
let mut manager = temp_manager.lock();
|
||||
manager.cleanup_all()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cleanup_orphaned_temp_files(
|
||||
temp_manager: State<'_, SharedTempFileManager>,
|
||||
) -> Result<usize, String> {
|
||||
let mut manager = temp_manager.lock();
|
||||
manager.cleanup_orphaned_files()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_file_size(file_path: String) -> Result<u64, String> {
|
||||
let metadata = std::fs::metadata(&file_path)
|
||||
.map_err(|e| format!("Failed to get file metadata: {}", e))?;
|
||||
Ok(metadata.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// Helper to run async tests
|
||||
fn run_async<F: std::future::Future>(f: F) -> F::Output {
|
||||
tokio::runtime::Runtime::new().unwrap().block_on(f)
|
||||
}
|
||||
|
||||
// ==================== validate_directory tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_validate_directory_absolute_path_exists() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let path = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
let result = run_async(validate_directory(path.clone(), None));
|
||||
assert!(result.is_ok());
|
||||
// Canonicalized path should be returned
|
||||
assert!(result.unwrap().contains(&temp_dir.path().file_name().unwrap().to_string_lossy().to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_directory_path_not_exists() {
|
||||
let result = run_async(validate_directory(
|
||||
"/nonexistent/path/that/does/not/exist".to_string(),
|
||||
None,
|
||||
));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_directory_path_is_file() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("test_file.txt");
|
||||
File::create(&file_path).unwrap();
|
||||
|
||||
let result = run_async(validate_directory(
|
||||
file_path.to_string_lossy().to_string(),
|
||||
None,
|
||||
));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not a directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_directory_home_expansion() {
|
||||
// This test assumes HOME is set (which it should be on most systems)
|
||||
if std::env::var_os("HOME").is_some() {
|
||||
let result = run_async(validate_directory("~".to_string(), None));
|
||||
assert!(result.is_ok());
|
||||
// Should not contain ~ after expansion
|
||||
assert!(!result.unwrap().contains("~"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_directory_home_subpath_expansion() {
|
||||
// This test assumes HOME is set and has some subdirectory
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
let home_path = std::path::Path::new(&home);
|
||||
// Find any subdirectory in home
|
||||
if let Ok(entries) = fs::read_dir(home_path) {
|
||||
for entry in entries.flatten() {
|
||||
if entry.path().is_dir() {
|
||||
let subdir_name = entry.file_name().to_string_lossy().to_string();
|
||||
let tilde_path = format!("~/{}", subdir_name);
|
||||
let result = run_async(validate_directory(tilde_path, None));
|
||||
assert!(result.is_ok());
|
||||
assert!(!result.unwrap().contains("~"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_directory_relative_path_with_current_dir() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let subdir = temp_dir.path().join("subdir");
|
||||
fs::create_dir(&subdir).unwrap();
|
||||
|
||||
let result = run_async(validate_directory(
|
||||
"subdir".to_string(),
|
||||
Some(temp_dir.path().to_string_lossy().to_string()),
|
||||
));
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().contains("subdir"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_directory_dot_path() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
let result = run_async(validate_directory(
|
||||
".".to_string(),
|
||||
Some(temp_dir.path().to_string_lossy().to_string()),
|
||||
));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_directory_dotdot_path() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let subdir = temp_dir.path().join("subdir");
|
||||
fs::create_dir(&subdir).unwrap();
|
||||
|
||||
let result = run_async(validate_directory(
|
||||
"..".to_string(),
|
||||
Some(subdir.to_string_lossy().to_string()),
|
||||
));
|
||||
assert!(result.is_ok());
|
||||
// Should resolve to parent
|
||||
let resolved = result.unwrap();
|
||||
assert!(resolved.contains(&temp_dir.path().file_name().unwrap().to_string_lossy().to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_directory_relative_without_current_dir() {
|
||||
// Relative path without current_dir - should fail since relative path likely won't exist
|
||||
let result = run_async(validate_directory(
|
||||
"some_random_nonexistent_relative_path".to_string(),
|
||||
None,
|
||||
));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ==================== get_file_size tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_get_file_size_empty_file() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("empty.txt");
|
||||
File::create(&file_path).unwrap();
|
||||
|
||||
let result = run_async(get_file_size(file_path.to_string_lossy().to_string()));
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_size_with_content() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("content.txt");
|
||||
let mut file = File::create(&file_path).unwrap();
|
||||
file.write_all(b"Hello, Hikari!").unwrap();
|
||||
|
||||
let result = run_async(get_file_size(file_path.to_string_lossy().to_string()));
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), 14); // "Hello, Hikari!" is 14 bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_size_larger_file() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("large.txt");
|
||||
let mut file = File::create(&file_path).unwrap();
|
||||
// Write 1000 bytes
|
||||
let data = vec![b'x'; 1000];
|
||||
file.write_all(&data).unwrap();
|
||||
|
||||
let result = run_async(get_file_size(file_path.to_string_lossy().to_string()));
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_size_nonexistent_file() {
|
||||
let result = run_async(get_file_size(
|
||||
"/nonexistent/path/file.txt".to_string(),
|
||||
));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Failed to get file metadata"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_size_directory() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
// Getting "size" of a directory should work but return directory metadata
|
||||
// This is actually valid - directories have metadata too
|
||||
let result = run_async(get_file_size(temp_dir.path().to_string_lossy().to_string()));
|
||||
assert!(result.is_ok());
|
||||
// Directory size is platform-dependent, just check it returns something
|
||||
}
|
||||
|
||||
// ==================== list_skills tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_list_skills_no_skills_dir() {
|
||||
// This test is tricky because it depends on HOME being set
|
||||
// and potentially affecting real user data, so we'll just
|
||||
// verify the function doesn't panic
|
||||
let result = run_async(list_skills());
|
||||
// Should either return Ok with a list or Ok with empty vec
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
// ==================== select_wsl_directory tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_select_wsl_directory_returns_home() {
|
||||
let result = run_async(select_wsl_directory());
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "/home");
|
||||
}
|
||||
|
||||
// ==================== UpdateInfo struct tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_update_info_serialization() {
|
||||
let info = UpdateInfo {
|
||||
current_version: "1.0.0".to_string(),
|
||||
latest_version: "0.4.0".to_string(),
|
||||
has_update: true,
|
||||
release_url: "https://example.com/release".to_string(),
|
||||
release_notes: Some("New features!".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&info).unwrap();
|
||||
assert!(json.contains("1.0.0"));
|
||||
assert!(json.contains("0.4.0"));
|
||||
assert!(json.contains("true"));
|
||||
assert!(json.contains("New features!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_info_without_notes() {
|
||||
let info = UpdateInfo {
|
||||
current_version: "1.0.0".to_string(),
|
||||
latest_version: "1.0.0".to_string(),
|
||||
has_update: false,
|
||||
release_url: "https://example.com/release".to_string(),
|
||||
release_notes: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&info).unwrap();
|
||||
assert!(json.contains("null") || json.contains("release_notes"));
|
||||
}
|
||||
|
||||
// ==================== SavedFileInfo struct tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_saved_file_info_serialization() {
|
||||
let info = SavedFileInfo {
|
||||
path: "/tmp/test.txt".to_string(),
|
||||
filename: "test.txt".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&info).unwrap();
|
||||
assert!(json.contains("/tmp/test.txt"));
|
||||
assert!(json.contains("test.txt"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ pub struct ClaudeStartOptions {
|
||||
|
||||
#[serde(default)]
|
||||
pub skip_greeting: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub resume_session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -55,6 +58,44 @@ pub struct HikariConfig {
|
||||
|
||||
#[serde(default = "default_notification_volume")]
|
||||
pub notification_volume: f32,
|
||||
|
||||
#[serde(default)]
|
||||
pub always_on_top: bool,
|
||||
|
||||
#[serde(default = "default_update_checks_enabled")]
|
||||
pub update_checks_enabled: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub character_panel_width: Option<u32>,
|
||||
|
||||
#[serde(default = "default_font_size")]
|
||||
pub font_size: u32,
|
||||
|
||||
#[serde(default)]
|
||||
pub minimize_to_tray: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub streamer_mode: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub streamer_hide_paths: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub compact_mode: bool,
|
||||
|
||||
// Profile fields
|
||||
#[serde(default)]
|
||||
pub profile_name: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub profile_avatar_path: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub profile_bio: Option<String>,
|
||||
|
||||
// Custom theme colors
|
||||
#[serde(default)]
|
||||
pub custom_theme_colors: CustomThemeColors,
|
||||
}
|
||||
|
||||
impl Default for HikariConfig {
|
||||
@@ -70,10 +111,26 @@ impl Default for HikariConfig {
|
||||
greeting_custom_prompt: None,
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: false,
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: None,
|
||||
font_size: 14,
|
||||
minimize_to_tray: false,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
profile_name: None,
|
||||
profile_avatar_path: None,
|
||||
profile_bio: None,
|
||||
custom_theme_colors: CustomThemeColors::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_update_checks_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_greeting_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -86,12 +143,39 @@ fn default_notification_volume() -> f32 {
|
||||
0.7
|
||||
}
|
||||
|
||||
fn default_font_size() -> u32 {
|
||||
14
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Theme {
|
||||
#[default]
|
||||
Dark,
|
||||
Light,
|
||||
#[serde(rename = "high-contrast")]
|
||||
HighContrast,
|
||||
Custom,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct CustomThemeColors {
|
||||
#[serde(default)]
|
||||
pub bg_primary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bg_secondary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bg_terminal: Option<String>,
|
||||
#[serde(default)]
|
||||
pub accent_primary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub accent_secondary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub text_primary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub text_secondary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub border_color: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -109,6 +193,18 @@ mod tests {
|
||||
assert_eq!(config.theme, Theme::Dark);
|
||||
assert!(config.greeting_enabled);
|
||||
assert!(config.greeting_custom_prompt.is_none());
|
||||
assert!(!config.always_on_top);
|
||||
assert!(config.update_checks_enabled);
|
||||
assert!(config.character_panel_width.is_none());
|
||||
assert_eq!(config.font_size, 14);
|
||||
assert!(!config.minimize_to_tray);
|
||||
assert!(!config.streamer_mode);
|
||||
assert!(!config.streamer_hide_paths);
|
||||
assert!(!config.compact_mode);
|
||||
assert!(config.profile_name.is_none());
|
||||
assert!(config.profile_avatar_path.is_none());
|
||||
assert!(config.profile_bio.is_none());
|
||||
assert_eq!(config.custom_theme_colors, CustomThemeColors::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -124,6 +220,18 @@ mod tests {
|
||||
greeting_custom_prompt: Some("Hello!".to_string()),
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: true,
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: Some(400),
|
||||
font_size: 16,
|
||||
minimize_to_tray: true,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
profile_name: Some("Test User".to_string()),
|
||||
profile_avatar_path: None,
|
||||
profile_bio: Some("A test bio".to_string()),
|
||||
custom_theme_colors: CustomThemeColors::default(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
@@ -134,15 +242,26 @@ mod tests {
|
||||
assert_eq!(deserialized.auto_granted_tools, config.auto_granted_tools);
|
||||
assert_eq!(deserialized.theme, Theme::Light);
|
||||
assert!(deserialized.greeting_enabled);
|
||||
assert_eq!(deserialized.greeting_custom_prompt, Some("Hello!".to_string()));
|
||||
assert_eq!(
|
||||
deserialized.greeting_custom_prompt,
|
||||
Some("Hello!".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_serialization() {
|
||||
let dark = Theme::Dark;
|
||||
let light = Theme::Light;
|
||||
let high_contrast = Theme::HighContrast;
|
||||
|
||||
assert_eq!(serde_json::to_string(&dark).unwrap(), "\"dark\"");
|
||||
assert_eq!(serde_json::to_string(&light).unwrap(), "\"light\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&high_contrast).unwrap(),
|
||||
"\"high-contrast\""
|
||||
);
|
||||
|
||||
let custom = Theme::Custom;
|
||||
assert_eq!(serde_json::to_string(&custom).unwrap(), "\"custom\"");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,878 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitStatus {
|
||||
pub is_repo: bool,
|
||||
pub branch: Option<String>,
|
||||
pub upstream: Option<String>,
|
||||
pub ahead: u32,
|
||||
pub behind: u32,
|
||||
pub staged: Vec<GitFileChange>,
|
||||
pub unstaged: Vec<GitFileChange>,
|
||||
pub untracked: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitFileChange {
|
||||
pub path: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitBranch {
|
||||
pub name: String,
|
||||
pub is_current: bool,
|
||||
pub is_remote: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitLogEntry {
|
||||
pub hash: String,
|
||||
pub short_hash: String,
|
||||
pub author: String,
|
||||
pub date: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(working_dir)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute git: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_status(working_dir: String) -> Result<GitStatus, String> {
|
||||
// Check if it's a git repo
|
||||
let is_repo = run_git_command(&working_dir, &["rev-parse", "--git-dir"]).is_ok();
|
||||
|
||||
if !is_repo {
|
||||
return Ok(GitStatus {
|
||||
is_repo: false,
|
||||
branch: None,
|
||||
upstream: None,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
staged: vec![],
|
||||
unstaged: vec![],
|
||||
untracked: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
// Get current branch
|
||||
let branch = run_git_command(&working_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
// Get upstream branch
|
||||
let upstream = run_git_command(
|
||||
&working_dir,
|
||||
&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
||||
)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
// Get ahead/behind counts
|
||||
let (ahead, behind) = if upstream.is_some() {
|
||||
let rev_list =
|
||||
run_git_command(&working_dir, &["rev-list", "--left-right", "--count", "@{u}...HEAD"])
|
||||
.unwrap_or_default();
|
||||
let parts: Vec<&str> = rev_list.trim().split('\t').collect();
|
||||
if parts.len() == 2 {
|
||||
(
|
||||
parts[1].parse().unwrap_or(0),
|
||||
parts[0].parse().unwrap_or(0),
|
||||
)
|
||||
} else {
|
||||
(0, 0)
|
||||
}
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
|
||||
// Get status with porcelain format
|
||||
let status_output =
|
||||
run_git_command(&working_dir, &["status", "--porcelain=v1"]).unwrap_or_default();
|
||||
|
||||
let mut staged = vec![];
|
||||
let mut unstaged = vec![];
|
||||
let mut untracked = vec![];
|
||||
|
||||
for line in status_output.lines() {
|
||||
if line.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let index_status = line.chars().next().unwrap_or(' ');
|
||||
let worktree_status = line.chars().nth(1).unwrap_or(' ');
|
||||
let path = line[3..].to_string();
|
||||
|
||||
// Untracked files
|
||||
if index_status == '?' && worktree_status == '?' {
|
||||
untracked.push(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Staged changes (index status)
|
||||
if index_status != ' ' && index_status != '?' {
|
||||
staged.push(GitFileChange {
|
||||
path: path.clone(),
|
||||
status: match index_status {
|
||||
'M' => "modified".to_string(),
|
||||
'A' => "added".to_string(),
|
||||
'D' => "deleted".to_string(),
|
||||
'R' => "renamed".to_string(),
|
||||
'C' => "copied".to_string(),
|
||||
_ => "unknown".to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Unstaged changes (worktree status)
|
||||
if worktree_status != ' ' && worktree_status != '?' {
|
||||
unstaged.push(GitFileChange {
|
||||
path,
|
||||
status: match worktree_status {
|
||||
'M' => "modified".to_string(),
|
||||
'D' => "deleted".to_string(),
|
||||
_ => "unknown".to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(GitStatus {
|
||||
is_repo: true,
|
||||
branch,
|
||||
upstream,
|
||||
ahead,
|
||||
behind,
|
||||
staged,
|
||||
unstaged,
|
||||
untracked,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_diff(working_dir: String, file_path: Option<String>, staged: bool) -> Result<String, String> {
|
||||
let mut args = vec!["diff"];
|
||||
|
||||
if staged {
|
||||
args.push("--cached");
|
||||
}
|
||||
|
||||
if let Some(ref path) = file_path {
|
||||
args.push("--");
|
||||
args.push(path);
|
||||
}
|
||||
|
||||
run_git_command(&working_dir, &args)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_branches(working_dir: String) -> Result<Vec<GitBranch>, String> {
|
||||
let output = run_git_command(&working_dir, &["branch", "-a", "--format=%(refname:short)\t%(HEAD)"])?;
|
||||
|
||||
let branches: Vec<GitBranch> = output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = parts[0].to_string();
|
||||
let is_current = parts.get(1).map(|s| *s == "*").unwrap_or(false);
|
||||
let is_remote = name.starts_with("remotes/") || name.starts_with("origin/");
|
||||
|
||||
Some(GitBranch {
|
||||
name,
|
||||
is_current,
|
||||
is_remote,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(branches)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_checkout(working_dir: String, branch: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["checkout", &branch])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_stage(working_dir: String, file_path: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["add", &file_path])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_unstage(working_dir: String, file_path: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["restore", "--staged", &file_path])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_stage_all(working_dir: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["add", "-A"])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_commit(working_dir: String, message: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["commit", "-m", &message])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_push(working_dir: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["push"])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_pull(working_dir: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["pull"])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_fetch(working_dir: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["fetch", "--all"])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_log(working_dir: String, limit: Option<u32>) -> Result<Vec<GitLogEntry>, String> {
|
||||
let limit_str = limit.unwrap_or(10).to_string();
|
||||
let output = run_git_command(
|
||||
&working_dir,
|
||||
&[
|
||||
"log",
|
||||
&format!("-{}", limit_str),
|
||||
"--pretty=format:%H\t%h\t%an\t%ar\t%s",
|
||||
],
|
||||
)?;
|
||||
|
||||
let entries: Vec<GitLogEntry> = output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(GitLogEntry {
|
||||
hash: parts[0].to_string(),
|
||||
short_hash: parts[1].to_string(),
|
||||
author: parts[2].to_string(),
|
||||
date: parts[3].to_string(),
|
||||
message: parts[4..].join("\t"),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_discard(working_dir: String, file_path: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["checkout", "--", &file_path])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_create_branch(working_dir: String, branch_name: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["checkout", "-b", &branch_name])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// Helper to create a git repository in a temp directory
|
||||
fn create_test_repo() -> TempDir {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
// Initialize git repo
|
||||
run_git_command(&working_dir, &["init"]).unwrap();
|
||||
|
||||
// Configure git user for commits
|
||||
run_git_command(&working_dir, &["config", "user.email", "test@example.com"]).unwrap();
|
||||
run_git_command(&working_dir, &["config", "user.name", "Test User"]).unwrap();
|
||||
|
||||
// Disable GPG signing for tests (user may have it enabled globally)
|
||||
run_git_command(&working_dir, &["config", "commit.gpgsign", "false"]).unwrap();
|
||||
|
||||
temp_dir
|
||||
}
|
||||
|
||||
// Helper to create a file in the test repo
|
||||
fn create_file(dir: &TempDir, name: &str, content: &str) {
|
||||
let file_path = dir.path().join(name);
|
||||
let mut file = File::create(file_path).unwrap();
|
||||
file.write_all(content.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
// ==================== GitStatus struct tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_git_status_serialization() {
|
||||
let status = GitStatus {
|
||||
is_repo: true,
|
||||
branch: Some("main".to_string()),
|
||||
upstream: Some("origin/main".to_string()),
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
staged: vec![GitFileChange {
|
||||
path: "file.txt".to_string(),
|
||||
status: "modified".to_string(),
|
||||
}],
|
||||
unstaged: vec![],
|
||||
untracked: vec!["new_file.txt".to_string()],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&status).unwrap();
|
||||
assert!(json.contains("\"is_repo\":true"));
|
||||
assert!(json.contains("\"branch\":\"main\""));
|
||||
assert!(json.contains("\"ahead\":2"));
|
||||
assert!(json.contains("\"behind\":1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_status_not_a_repo() {
|
||||
let status = GitStatus {
|
||||
is_repo: false,
|
||||
branch: None,
|
||||
upstream: None,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
staged: vec![],
|
||||
unstaged: vec![],
|
||||
untracked: vec![],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&status).unwrap();
|
||||
let deserialized: GitStatus = serde_json::from_str(&json).unwrap();
|
||||
assert!(!deserialized.is_repo);
|
||||
assert!(deserialized.branch.is_none());
|
||||
}
|
||||
|
||||
// ==================== GitFileChange struct tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_git_file_change_serialization() {
|
||||
let change = GitFileChange {
|
||||
path: "src/main.rs".to_string(),
|
||||
status: "added".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&change).unwrap();
|
||||
assert!(json.contains("src/main.rs"));
|
||||
assert!(json.contains("added"));
|
||||
|
||||
let deserialized: GitFileChange = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.path, "src/main.rs");
|
||||
assert_eq!(deserialized.status, "added");
|
||||
}
|
||||
|
||||
// ==================== GitBranch struct tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_git_branch_serialization() {
|
||||
let branch = GitBranch {
|
||||
name: "feature/new-feature".to_string(),
|
||||
is_current: true,
|
||||
is_remote: false,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&branch).unwrap();
|
||||
assert!(json.contains("feature/new-feature"));
|
||||
assert!(json.contains("\"is_current\":true"));
|
||||
assert!(json.contains("\"is_remote\":false"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_branch_remote() {
|
||||
let branch = GitBranch {
|
||||
name: "origin/main".to_string(),
|
||||
is_current: false,
|
||||
is_remote: true,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&branch).unwrap();
|
||||
let deserialized: GitBranch = serde_json::from_str(&json).unwrap();
|
||||
assert!(deserialized.is_remote);
|
||||
assert!(!deserialized.is_current);
|
||||
}
|
||||
|
||||
// ==================== GitLogEntry struct tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_git_log_entry_serialization() {
|
||||
let entry = GitLogEntry {
|
||||
hash: "abc123def456".to_string(),
|
||||
short_hash: "abc123d".to_string(),
|
||||
author: "Hikari".to_string(),
|
||||
date: "2 hours ago".to_string(),
|
||||
message: "feat: add new feature".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
assert!(json.contains("abc123def456"));
|
||||
assert!(json.contains("Hikari"));
|
||||
assert!(json.contains("feat: add new feature"));
|
||||
}
|
||||
|
||||
// ==================== git_status integration tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_git_status_not_a_git_repo() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
let result = git_status(working_dir);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let status = result.unwrap();
|
||||
assert!(!status.is_repo);
|
||||
assert!(status.branch.is_none());
|
||||
assert!(status.staged.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_status_empty_repo() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
let result = git_status(working_dir);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let status = result.unwrap();
|
||||
assert!(status.is_repo);
|
||||
assert!(status.staged.is_empty());
|
||||
assert!(status.unstaged.is_empty());
|
||||
assert!(status.untracked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_status_with_untracked_file() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
// Create an untracked file
|
||||
create_file(&temp_dir, "untracked.txt", "hello");
|
||||
|
||||
let result = git_status(working_dir);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let status = result.unwrap();
|
||||
assert!(status.is_repo);
|
||||
assert!(status.untracked.contains(&"untracked.txt".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_status_with_staged_file() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
// Create and stage a file
|
||||
create_file(&temp_dir, "staged.txt", "hello");
|
||||
run_git_command(&working_dir, &["add", "staged.txt"]).unwrap();
|
||||
|
||||
let result = git_status(working_dir);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let status = result.unwrap();
|
||||
assert!(status.is_repo);
|
||||
assert!(!status.staged.is_empty());
|
||||
assert_eq!(status.staged[0].path, "staged.txt");
|
||||
assert_eq!(status.staged[0].status, "added");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_status_with_modified_file() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
// Create, stage, and commit a file
|
||||
create_file(&temp_dir, "file.txt", "initial content");
|
||||
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||
run_git_command(&working_dir, &["commit", "-m", "initial commit"]).unwrap();
|
||||
|
||||
// Modify the file
|
||||
create_file(&temp_dir, "file.txt", "modified content");
|
||||
|
||||
let result = git_status(working_dir);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let status = result.unwrap();
|
||||
assert!(status.is_repo);
|
||||
assert!(!status.unstaged.is_empty());
|
||||
assert_eq!(status.unstaged[0].path, "file.txt");
|
||||
assert_eq!(status.unstaged[0].status, "modified");
|
||||
}
|
||||
|
||||
// ==================== git_diff integration tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_git_diff_no_changes() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
let result = git_diff(working_dir, None, false);
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_diff_with_changes() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
// Create and commit a file
|
||||
create_file(&temp_dir, "file.txt", "initial content");
|
||||
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
||||
|
||||
// Modify the file
|
||||
create_file(&temp_dir, "file.txt", "modified content");
|
||||
|
||||
let result = git_diff(working_dir, None, false);
|
||||
assert!(result.is_ok());
|
||||
let diff = result.unwrap();
|
||||
assert!(diff.contains("diff"));
|
||||
assert!(diff.contains("file.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_diff_staged() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
// Create and commit a file
|
||||
create_file(&temp_dir, "file.txt", "initial content");
|
||||
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
||||
|
||||
// Modify and stage the file
|
||||
create_file(&temp_dir, "file.txt", "modified content");
|
||||
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||
|
||||
let result = git_diff(working_dir, None, true);
|
||||
assert!(result.is_ok());
|
||||
let diff = result.unwrap();
|
||||
assert!(diff.contains("diff"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_diff_specific_file() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
// Create and commit files
|
||||
create_file(&temp_dir, "file1.txt", "content1");
|
||||
create_file(&temp_dir, "file2.txt", "content2");
|
||||
run_git_command(&working_dir, &["add", "-A"]).unwrap();
|
||||
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
||||
|
||||
// Modify both files
|
||||
create_file(&temp_dir, "file1.txt", "modified1");
|
||||
create_file(&temp_dir, "file2.txt", "modified2");
|
||||
|
||||
// Get diff for only file1.txt
|
||||
let result = git_diff(working_dir, Some("file1.txt".to_string()), false);
|
||||
assert!(result.is_ok());
|
||||
let diff = result.unwrap();
|
||||
assert!(diff.contains("file1.txt"));
|
||||
assert!(!diff.contains("file2.txt"));
|
||||
}
|
||||
|
||||
// ==================== git_branches integration tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_git_branches_single_branch() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
// Need at least one commit for branches to show
|
||||
create_file(&temp_dir, "file.txt", "content");
|
||||
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
||||
|
||||
let result = git_branches(working_dir);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let branches = result.unwrap();
|
||||
assert!(!branches.is_empty());
|
||||
// Should have at least one branch (main or master)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_branches_multiple_branches() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
// Initial commit
|
||||
create_file(&temp_dir, "file.txt", "content");
|
||||
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
||||
|
||||
// Create additional branch
|
||||
run_git_command(&working_dir, &["branch", "feature-branch"]).unwrap();
|
||||
|
||||
let result = git_branches(working_dir);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let branches = result.unwrap();
|
||||
assert!(branches.len() >= 2);
|
||||
assert!(branches.iter().any(|b| b.name == "feature-branch"));
|
||||
}
|
||||
|
||||
// ==================== git_stage and git_unstage tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_git_stage_file() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
create_file(&temp_dir, "file.txt", "content");
|
||||
|
||||
let result = git_stage(working_dir.clone(), "file.txt".to_string());
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify file is staged
|
||||
let status = git_status(working_dir).unwrap();
|
||||
assert!(status.staged.iter().any(|f| f.path == "file.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_unstage_file() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
// First, commit a file so we have a HEAD to restore from
|
||||
create_file(&temp_dir, "file.txt", "initial content");
|
||||
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
||||
|
||||
// Modify and stage the file
|
||||
create_file(&temp_dir, "file.txt", "modified content");
|
||||
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||
|
||||
let result = git_unstage(working_dir.clone(), "file.txt".to_string());
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify file is unstaged (should now be in unstaged/modified, not staged)
|
||||
let status = git_status(working_dir).unwrap();
|
||||
assert!(!status.staged.iter().any(|f| f.path == "file.txt"));
|
||||
assert!(status.unstaged.iter().any(|f| f.path == "file.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_stage_all() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
create_file(&temp_dir, "file1.txt", "content1");
|
||||
create_file(&temp_dir, "file2.txt", "content2");
|
||||
|
||||
let result = git_stage_all(working_dir.clone());
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify all files are staged
|
||||
let status = git_status(working_dir).unwrap();
|
||||
assert_eq!(status.staged.len(), 2);
|
||||
}
|
||||
|
||||
// ==================== git_commit tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_git_commit() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
create_file(&temp_dir, "file.txt", "content");
|
||||
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||
|
||||
let result = git_commit(working_dir.clone(), "test commit message".to_string());
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify commit was made
|
||||
let log = git_log(working_dir, Some(1)).unwrap();
|
||||
assert!(!log.is_empty());
|
||||
assert!(log[0].message.contains("test commit message"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_commit_nothing_to_commit() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
// Need initial commit first
|
||||
create_file(&temp_dir, "file.txt", "content");
|
||||
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
||||
|
||||
// Try to commit with nothing staged
|
||||
let result = git_commit(working_dir, "empty commit".to_string());
|
||||
assert!(result.is_err()); // Should fail because nothing to commit
|
||||
}
|
||||
|
||||
// ==================== git_log tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_git_log_empty_repo() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
let result = git_log(working_dir, Some(10));
|
||||
// May fail on empty repo or return empty
|
||||
if let Ok(commits) = result {
|
||||
assert!(commits.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_log_with_commits() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
// Make multiple commits
|
||||
for i in 1..=3 {
|
||||
create_file(&temp_dir, &format!("file{}.txt", i), "content");
|
||||
run_git_command(&working_dir, &["add", "-A"]).unwrap();
|
||||
run_git_command(&working_dir, &["commit", "-m", &format!("commit {}", i)]).unwrap();
|
||||
}
|
||||
|
||||
let result = git_log(working_dir, Some(10));
|
||||
assert!(result.is_ok());
|
||||
|
||||
let log = result.unwrap();
|
||||
assert_eq!(log.len(), 3);
|
||||
assert!(log[0].message.contains("commit 3")); // Most recent first
|
||||
assert!(log[2].message.contains("commit 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_log_limit() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
// Make 5 commits
|
||||
for i in 1..=5 {
|
||||
create_file(&temp_dir, &format!("file{}.txt", i), "content");
|
||||
run_git_command(&working_dir, &["add", "-A"]).unwrap();
|
||||
run_git_command(&working_dir, &["commit", "-m", &format!("commit {}", i)]).unwrap();
|
||||
}
|
||||
|
||||
// Only get last 2
|
||||
let result = git_log(working_dir, Some(2));
|
||||
assert!(result.is_ok());
|
||||
|
||||
let log = result.unwrap();
|
||||
assert_eq!(log.len(), 2);
|
||||
}
|
||||
|
||||
// ==================== git_discard tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_git_discard_changes() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
// Create and commit a file
|
||||
create_file(&temp_dir, "file.txt", "original content");
|
||||
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
||||
|
||||
// Modify the file
|
||||
create_file(&temp_dir, "file.txt", "modified content");
|
||||
|
||||
// Discard changes
|
||||
let result = git_discard(working_dir.clone(), "file.txt".to_string());
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify file contents are restored
|
||||
let content = fs::read_to_string(temp_dir.path().join("file.txt")).unwrap();
|
||||
assert_eq!(content, "original content");
|
||||
}
|
||||
|
||||
// ==================== git_create_branch tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_git_create_branch() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
// Initial commit required
|
||||
create_file(&temp_dir, "file.txt", "content");
|
||||
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
||||
|
||||
let result = git_create_branch(working_dir.clone(), "new-branch".to_string());
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify branch exists and is current
|
||||
let branches = git_branches(working_dir).unwrap();
|
||||
assert!(branches.iter().any(|b| b.name == "new-branch" && b.is_current));
|
||||
}
|
||||
|
||||
// ==================== git_checkout tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_git_checkout() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
// Initial commit required
|
||||
create_file(&temp_dir, "file.txt", "content");
|
||||
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
||||
|
||||
// Create a branch
|
||||
run_git_command(&working_dir, &["branch", "other-branch"]).unwrap();
|
||||
|
||||
// Checkout the branch
|
||||
let result = git_checkout(working_dir.clone(), "other-branch".to_string());
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify current branch
|
||||
let branches = git_branches(working_dir).unwrap();
|
||||
let current = branches.iter().find(|b| b.is_current);
|
||||
assert!(current.is_some());
|
||||
assert_eq!(current.unwrap().name, "other-branch");
|
||||
}
|
||||
|
||||
// ==================== run_git_command tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_run_git_command_success() {
|
||||
let temp_dir = create_test_repo();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
let result = run_git_command(&working_dir, &["status"]);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_git_command_failure() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
// This should fail because it's not a git repo
|
||||
let result = run_git_command(&working_dir, &["log"]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_git_command_invalid_dir() {
|
||||
let result = run_git_command("/nonexistent/path", &["status"]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,42 @@
|
||||
mod achievements;
|
||||
mod bridge_manager;
|
||||
mod clipboard;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod git;
|
||||
mod notifications;
|
||||
mod quick_actions;
|
||||
mod sessions;
|
||||
mod snippets;
|
||||
mod stats;
|
||||
mod temp_manager;
|
||||
mod tray;
|
||||
mod types;
|
||||
mod wsl_bridge;
|
||||
mod wsl_notifications;
|
||||
mod vbs_notification;
|
||||
mod windows_toast;
|
||||
mod wsl_bridge;
|
||||
mod wsl_notifications;
|
||||
|
||||
use commands::*;
|
||||
use notifications::*;
|
||||
use bridge_manager::create_shared_bridge_manager;
|
||||
use clipboard::*;
|
||||
use commands::load_saved_achievements;
|
||||
use wsl_notifications::*;
|
||||
use commands::*;
|
||||
use git::*;
|
||||
use notifications::*;
|
||||
use quick_actions::*;
|
||||
use sessions::*;
|
||||
use snippets::*;
|
||||
use tauri::Manager;
|
||||
use temp_manager::create_shared_temp_manager;
|
||||
use tray::{setup_tray, should_minimize_to_tray};
|
||||
use vbs_notification::*;
|
||||
use windows_toast::*;
|
||||
use wsl_notifications::*;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let bridge_manager = create_shared_bridge_manager();
|
||||
let temp_manager = create_shared_temp_manager().expect("Failed to create temp file manager");
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
@@ -29,10 +45,43 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.manage(bridge_manager.clone())
|
||||
.manage(temp_manager.clone())
|
||||
.setup(move |app| {
|
||||
// Initialize the app handle in the bridge manager
|
||||
bridge_manager.lock().set_app_handle(app.handle().clone());
|
||||
|
||||
// Clean up any orphaned temp files from previous sessions
|
||||
if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() {
|
||||
if count > 0 {
|
||||
println!("Cleaned up {} orphaned temp files", count);
|
||||
}
|
||||
}
|
||||
|
||||
// Set up system tray
|
||||
if let Err(e) = setup_tray(app.handle()) {
|
||||
eprintln!("Failed to set up system tray: {}", e);
|
||||
}
|
||||
|
||||
// Handle window close event for minimize to tray
|
||||
let main_window = app.get_webview_window("main").unwrap();
|
||||
main_window.on_window_event({
|
||||
let app_handle = app.handle().clone();
|
||||
move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
if should_minimize_to_tray(&app_handle) {
|
||||
api.prevent_close();
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -46,13 +95,62 @@ pub fn run() {
|
||||
get_config,
|
||||
save_config,
|
||||
get_usage_stats,
|
||||
get_persisted_stats,
|
||||
load_saved_achievements,
|
||||
answer_question,
|
||||
send_windows_notification,
|
||||
send_simple_notification,
|
||||
send_windows_toast,
|
||||
send_notify_send,
|
||||
send_wsl_notification,
|
||||
send_vbs_notification,
|
||||
validate_directory,
|
||||
list_skills,
|
||||
check_for_updates,
|
||||
save_temp_file,
|
||||
register_temp_file,
|
||||
get_temp_files,
|
||||
cleanup_temp_files,
|
||||
cleanup_all_temp_files,
|
||||
cleanup_orphaned_temp_files,
|
||||
get_file_size,
|
||||
list_sessions,
|
||||
save_session,
|
||||
load_session,
|
||||
delete_session,
|
||||
search_sessions,
|
||||
clear_all_sessions,
|
||||
list_snippets,
|
||||
save_snippet,
|
||||
delete_snippet,
|
||||
get_snippet_categories,
|
||||
reset_default_snippets,
|
||||
list_quick_actions,
|
||||
save_quick_action,
|
||||
delete_quick_action,
|
||||
reset_default_quick_actions,
|
||||
git_status,
|
||||
git_diff,
|
||||
git_branches,
|
||||
git_checkout,
|
||||
git_stage,
|
||||
git_unstage,
|
||||
git_stage_all,
|
||||
git_commit,
|
||||
git_push,
|
||||
git_pull,
|
||||
git_fetch,
|
||||
git_log,
|
||||
git_discard,
|
||||
git_create_branch,
|
||||
list_clipboard_entries,
|
||||
capture_clipboard,
|
||||
delete_clipboard_entry,
|
||||
toggle_pin_clipboard_entry,
|
||||
clear_clipboard_history,
|
||||
search_clipboard_entries,
|
||||
get_clipboard_languages,
|
||||
update_clipboard_language,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use tauri::command;
|
||||
use std::process::Command;
|
||||
use tauri::command;
|
||||
|
||||
#[command]
|
||||
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
|
||||
@@ -10,7 +10,12 @@ pub async fn send_notify_send(title: String, body: String) -> Result<(), String>
|
||||
.arg("--urgency=normal")
|
||||
.arg("--app-name=Hikari Desktop")
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute notify-send: {}. Make sure libnotify-bin is installed.", e))?;
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to execute notify-send: {}. Make sure libnotify-bin is installed.",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
@@ -93,4 +98,4 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(),
|
||||
.map_err(|e| format!("Failed to send message: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
const QUICK_ACTIONS_STORE_KEY: &str = "quick_actions";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QuickAction {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub prompt: String,
|
||||
pub icon: String,
|
||||
pub is_default: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
fn get_default_quick_actions() -> Vec<QuickAction> {
|
||||
let now = Utc::now();
|
||||
vec![
|
||||
QuickAction {
|
||||
id: "default-review-pr".to_string(),
|
||||
name: "Review PR".to_string(),
|
||||
prompt: "Please review this pull request and provide feedback on code quality, potential issues, and suggestions for improvement.".to_string(),
|
||||
icon: "git-pull-request".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
QuickAction {
|
||||
id: "default-run-tests".to_string(),
|
||||
name: "Run Tests".to_string(),
|
||||
prompt: "Please run the test suite for this project and report any failures or issues.".to_string(),
|
||||
icon: "play".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
QuickAction {
|
||||
id: "default-explain-file".to_string(),
|
||||
name: "Explain File".to_string(),
|
||||
prompt: "Please explain what this file does, its purpose, and how it fits into the overall project structure.".to_string(),
|
||||
icon: "file-text".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
QuickAction {
|
||||
id: "default-fix-error".to_string(),
|
||||
name: "Fix Error".to_string(),
|
||||
prompt: "I'm getting an error. Can you help me identify the cause and fix it?".to_string(),
|
||||
icon: "alert-circle".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
QuickAction {
|
||||
id: "default-write-tests".to_string(),
|
||||
name: "Write Tests".to_string(),
|
||||
prompt: "Please write comprehensive unit tests for the current code with good coverage.".to_string(),
|
||||
icon: "check-square".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
QuickAction {
|
||||
id: "default-refactor".to_string(),
|
||||
name: "Refactor".to_string(),
|
||||
prompt: "Please refactor this code to improve readability, maintainability, and performance.".to_string(),
|
||||
icon: "refresh-cw".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn load_all_quick_actions(app: &AppHandle) -> Result<Vec<QuickAction>, String> {
|
||||
let store = app
|
||||
.store("hikari-quick-actions.json")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match store.get(QUICK_ACTIONS_STORE_KEY) {
|
||||
Some(value) => {
|
||||
let mut actions: Vec<QuickAction> =
|
||||
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
|
||||
|
||||
let defaults = get_default_quick_actions();
|
||||
for default in defaults {
|
||||
if !actions.iter().any(|a| a.id == default.id) {
|
||||
actions.push(default);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(actions)
|
||||
}
|
||||
None => Ok(get_default_quick_actions()),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_all_quick_actions(app: &AppHandle, actions: &[QuickAction]) -> Result<(), String> {
|
||||
let store = app
|
||||
.store("hikari-quick-actions.json")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let value = serde_json::to_value(actions).map_err(|e| e.to_string())?;
|
||||
store.set(QUICK_ACTIONS_STORE_KEY, value);
|
||||
store.save().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_quick_actions(app: AppHandle) -> Result<Vec<QuickAction>, String> {
|
||||
let mut actions = load_all_quick_actions(&app)?;
|
||||
|
||||
actions.sort_by(|a, b| {
|
||||
let default_cmp = b.is_default.cmp(&a.is_default);
|
||||
if default_cmp == std::cmp::Ordering::Equal {
|
||||
a.name.cmp(&b.name)
|
||||
} else {
|
||||
default_cmp
|
||||
}
|
||||
});
|
||||
|
||||
Ok(actions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_quick_action(app: AppHandle, action: QuickAction) -> Result<(), String> {
|
||||
let mut actions = load_all_quick_actions(&app)?;
|
||||
|
||||
if let Some(existing) = actions.iter_mut().find(|a| a.id == action.id) {
|
||||
let mut updated = action;
|
||||
updated.is_default = existing.is_default;
|
||||
*existing = updated;
|
||||
} else {
|
||||
actions.push(action);
|
||||
}
|
||||
|
||||
save_all_quick_actions(&app, &actions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_quick_action(app: AppHandle, action_id: String) -> Result<(), String> {
|
||||
let mut actions = load_all_quick_actions(&app)?;
|
||||
|
||||
if actions
|
||||
.iter()
|
||||
.any(|a| a.id == action_id && a.is_default)
|
||||
{
|
||||
return Err("Cannot delete default quick actions".to_string());
|
||||
}
|
||||
|
||||
actions.retain(|a| a.id != action_id);
|
||||
save_all_quick_actions(&app, &actions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reset_default_quick_actions(app: AppHandle) -> Result<(), String> {
|
||||
let mut actions = load_all_quick_actions(&app)?;
|
||||
|
||||
actions.retain(|a| !a.is_default);
|
||||
actions.extend(get_default_quick_actions());
|
||||
|
||||
save_all_quick_actions(&app, &actions)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_action(id: &str, name: &str, is_default: bool) -> QuickAction {
|
||||
QuickAction {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
prompt: "Test prompt".to_string(),
|
||||
icon: "star".to_string(),
|
||||
is_default,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_quick_actions_exist() {
|
||||
let defaults = get_default_quick_actions();
|
||||
assert!(!defaults.is_empty());
|
||||
assert!(defaults.iter().all(|a| a.is_default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_quick_actions_have_required_fields() {
|
||||
let defaults = get_default_quick_actions();
|
||||
for action in defaults {
|
||||
assert!(!action.id.is_empty());
|
||||
assert!(!action.name.is_empty());
|
||||
assert!(!action.prompt.is_empty());
|
||||
assert!(!action.icon.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_quick_actions_count() {
|
||||
let defaults = get_default_quick_actions();
|
||||
// Should have 6 default actions
|
||||
assert_eq!(defaults.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_quick_actions_have_unique_ids() {
|
||||
let defaults = get_default_quick_actions();
|
||||
let mut ids: Vec<&String> = defaults.iter().map(|a| &a.id).collect();
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), defaults.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_quick_actions_ids_start_with_default() {
|
||||
let defaults = get_default_quick_actions();
|
||||
assert!(defaults.iter().all(|a| a.id.starts_with("default-")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quick_action_serialization() {
|
||||
let action = create_test_action("test-1", "Test Action", false);
|
||||
let json = serde_json::to_string(&action).expect("Failed to serialize");
|
||||
let parsed: QuickAction = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
|
||||
assert_eq!(parsed.id, action.id);
|
||||
assert_eq!(parsed.name, action.name);
|
||||
assert_eq!(parsed.prompt, action.prompt);
|
||||
assert_eq!(parsed.icon, action.icon);
|
||||
assert_eq!(parsed.is_default, action.is_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quick_action_clone() {
|
||||
let original = create_test_action("clone-test", "Clone Test", true);
|
||||
let cloned = original.clone();
|
||||
|
||||
assert_eq!(original.id, cloned.id);
|
||||
assert_eq!(original.name, cloned.name);
|
||||
assert_eq!(original.is_default, cloned.is_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::useless_vec)]
|
||||
fn test_quick_action_sorting_defaults_first() {
|
||||
let mut actions = vec![
|
||||
create_test_action("custom-z", "Zebra", false),
|
||||
create_test_action("default-a", "Apple", true),
|
||||
create_test_action("custom-a", "Alpha", false),
|
||||
create_test_action("default-z", "Zulu", true),
|
||||
];
|
||||
|
||||
// Sort by: defaults first, then alphabetically by name
|
||||
actions.sort_by(|a, b| {
|
||||
let default_cmp = b.is_default.cmp(&a.is_default);
|
||||
if default_cmp == std::cmp::Ordering::Equal {
|
||||
a.name.cmp(&b.name)
|
||||
} else {
|
||||
default_cmp
|
||||
}
|
||||
});
|
||||
|
||||
// Defaults should come first
|
||||
assert!(actions[0].is_default);
|
||||
assert!(actions[1].is_default);
|
||||
assert!(!actions[2].is_default);
|
||||
assert!(!actions[3].is_default);
|
||||
|
||||
// Within defaults, alphabetically sorted
|
||||
assert_eq!(actions[0].name, "Apple");
|
||||
assert_eq!(actions[1].name, "Zulu");
|
||||
|
||||
// Within non-defaults, alphabetically sorted
|
||||
assert_eq!(actions[2].name, "Alpha");
|
||||
assert_eq!(actions[3].name, "Zebra");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_known_default_actions() {
|
||||
let defaults = get_default_quick_actions();
|
||||
let ids: Vec<&str> = defaults.iter().map(|a| a.id.as_str()).collect();
|
||||
|
||||
assert!(ids.contains(&"default-review-pr"));
|
||||
assert!(ids.contains(&"default-run-tests"));
|
||||
assert!(ids.contains(&"default-explain-file"));
|
||||
assert!(ids.contains(&"default-fix-error"));
|
||||
assert!(ids.contains(&"default-write-tests"));
|
||||
assert!(ids.contains(&"default-refactor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_action_icons() {
|
||||
let defaults = get_default_quick_actions();
|
||||
let icons: Vec<&str> = defaults.iter().map(|a| a.icon.as_str()).collect();
|
||||
|
||||
assert!(icons.contains(&"git-pull-request"));
|
||||
assert!(icons.contains(&"play"));
|
||||
assert!(icons.contains(&"file-text"));
|
||||
assert!(icons.contains(&"alert-circle"));
|
||||
assert!(icons.contains(&"check-square"));
|
||||
assert!(icons.contains(&"refresh-cw"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quick_action_prompts_not_empty() {
|
||||
let defaults = get_default_quick_actions();
|
||||
for action in defaults {
|
||||
assert!(
|
||||
action.prompt.len() > 10,
|
||||
"Prompt should be meaningful: {}",
|
||||
action.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quick_action_timestamps() {
|
||||
let action = create_test_action("time-test", "Time Test", false);
|
||||
assert!(action.created_at <= action.updated_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_actions_have_same_timestamps() {
|
||||
let defaults = get_default_quick_actions();
|
||||
// All defaults are created at the same instant
|
||||
let first_created = defaults[0].created_at;
|
||||
let first_updated = defaults[0].updated_at;
|
||||
|
||||
for action in &defaults {
|
||||
assert_eq!(action.created_at, first_created);
|
||||
assert_eq!(action.updated_at, first_updated);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_retain_non_default() {
|
||||
let mut actions = vec![
|
||||
create_test_action("default-1", "Default 1", true),
|
||||
create_test_action("custom-1", "Custom 1", false),
|
||||
create_test_action("default-2", "Default 2", true),
|
||||
create_test_action("custom-2", "Custom 2", false),
|
||||
];
|
||||
|
||||
// Mimics reset_default_quick_actions behavior (retain non-defaults)
|
||||
actions.retain(|a| !a.is_default);
|
||||
|
||||
assert_eq!(actions.len(), 2);
|
||||
assert!(actions.iter().all(|a| !a.is_default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::useless_vec)]
|
||||
fn test_action_find_by_id() {
|
||||
let actions = vec![
|
||||
create_test_action("action-1", "First", false),
|
||||
create_test_action("action-2", "Second", false),
|
||||
create_test_action("action-3", "Third", false),
|
||||
];
|
||||
|
||||
let found = actions.iter().find(|a| a.id == "action-2");
|
||||
assert!(found.is_some());
|
||||
assert_eq!(found.unwrap().name, "Second");
|
||||
|
||||
let not_found = actions.iter().find(|a| a.id == "action-999");
|
||||
assert!(not_found.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
const SESSIONS_STORE_KEY: &str = "sessions";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SavedSession {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_activity_at: DateTime<Utc>,
|
||||
pub working_directory: String,
|
||||
pub message_count: usize,
|
||||
pub preview: String, // First ~100 chars of conversation for preview
|
||||
pub messages: Vec<SavedMessage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SavedMessage {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub message_type: String,
|
||||
pub content: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub tool_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionListItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_activity_at: DateTime<Utc>,
|
||||
pub working_directory: String,
|
||||
pub message_count: usize,
|
||||
pub preview: String,
|
||||
}
|
||||
|
||||
impl From<&SavedSession> for SessionListItem {
|
||||
fn from(session: &SavedSession) -> Self {
|
||||
SessionListItem {
|
||||
id: session.id.clone(),
|
||||
name: session.name.clone(),
|
||||
created_at: session.created_at,
|
||||
last_activity_at: session.last_activity_at,
|
||||
working_directory: session.working_directory.clone(),
|
||||
message_count: session.message_count,
|
||||
preview: session.preview.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_all_sessions(app: &AppHandle) -> Result<Vec<SavedSession>, String> {
|
||||
let store = app
|
||||
.store("hikari-sessions.json")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match store.get(SESSIONS_STORE_KEY) {
|
||||
Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()),
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_all_sessions(app: &AppHandle, sessions: &[SavedSession]) -> Result<(), String> {
|
||||
let store = app
|
||||
.store("hikari-sessions.json")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let value = serde_json::to_value(sessions).map_err(|e| e.to_string())?;
|
||||
store.set(SESSIONS_STORE_KEY, value);
|
||||
store.save().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_sessions(app: AppHandle) -> Result<Vec<SessionListItem>, String> {
|
||||
let sessions = load_all_sessions(&app)?;
|
||||
let mut items: Vec<SessionListItem> = sessions.iter().map(SessionListItem::from).collect();
|
||||
|
||||
// Sort by last activity, most recent first
|
||||
items.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at));
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_session(app: AppHandle, session: SavedSession) -> Result<(), String> {
|
||||
let mut sessions = load_all_sessions(&app)?;
|
||||
|
||||
// Update existing or add new
|
||||
if let Some(existing) = sessions.iter_mut().find(|s| s.id == session.id) {
|
||||
*existing = session;
|
||||
} else {
|
||||
sessions.push(session);
|
||||
}
|
||||
|
||||
save_all_sessions(&app, &sessions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_session(app: AppHandle, session_id: String) -> Result<Option<SavedSession>, String> {
|
||||
let sessions = load_all_sessions(&app)?;
|
||||
Ok(sessions.into_iter().find(|s| s.id == session_id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_session(app: AppHandle, session_id: String) -> Result<(), String> {
|
||||
let mut sessions = load_all_sessions(&app)?;
|
||||
sessions.retain(|s| s.id != session_id);
|
||||
save_all_sessions(&app, &sessions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn search_sessions(app: AppHandle, query: String) -> Result<Vec<SessionListItem>, String> {
|
||||
let sessions = load_all_sessions(&app)?;
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
let mut matching: Vec<SessionListItem> = sessions
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
s.name.to_lowercase().contains(&query_lower)
|
||||
|| s.preview.to_lowercase().contains(&query_lower)
|
||||
|| s.working_directory.to_lowercase().contains(&query_lower)
|
||||
|| s.messages
|
||||
.iter()
|
||||
.any(|m| m.content.to_lowercase().contains(&query_lower))
|
||||
})
|
||||
.map(SessionListItem::from)
|
||||
.collect();
|
||||
|
||||
// Sort by last activity, most recent first
|
||||
matching.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at));
|
||||
|
||||
Ok(matching)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_all_sessions(app: AppHandle) -> Result<(), String> {
|
||||
save_all_sessions(&app, &[])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
fn create_test_session(id: &str, name: &str) -> SavedSession {
|
||||
SavedSession {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
created_at: Utc::now(),
|
||||
last_activity_at: Utc::now(),
|
||||
working_directory: "/home/test".to_string(),
|
||||
message_count: 5,
|
||||
preview: "Hello world".to_string(),
|
||||
messages: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_message(id: &str, content: &str, msg_type: &str) -> SavedMessage {
|
||||
SavedMessage {
|
||||
id: id.to_string(),
|
||||
message_type: msg_type.to_string(),
|
||||
content: content.to_string(),
|
||||
timestamp: Utc::now(),
|
||||
tool_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_list_item_from_saved_session() {
|
||||
let session = SavedSession {
|
||||
id: "test-id".to_string(),
|
||||
name: "Test Session".to_string(),
|
||||
created_at: Utc::now(),
|
||||
last_activity_at: Utc::now(),
|
||||
working_directory: "/home/test".to_string(),
|
||||
message_count: 5,
|
||||
preview: "Hello world".to_string(),
|
||||
messages: vec![],
|
||||
};
|
||||
|
||||
let item = SessionListItem::from(&session);
|
||||
assert_eq!(item.id, "test-id");
|
||||
assert_eq!(item.name, "Test Session");
|
||||
assert_eq!(item.message_count, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_list_item_preserves_all_fields() {
|
||||
let created = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap();
|
||||
let last_activity = Utc.with_ymd_and_hms(2024, 1, 15, 14, 45, 0).unwrap();
|
||||
|
||||
let session = SavedSession {
|
||||
id: "sess-123".to_string(),
|
||||
name: "My Chat".to_string(),
|
||||
created_at: created,
|
||||
last_activity_at: last_activity,
|
||||
working_directory: "/home/naomi/project".to_string(),
|
||||
message_count: 42,
|
||||
preview: "What is the meaning of life?".to_string(),
|
||||
messages: vec![],
|
||||
};
|
||||
|
||||
let item = SessionListItem::from(&session);
|
||||
|
||||
assert_eq!(item.id, "sess-123");
|
||||
assert_eq!(item.name, "My Chat");
|
||||
assert_eq!(item.created_at, created);
|
||||
assert_eq!(item.last_activity_at, last_activity);
|
||||
assert_eq!(item.working_directory, "/home/naomi/project");
|
||||
assert_eq!(item.message_count, 42);
|
||||
assert_eq!(item.preview, "What is the meaning of life?");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_saved_session_serialization() {
|
||||
let session = create_test_session("test-1", "Test Session");
|
||||
let json = serde_json::to_string(&session).expect("Failed to serialize");
|
||||
let parsed: SavedSession = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
|
||||
assert_eq!(parsed.id, session.id);
|
||||
assert_eq!(parsed.name, session.name);
|
||||
assert_eq!(parsed.working_directory, session.working_directory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_saved_message_serialization() {
|
||||
let message = create_test_message("msg-1", "Hello!", "user");
|
||||
let json = serde_json::to_string(&message).expect("Failed to serialize");
|
||||
let parsed: SavedMessage = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
|
||||
assert_eq!(parsed.id, message.id);
|
||||
assert_eq!(parsed.content, message.content);
|
||||
assert_eq!(parsed.message_type, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_saved_message_with_tool_name() {
|
||||
let message = SavedMessage {
|
||||
id: "msg-tool-1".to_string(),
|
||||
message_type: "tool".to_string(),
|
||||
content: "File read successfully".to_string(),
|
||||
timestamp: Utc::now(),
|
||||
tool_name: Some("Read".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&message).expect("Failed to serialize");
|
||||
let parsed: SavedMessage = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
|
||||
assert_eq!(parsed.tool_name, Some("Read".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_with_messages_serialization() {
|
||||
let mut session = create_test_session("sess-full", "Full Session");
|
||||
session.messages = vec![
|
||||
create_test_message("msg-1", "Hello!", "user"),
|
||||
create_test_message("msg-2", "Hi there!", "assistant"),
|
||||
create_test_message("msg-3", "Read file", "tool"),
|
||||
];
|
||||
session.message_count = 3;
|
||||
|
||||
let json = serde_json::to_string(&session).expect("Failed to serialize");
|
||||
let parsed: SavedSession = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
|
||||
assert_eq!(parsed.messages.len(), 3);
|
||||
assert_eq!(parsed.messages[0].content, "Hello!");
|
||||
assert_eq!(parsed.messages[1].message_type, "assistant");
|
||||
assert_eq!(parsed.messages[2].message_type, "tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_list_item_serialization() {
|
||||
let item = SessionListItem {
|
||||
id: "list-item-1".to_string(),
|
||||
name: "Quick Chat".to_string(),
|
||||
created_at: Utc::now(),
|
||||
last_activity_at: Utc::now(),
|
||||
working_directory: "/tmp".to_string(),
|
||||
message_count: 10,
|
||||
preview: "Short preview...".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&item).expect("Failed to serialize");
|
||||
let parsed: SessionListItem = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
|
||||
assert_eq!(parsed.id, item.id);
|
||||
assert_eq!(parsed.name, item.name);
|
||||
assert_eq!(parsed.preview, item.preview);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_type_field_rename() {
|
||||
// The message_type field is renamed to "type" in JSON
|
||||
let message = create_test_message("msg-1", "Test", "assistant");
|
||||
let json = serde_json::to_string(&message).expect("Failed to serialize");
|
||||
|
||||
assert!(json.contains("\"type\":"));
|
||||
assert!(!json.contains("\"message_type\":"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_default_empty_messages() {
|
||||
let session = SavedSession {
|
||||
id: "empty".to_string(),
|
||||
name: "Empty".to_string(),
|
||||
created_at: Utc::now(),
|
||||
last_activity_at: Utc::now(),
|
||||
working_directory: "/".to_string(),
|
||||
message_count: 0,
|
||||
preview: "".to_string(),
|
||||
messages: vec![],
|
||||
};
|
||||
|
||||
assert!(session.messages.is_empty());
|
||||
assert_eq!(session.message_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::useless_vec)]
|
||||
fn test_session_sorting_by_activity() {
|
||||
let old_time = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
||||
let new_time = Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
|
||||
|
||||
let mut sessions = vec![
|
||||
SessionListItem {
|
||||
id: "old".to_string(),
|
||||
name: "Old Session".to_string(),
|
||||
created_at: old_time,
|
||||
last_activity_at: old_time,
|
||||
working_directory: "/old".to_string(),
|
||||
message_count: 1,
|
||||
preview: "Old".to_string(),
|
||||
},
|
||||
SessionListItem {
|
||||
id: "new".to_string(),
|
||||
name: "New Session".to_string(),
|
||||
created_at: new_time,
|
||||
last_activity_at: new_time,
|
||||
working_directory: "/new".to_string(),
|
||||
message_count: 1,
|
||||
preview: "New".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
// Sort by last activity, most recent first (mimics list_sessions behavior)
|
||||
sessions.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at));
|
||||
|
||||
assert_eq!(sessions[0].id, "new");
|
||||
assert_eq!(sessions[1].id, "old");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_clone() {
|
||||
let original = create_test_session("clone-test", "Clone Test");
|
||||
let cloned = original.clone();
|
||||
|
||||
assert_eq!(original.id, cloned.id);
|
||||
assert_eq!(original.name, cloned.name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_clone() {
|
||||
let original = create_test_message("msg-clone", "Content", "user");
|
||||
let cloned = original.clone();
|
||||
|
||||
assert_eq!(original.id, cloned.id);
|
||||
assert_eq!(original.content, cloned.content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
const SNIPPETS_STORE_KEY: &str = "snippets";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Snippet {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub content: String,
|
||||
pub category: String,
|
||||
pub is_default: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
fn get_default_snippets() -> Vec<Snippet> {
|
||||
let now = Utc::now();
|
||||
vec![
|
||||
Snippet {
|
||||
id: "default-explain-code".to_string(),
|
||||
name: "Explain this code".to_string(),
|
||||
content: "Please explain what this code does, step by step:".to_string(),
|
||||
category: "Code Review".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
Snippet {
|
||||
id: "default-fix-error".to_string(),
|
||||
name: "Fix this error".to_string(),
|
||||
content: "I'm getting the following error. Can you help me fix it?".to_string(),
|
||||
category: "Debugging".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
Snippet {
|
||||
id: "default-write-tests".to_string(),
|
||||
name: "Write tests".to_string(),
|
||||
content: "Please write unit tests for this code with good coverage:".to_string(),
|
||||
category: "Testing".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
Snippet {
|
||||
id: "default-refactor".to_string(),
|
||||
name: "Refactor for clarity".to_string(),
|
||||
content: "Please refactor this code to improve readability and maintainability:".to_string(),
|
||||
category: "Code Review".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
Snippet {
|
||||
id: "default-optimize".to_string(),
|
||||
name: "Optimize performance".to_string(),
|
||||
content: "Please analyze this code for performance issues and suggest optimizations:".to_string(),
|
||||
category: "Performance".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
Snippet {
|
||||
id: "default-review-pr".to_string(),
|
||||
name: "Review PR".to_string(),
|
||||
content: "Please review this pull request and provide feedback on code quality, potential issues, and suggestions for improvement.".to_string(),
|
||||
category: "Code Review".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
Snippet {
|
||||
id: "default-add-comments".to_string(),
|
||||
name: "Add documentation".to_string(),
|
||||
content: "Please add clear documentation comments to this code explaining what it does:".to_string(),
|
||||
category: "Documentation".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
Snippet {
|
||||
id: "default-security-review".to_string(),
|
||||
name: "Security review".to_string(),
|
||||
content: "Please review this code for security vulnerabilities and suggest fixes:".to_string(),
|
||||
category: "Security".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn load_all_snippets(app: &AppHandle) -> Result<Vec<Snippet>, String> {
|
||||
let store = app
|
||||
.store("hikari-snippets.json")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match store.get(SNIPPETS_STORE_KEY) {
|
||||
Some(value) => {
|
||||
let mut snippets: Vec<Snippet> =
|
||||
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
|
||||
|
||||
// Ensure default snippets exist (in case new ones were added in an update)
|
||||
let defaults = get_default_snippets();
|
||||
for default in defaults {
|
||||
if !snippets.iter().any(|s| s.id == default.id) {
|
||||
snippets.push(default);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(snippets)
|
||||
}
|
||||
None => Ok(get_default_snippets()),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_all_snippets(app: &AppHandle, snippets: &[Snippet]) -> Result<(), String> {
|
||||
let store = app
|
||||
.store("hikari-snippets.json")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let value = serde_json::to_value(snippets).map_err(|e| e.to_string())?;
|
||||
store.set(SNIPPETS_STORE_KEY, value);
|
||||
store.save().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_snippets(app: AppHandle) -> Result<Vec<Snippet>, String> {
|
||||
let mut snippets = load_all_snippets(&app)?;
|
||||
|
||||
// Sort by category, then by name
|
||||
snippets.sort_by(|a, b| {
|
||||
let cat_cmp = a.category.cmp(&b.category);
|
||||
if cat_cmp == std::cmp::Ordering::Equal {
|
||||
a.name.cmp(&b.name)
|
||||
} else {
|
||||
cat_cmp
|
||||
}
|
||||
});
|
||||
|
||||
Ok(snippets)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_snippet(app: AppHandle, snippet: Snippet) -> Result<(), String> {
|
||||
let mut snippets = load_all_snippets(&app)?;
|
||||
|
||||
// Update existing or add new
|
||||
if let Some(existing) = snippets.iter_mut().find(|s| s.id == snippet.id) {
|
||||
// Don't allow editing default snippets' is_default flag
|
||||
let mut updated = snippet;
|
||||
updated.is_default = existing.is_default;
|
||||
*existing = updated;
|
||||
} else {
|
||||
snippets.push(snippet);
|
||||
}
|
||||
|
||||
save_all_snippets(&app, &snippets)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_snippet(app: AppHandle, snippet_id: String) -> Result<(), String> {
|
||||
let mut snippets = load_all_snippets(&app)?;
|
||||
|
||||
// Don't allow deleting default snippets
|
||||
if snippets
|
||||
.iter()
|
||||
.any(|s| s.id == snippet_id && s.is_default)
|
||||
{
|
||||
return Err("Cannot delete default snippets".to_string());
|
||||
}
|
||||
|
||||
snippets.retain(|s| s.id != snippet_id);
|
||||
save_all_snippets(&app, &snippets)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_snippet_categories(app: AppHandle) -> Result<Vec<String>, String> {
|
||||
let snippets = load_all_snippets(&app)?;
|
||||
let mut categories: Vec<String> = snippets.iter().map(|s| s.category.clone()).collect();
|
||||
categories.sort();
|
||||
categories.dedup();
|
||||
Ok(categories)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reset_default_snippets(app: AppHandle) -> Result<(), String> {
|
||||
let mut snippets = load_all_snippets(&app)?;
|
||||
|
||||
// Remove all default snippets
|
||||
snippets.retain(|s| !s.is_default);
|
||||
|
||||
// Add fresh default snippets
|
||||
snippets.extend(get_default_snippets());
|
||||
|
||||
save_all_snippets(&app, &snippets)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn create_test_snippet(id: &str, name: &str, category: &str, is_default: bool) -> Snippet {
|
||||
Snippet {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
content: "Test content".to_string(),
|
||||
category: category.to_string(),
|
||||
is_default,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_snippets_exist() {
|
||||
let defaults = get_default_snippets();
|
||||
assert!(!defaults.is_empty());
|
||||
assert!(defaults.iter().all(|s| s.is_default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_snippets_have_required_fields() {
|
||||
let defaults = get_default_snippets();
|
||||
for snippet in defaults {
|
||||
assert!(!snippet.id.is_empty());
|
||||
assert!(!snippet.name.is_empty());
|
||||
assert!(!snippet.content.is_empty());
|
||||
assert!(!snippet.category.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_snippets_count() {
|
||||
let defaults = get_default_snippets();
|
||||
// Should have 8 default snippets
|
||||
assert_eq!(defaults.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_snippets_have_unique_ids() {
|
||||
let defaults = get_default_snippets();
|
||||
let ids: HashSet<&String> = defaults.iter().map(|s| &s.id).collect();
|
||||
assert_eq!(ids.len(), defaults.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_snippets_ids_start_with_default() {
|
||||
let defaults = get_default_snippets();
|
||||
assert!(defaults.iter().all(|s| s.id.starts_with("default-")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snippet_serialization() {
|
||||
let snippet = create_test_snippet("test-1", "Test Snippet", "Testing", false);
|
||||
let json = serde_json::to_string(&snippet).expect("Failed to serialize");
|
||||
let parsed: Snippet = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
|
||||
assert_eq!(parsed.id, snippet.id);
|
||||
assert_eq!(parsed.name, snippet.name);
|
||||
assert_eq!(parsed.content, snippet.content);
|
||||
assert_eq!(parsed.category, snippet.category);
|
||||
assert_eq!(parsed.is_default, snippet.is_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snippet_clone() {
|
||||
let original = create_test_snippet("clone-test", "Clone Test", "Category", true);
|
||||
let cloned = original.clone();
|
||||
|
||||
assert_eq!(original.id, cloned.id);
|
||||
assert_eq!(original.name, cloned.name);
|
||||
assert_eq!(original.is_default, cloned.is_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::useless_vec)]
|
||||
fn test_snippet_sorting_by_category_then_name() {
|
||||
let mut snippets = vec![
|
||||
create_test_snippet("s1", "Zebra", "B-Category", false),
|
||||
create_test_snippet("s2", "Apple", "A-Category", false),
|
||||
create_test_snippet("s3", "Banana", "B-Category", false),
|
||||
create_test_snippet("s4", "Alpha", "A-Category", false),
|
||||
];
|
||||
|
||||
// Sort by category, then by name (mimics list_snippets behavior)
|
||||
snippets.sort_by(|a, b| {
|
||||
let cat_cmp = a.category.cmp(&b.category);
|
||||
if cat_cmp == std::cmp::Ordering::Equal {
|
||||
a.name.cmp(&b.name)
|
||||
} else {
|
||||
cat_cmp
|
||||
}
|
||||
});
|
||||
|
||||
// A-Category should come first
|
||||
assert_eq!(snippets[0].category, "A-Category");
|
||||
assert_eq!(snippets[1].category, "A-Category");
|
||||
assert_eq!(snippets[2].category, "B-Category");
|
||||
assert_eq!(snippets[3].category, "B-Category");
|
||||
|
||||
// Within categories, alphabetically by name
|
||||
assert_eq!(snippets[0].name, "Alpha");
|
||||
assert_eq!(snippets[1].name, "Apple");
|
||||
assert_eq!(snippets[2].name, "Banana");
|
||||
assert_eq!(snippets[3].name, "Zebra");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_known_default_snippets() {
|
||||
let defaults = get_default_snippets();
|
||||
let ids: Vec<&str> = defaults.iter().map(|s| s.id.as_str()).collect();
|
||||
|
||||
assert!(ids.contains(&"default-explain-code"));
|
||||
assert!(ids.contains(&"default-fix-error"));
|
||||
assert!(ids.contains(&"default-write-tests"));
|
||||
assert!(ids.contains(&"default-refactor"));
|
||||
assert!(ids.contains(&"default-optimize"));
|
||||
assert!(ids.contains(&"default-review-pr"));
|
||||
assert!(ids.contains(&"default-add-comments"));
|
||||
assert!(ids.contains(&"default-security-review"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_snippet_categories() {
|
||||
let defaults = get_default_snippets();
|
||||
let categories: HashSet<&String> = defaults.iter().map(|s| &s.category).collect();
|
||||
|
||||
assert!(categories.contains(&"Code Review".to_string()));
|
||||
assert!(categories.contains(&"Debugging".to_string()));
|
||||
assert!(categories.contains(&"Testing".to_string()));
|
||||
assert!(categories.contains(&"Performance".to_string()));
|
||||
assert!(categories.contains(&"Documentation".to_string()));
|
||||
assert!(categories.contains(&"Security".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snippet_content_not_empty() {
|
||||
let defaults = get_default_snippets();
|
||||
for snippet in defaults {
|
||||
assert!(
|
||||
snippet.content.len() > 10,
|
||||
"Content should be meaningful: {}",
|
||||
snippet.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snippet_timestamps() {
|
||||
let snippet = create_test_snippet("time-test", "Time Test", "Cat", false);
|
||||
assert!(snippet.created_at <= snippet.updated_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_snippets_have_same_timestamps() {
|
||||
let defaults = get_default_snippets();
|
||||
// All defaults are created at the same instant
|
||||
let first_created = defaults[0].created_at;
|
||||
let first_updated = defaults[0].updated_at;
|
||||
|
||||
for snippet in &defaults {
|
||||
assert_eq!(snippet.created_at, first_created);
|
||||
assert_eq!(snippet.updated_at, first_updated);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snippet_retain_non_default() {
|
||||
let mut snippets = vec![
|
||||
create_test_snippet("default-1", "Default 1", "Cat", true),
|
||||
create_test_snippet("custom-1", "Custom 1", "Cat", false),
|
||||
create_test_snippet("default-2", "Default 2", "Cat", true),
|
||||
create_test_snippet("custom-2", "Custom 2", "Cat", false),
|
||||
];
|
||||
|
||||
// Mimics reset_default_snippets behavior (retain non-defaults)
|
||||
snippets.retain(|s| !s.is_default);
|
||||
|
||||
assert_eq!(snippets.len(), 2);
|
||||
assert!(snippets.iter().all(|s| !s.is_default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::useless_vec)]
|
||||
fn test_snippet_find_by_id() {
|
||||
let snippets = vec![
|
||||
create_test_snippet("snippet-1", "First", "Cat", false),
|
||||
create_test_snippet("snippet-2", "Second", "Cat", false),
|
||||
create_test_snippet("snippet-3", "Third", "Cat", false),
|
||||
];
|
||||
|
||||
let found = snippets.iter().find(|s| s.id == "snippet-2");
|
||||
assert!(found.is_some());
|
||||
assert_eq!(found.unwrap().name, "Second");
|
||||
|
||||
let not_found = snippets.iter().find(|s| s.id == "snippet-999");
|
||||
assert!(not_found.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::useless_vec)]
|
||||
fn test_extract_categories_sorted_and_deduped() {
|
||||
let snippets = vec![
|
||||
create_test_snippet("s1", "S1", "Zebra", false),
|
||||
create_test_snippet("s2", "S2", "Alpha", false),
|
||||
create_test_snippet("s3", "S3", "Beta", false),
|
||||
create_test_snippet("s4", "S4", "Alpha", false), // Duplicate
|
||||
];
|
||||
|
||||
let mut categories: Vec<String> = snippets.iter().map(|s| s.category.clone()).collect();
|
||||
categories.sort();
|
||||
categories.dedup();
|
||||
|
||||
assert_eq!(categories.len(), 3);
|
||||
assert_eq!(categories[0], "Alpha");
|
||||
assert_eq!(categories[1], "Beta");
|
||||
assert_eq!(categories[2], "Zebra");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snippet_category_code_review_count() {
|
||||
let defaults = get_default_snippets();
|
||||
let code_review_count = defaults
|
||||
.iter()
|
||||
.filter(|s| s.category == "Code Review")
|
||||
.count();
|
||||
|
||||
// There should be multiple code review snippets
|
||||
assert!(code_review_count >= 2);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::achievements::{check_achievements, AchievementProgress};
|
||||
use chrono::{Local, Timelike};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
use crate::achievements::{AchievementProgress, check_achievements};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct UsageStats {
|
||||
@@ -28,6 +30,14 @@ pub struct UsageStats {
|
||||
#[serde(skip)]
|
||||
pub session_start: Option<Instant>,
|
||||
|
||||
// Extended tracking for achievements
|
||||
pub sessions_started: u64,
|
||||
pub consecutive_days: u64,
|
||||
pub total_days_used: u64,
|
||||
pub morning_sessions: u64, // Sessions started before 9 AM
|
||||
pub night_sessions: u64, // Sessions started after 10 PM
|
||||
pub last_session_date: Option<String>, // ISO date string for streak tracking
|
||||
|
||||
// Achievement tracking
|
||||
#[serde(skip)]
|
||||
pub achievements: AchievementProgress,
|
||||
@@ -65,6 +75,47 @@ impl UsageStats {
|
||||
self.session_duration_seconds = 0;
|
||||
self.session_start = Some(Instant::now());
|
||||
self.achievements.start_session();
|
||||
|
||||
// Track session start for achievements
|
||||
self.track_session_start();
|
||||
}
|
||||
|
||||
pub fn track_session_start(&mut self) {
|
||||
let now = Local::now();
|
||||
let today = now.format("%Y-%m-%d").to_string();
|
||||
let hour = now.hour();
|
||||
|
||||
// Increment session count
|
||||
self.sessions_started += 1;
|
||||
|
||||
// Track morning/night sessions
|
||||
if hour < 9 {
|
||||
self.morning_sessions += 1;
|
||||
}
|
||||
if hour >= 22 {
|
||||
self.night_sessions += 1;
|
||||
}
|
||||
|
||||
// Track consecutive days and total days
|
||||
if let Some(last_date) = &self.last_session_date {
|
||||
if last_date != &today {
|
||||
// Check if it's the next day (consecutive)
|
||||
if is_consecutive_day(last_date, &today) {
|
||||
self.consecutive_days += 1;
|
||||
} else {
|
||||
// Streak broken
|
||||
self.consecutive_days = 1;
|
||||
}
|
||||
self.total_days_used += 1;
|
||||
self.last_session_date = Some(today);
|
||||
}
|
||||
// Same day - don't increment anything
|
||||
} else {
|
||||
// First session ever
|
||||
self.consecutive_days = 1;
|
||||
self.total_days_used = 1;
|
||||
self.last_session_date = Some(today);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn increment_messages(&mut self) {
|
||||
@@ -89,7 +140,10 @@ impl UsageStats {
|
||||
|
||||
pub fn increment_tool_usage(&mut self, tool_name: &str) {
|
||||
*self.tools_usage.entry(tool_name.to_string()).or_insert(0) += 1;
|
||||
*self.session_tools_usage.entry(tool_name.to_string()).or_insert(0) += 1;
|
||||
*self
|
||||
.session_tools_usage
|
||||
.entry(tool_name.to_string())
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
|
||||
pub fn get_session_duration(&mut self) -> u64 {
|
||||
@@ -124,12 +178,34 @@ impl UsageStats {
|
||||
session_tools_usage: self.session_tools_usage.clone(),
|
||||
session_duration_seconds: self.session_duration_seconds,
|
||||
session_start: self.session_start,
|
||||
sessions_started: self.sessions_started,
|
||||
consecutive_days: self.consecutive_days,
|
||||
total_days_used: self.total_days_used,
|
||||
morning_sessions: self.morning_sessions,
|
||||
night_sessions: self.night_sessions,
|
||||
last_session_date: self.last_session_date.clone(),
|
||||
achievements: AchievementProgress::new(), // Dummy for copy
|
||||
};
|
||||
check_achievements(&stats_copy, &mut self.achievements)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if two dates are consecutive
|
||||
fn is_consecutive_day(prev_date: &str, current_date: &str) -> bool {
|
||||
use chrono::NaiveDate;
|
||||
|
||||
let prev = NaiveDate::parse_from_str(prev_date, "%Y-%m-%d").ok();
|
||||
let current = NaiveDate::parse_from_str(current_date, "%Y-%m-%d").ok();
|
||||
|
||||
match (prev, current) {
|
||||
(Some(p), Some(c)) => {
|
||||
let diff = c.signed_duration_since(p).num_days();
|
||||
diff == 1
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// Pricing as of January 2025
|
||||
// https://www.anthropic.com/pricing
|
||||
fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
|
||||
@@ -166,6 +242,111 @@ pub struct StatsUpdateEvent {
|
||||
pub stats: UsageStats,
|
||||
}
|
||||
|
||||
/// Serializable struct for persisting only lifetime (total) stats
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PersistedStats {
|
||||
pub total_input_tokens: u64,
|
||||
pub total_output_tokens: u64,
|
||||
pub total_cost_usd: f64,
|
||||
pub messages_exchanged: u64,
|
||||
pub code_blocks_generated: u64,
|
||||
pub files_edited: u64,
|
||||
pub files_created: u64,
|
||||
pub tools_usage: HashMap<String, u64>,
|
||||
pub sessions_started: u64,
|
||||
pub consecutive_days: u64,
|
||||
pub total_days_used: u64,
|
||||
pub morning_sessions: u64,
|
||||
pub night_sessions: u64,
|
||||
pub last_session_date: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&UsageStats> for PersistedStats {
|
||||
fn from(stats: &UsageStats) -> Self {
|
||||
PersistedStats {
|
||||
total_input_tokens: stats.total_input_tokens,
|
||||
total_output_tokens: stats.total_output_tokens,
|
||||
total_cost_usd: stats.total_cost_usd,
|
||||
messages_exchanged: stats.messages_exchanged,
|
||||
code_blocks_generated: stats.code_blocks_generated,
|
||||
files_edited: stats.files_edited,
|
||||
files_created: stats.files_created,
|
||||
tools_usage: stats.tools_usage.clone(),
|
||||
sessions_started: stats.sessions_started,
|
||||
consecutive_days: stats.consecutive_days,
|
||||
total_days_used: stats.total_days_used,
|
||||
morning_sessions: stats.morning_sessions,
|
||||
night_sessions: stats.night_sessions,
|
||||
last_session_date: stats.last_session_date.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UsageStats {
|
||||
/// Apply persisted stats to restore lifetime totals
|
||||
pub fn apply_persisted(&mut self, persisted: PersistedStats) {
|
||||
self.total_input_tokens = persisted.total_input_tokens;
|
||||
self.total_output_tokens = persisted.total_output_tokens;
|
||||
self.total_cost_usd = persisted.total_cost_usd;
|
||||
self.messages_exchanged = persisted.messages_exchanged;
|
||||
self.code_blocks_generated = persisted.code_blocks_generated;
|
||||
self.files_edited = persisted.files_edited;
|
||||
self.files_created = persisted.files_created;
|
||||
self.tools_usage = persisted.tools_usage;
|
||||
self.sessions_started = persisted.sessions_started;
|
||||
self.consecutive_days = persisted.consecutive_days;
|
||||
self.total_days_used = persisted.total_days_used;
|
||||
self.morning_sessions = persisted.morning_sessions;
|
||||
self.night_sessions = persisted.night_sessions;
|
||||
self.last_session_date = persisted.last_session_date;
|
||||
}
|
||||
}
|
||||
|
||||
/// Save lifetime stats to persistent store
|
||||
pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<(), String> {
|
||||
let store = app.store("stats.json").map_err(|e| e.to_string())?;
|
||||
|
||||
let persisted = PersistedStats::from(stats);
|
||||
|
||||
println!("Saving stats: {:?}", persisted);
|
||||
|
||||
store.set(
|
||||
"lifetime_stats",
|
||||
serde_json::to_value(persisted).map_err(|e| e.to_string())?,
|
||||
);
|
||||
store.save().map_err(|e| e.to_string())?;
|
||||
|
||||
println!("Stats saved successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load lifetime stats from persistent store
|
||||
pub async fn load_stats(app: &tauri::AppHandle) -> Option<PersistedStats> {
|
||||
println!("Loading stats from store...");
|
||||
|
||||
let store = match app.store("stats.json") {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
println!("Failed to open stats store: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(stats_value) = store.get("lifetime_stats") {
|
||||
println!("Found lifetime stats in store: {:?}", stats_value);
|
||||
if let Ok(persisted) = serde_json::from_value::<PersistedStats>(stats_value.clone()) {
|
||||
println!("Loaded lifetime stats successfully");
|
||||
return Some(persisted);
|
||||
} else {
|
||||
println!("Failed to parse lifetime stats");
|
||||
}
|
||||
} else {
|
||||
println!("No lifetime stats found in store");
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -188,6 +369,36 @@ mod tests {
|
||||
assert!((cost - 0.165).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_calculation_opus_45() {
|
||||
let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101");
|
||||
// Same pricing as Opus 4
|
||||
assert!((cost - 0.165).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_calculation_haiku() {
|
||||
let cost = calculate_cost(1000, 2000, "claude-3-5-haiku-20241022");
|
||||
// 1000 input * $1/M = $0.001
|
||||
// 2000 output * $5/M = $0.010
|
||||
// Total = $0.011
|
||||
assert!((cost - 0.011).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_calculation_unknown_defaults_to_sonnet() {
|
||||
let cost = calculate_cost(1000, 2000, "some-unknown-model");
|
||||
// Should default to Sonnet pricing
|
||||
assert!((cost - 0.033).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_calculation_legacy_sonnet() {
|
||||
let cost = calculate_cost(1000, 2000, "claude-3-5-sonnet-20241022");
|
||||
// Same as Sonnet 4 pricing
|
||||
assert!((cost - 0.033).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_usage_stats_accumulation() {
|
||||
let mut stats = UsageStats::new();
|
||||
@@ -200,6 +411,28 @@ mod tests {
|
||||
assert!((stats.total_cost_usd - 0.033).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_usage_stats_multiple_accumulations() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514");
|
||||
stats.add_usage(500, 500, "claude-sonnet-4-20250514");
|
||||
|
||||
assert_eq!(stats.total_input_tokens, 1500);
|
||||
assert_eq!(stats.total_output_tokens, 1500);
|
||||
assert_eq!(stats.session_input_tokens, 1500);
|
||||
assert_eq!(stats.session_output_tokens, 1500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_usage_stats_model_updated() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514");
|
||||
assert_eq!(stats.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||
|
||||
stats.add_usage(500, 500, "claude-opus-4-20250514");
|
||||
assert_eq!(stats.model, Some("claude-opus-4-20250514".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_reset() {
|
||||
let mut stats = UsageStats::new();
|
||||
@@ -213,4 +446,230 @@ mod tests {
|
||||
assert_eq!(stats.session_cost_usd, 0.0);
|
||||
assert!(stats.total_cost_usd > 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_reset_clears_session_stats() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.increment_messages();
|
||||
stats.increment_messages();
|
||||
stats.increment_code_blocks();
|
||||
stats.increment_files_edited();
|
||||
stats.increment_files_created();
|
||||
stats.increment_tool_usage("Read");
|
||||
|
||||
stats.reset_session();
|
||||
|
||||
assert_eq!(stats.session_messages_exchanged, 0);
|
||||
assert_eq!(stats.session_code_blocks_generated, 0);
|
||||
assert_eq!(stats.session_files_edited, 0);
|
||||
assert_eq!(stats.session_files_created, 0);
|
||||
assert!(stats.session_tools_usage.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_increment_messages() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.increment_messages();
|
||||
stats.increment_messages();
|
||||
stats.increment_messages();
|
||||
|
||||
assert_eq!(stats.messages_exchanged, 3);
|
||||
assert_eq!(stats.session_messages_exchanged, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_increment_code_blocks() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.increment_code_blocks();
|
||||
stats.increment_code_blocks();
|
||||
|
||||
assert_eq!(stats.code_blocks_generated, 2);
|
||||
assert_eq!(stats.session_code_blocks_generated, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_increment_files_edited() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.increment_files_edited();
|
||||
|
||||
assert_eq!(stats.files_edited, 1);
|
||||
assert_eq!(stats.session_files_edited, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_increment_files_created() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.increment_files_created();
|
||||
|
||||
assert_eq!(stats.files_created, 1);
|
||||
assert_eq!(stats.session_files_created, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_increment_tool_usage() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.increment_tool_usage("Read");
|
||||
stats.increment_tool_usage("Read");
|
||||
stats.increment_tool_usage("Write");
|
||||
|
||||
assert_eq!(stats.tools_usage.get("Read"), Some(&2));
|
||||
assert_eq!(stats.tools_usage.get("Write"), Some(&1));
|
||||
assert_eq!(stats.session_tools_usage.get("Read"), Some(&2));
|
||||
assert_eq!(stats.session_tools_usage.get("Write"), Some(&1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_duration_tracking() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.session_start = Some(Instant::now());
|
||||
|
||||
// Verify duration is returned (u64 is always non-negative)
|
||||
let _duration = stats.get_session_duration();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_duration_without_start() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.session_start = None;
|
||||
stats.session_duration_seconds = 100;
|
||||
|
||||
// Should return the stored value when no start time
|
||||
let duration = stats.get_session_duration();
|
||||
assert_eq!(duration, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_consecutive_day_true() {
|
||||
assert!(is_consecutive_day("2024-01-15", "2024-01-16"));
|
||||
assert!(is_consecutive_day("2024-12-31", "2025-01-01"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_consecutive_day_false() {
|
||||
assert!(!is_consecutive_day("2024-01-15", "2024-01-15")); // Same day
|
||||
assert!(!is_consecutive_day("2024-01-15", "2024-01-17")); // Gap
|
||||
assert!(!is_consecutive_day("2024-01-15", "2024-01-14")); // Backwards
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_consecutive_day_invalid_dates() {
|
||||
assert!(!is_consecutive_day("invalid", "2024-01-01"));
|
||||
assert!(!is_consecutive_day("2024-01-01", "invalid"));
|
||||
assert!(!is_consecutive_day("invalid", "also-invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_persisted_stats_from_usage_stats() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.total_input_tokens = 5000;
|
||||
stats.total_output_tokens = 10000;
|
||||
stats.total_cost_usd = 1.23;
|
||||
stats.messages_exchanged = 50;
|
||||
stats.sessions_started = 5;
|
||||
stats.consecutive_days = 3;
|
||||
|
||||
let persisted = PersistedStats::from(&stats);
|
||||
|
||||
assert_eq!(persisted.total_input_tokens, 5000);
|
||||
assert_eq!(persisted.total_output_tokens, 10000);
|
||||
assert_eq!(persisted.total_cost_usd, 1.23);
|
||||
assert_eq!(persisted.messages_exchanged, 50);
|
||||
assert_eq!(persisted.sessions_started, 5);
|
||||
assert_eq!(persisted.consecutive_days, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_persisted_stats() {
|
||||
let persisted = PersistedStats {
|
||||
total_input_tokens: 10000,
|
||||
total_output_tokens: 20000,
|
||||
total_cost_usd: 5.50,
|
||||
messages_exchanged: 100,
|
||||
code_blocks_generated: 25,
|
||||
files_edited: 10,
|
||||
files_created: 5,
|
||||
tools_usage: {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("Read".to_string(), 50);
|
||||
map
|
||||
},
|
||||
sessions_started: 10,
|
||||
consecutive_days: 7,
|
||||
total_days_used: 14,
|
||||
morning_sessions: 3,
|
||||
night_sessions: 2,
|
||||
last_session_date: Some("2024-06-15".to_string()),
|
||||
};
|
||||
|
||||
let mut stats = UsageStats::new();
|
||||
stats.apply_persisted(persisted);
|
||||
|
||||
assert_eq!(stats.total_input_tokens, 10000);
|
||||
assert_eq!(stats.total_output_tokens, 20000);
|
||||
assert_eq!(stats.total_cost_usd, 5.50);
|
||||
assert_eq!(stats.messages_exchanged, 100);
|
||||
assert_eq!(stats.tools_usage.get("Read"), Some(&50));
|
||||
assert_eq!(stats.consecutive_days, 7);
|
||||
assert_eq!(stats.morning_sessions, 3);
|
||||
assert_eq!(stats.last_session_date, Some("2024-06-15".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_usage_stats_default() {
|
||||
let stats = UsageStats::default();
|
||||
|
||||
assert_eq!(stats.total_input_tokens, 0);
|
||||
assert_eq!(stats.total_output_tokens, 0);
|
||||
assert_eq!(stats.total_cost_usd, 0.0);
|
||||
assert!(stats.model.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_persisted_stats_default() {
|
||||
let persisted = PersistedStats::default();
|
||||
|
||||
assert_eq!(persisted.total_input_tokens, 0);
|
||||
assert!(persisted.last_session_date.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_usage_stats_serialization() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
|
||||
stats.increment_messages();
|
||||
|
||||
// UsageStats should be serializable (for events)
|
||||
let json = serde_json::to_string(&stats).expect("Failed to serialize");
|
||||
assert!(json.contains("total_input_tokens"));
|
||||
assert!(json.contains("1000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_persisted_stats_serialization() {
|
||||
let persisted = PersistedStats {
|
||||
total_input_tokens: 1234,
|
||||
total_output_tokens: 5678,
|
||||
total_cost_usd: 0.99,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&persisted).expect("Failed to serialize");
|
||||
let parsed: PersistedStats = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
|
||||
assert_eq!(parsed.total_input_tokens, 1234);
|
||||
assert_eq!(parsed.total_output_tokens, 5678);
|
||||
assert!((parsed.total_cost_usd - 0.99).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stats_update_event_serialization() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(100, 200, "claude-sonnet-4-20250514");
|
||||
|
||||
let event = StatsUpdateEvent { stats };
|
||||
let json = serde_json::to_string(&event).expect("Failed to serialize");
|
||||
|
||||
assert!(json.contains("stats"));
|
||||
assert!(json.contains("total_input_tokens"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
const TEMP_DIR_NAME: &str = "hikari-uploads";
|
||||
|
||||
pub struct TempFileManager {
|
||||
base_dir: PathBuf,
|
||||
files: HashMap<String, Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
impl TempFileManager {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let base_dir = std::env::temp_dir().join(TEMP_DIR_NAME);
|
||||
|
||||
if !base_dir.exists() {
|
||||
fs::create_dir_all(&base_dir)
|
||||
.map_err(|e| format!("Failed to create temp directory: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(TempFileManager {
|
||||
base_dir,
|
||||
files: HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_base_dir(&self) -> &Path {
|
||||
&self.base_dir
|
||||
}
|
||||
|
||||
pub fn save_file(
|
||||
&mut self,
|
||||
conversation_id: &str,
|
||||
data: &[u8],
|
||||
original_filename: Option<&str>,
|
||||
) -> Result<PathBuf, String> {
|
||||
let unique_id = Uuid::new_v4();
|
||||
let extension = original_filename
|
||||
.and_then(|name| Path::new(name).extension())
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("bin");
|
||||
|
||||
let filename = format!("{}_{}.{}", conversation_id, unique_id, extension);
|
||||
let file_path = self.base_dir.join(&filename);
|
||||
|
||||
fs::write(&file_path, data)
|
||||
.map_err(|e| format!("Failed to write temp file: {}", e))?;
|
||||
|
||||
self.files
|
||||
.entry(conversation_id.to_string())
|
||||
.or_default()
|
||||
.push(file_path.clone());
|
||||
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
pub fn register_file(&mut self, conversation_id: &str, file_path: PathBuf) {
|
||||
self.files
|
||||
.entry(conversation_id.to_string())
|
||||
.or_default()
|
||||
.push(file_path);
|
||||
}
|
||||
|
||||
pub fn get_files_for_conversation(&self, conversation_id: &str) -> Vec<PathBuf> {
|
||||
self.files
|
||||
.get(conversation_id)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn cleanup_conversation(&mut self, conversation_id: &str) -> Result<(), String> {
|
||||
if let Some(files) = self.files.remove(conversation_id) {
|
||||
for file_path in files {
|
||||
if file_path.exists() {
|
||||
if let Err(e) = fs::remove_file(&file_path) {
|
||||
eprintln!(
|
||||
"Warning: Failed to remove temp file {:?}: {}",
|
||||
file_path, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cleanup_all(&mut self) -> Result<(), String> {
|
||||
let conversation_ids: Vec<String> = self.files.keys().cloned().collect();
|
||||
|
||||
for conversation_id in conversation_ids {
|
||||
self.cleanup_conversation(&conversation_id)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cleanup_orphaned_files(&mut self) -> Result<usize, String> {
|
||||
let mut cleaned_count = 0;
|
||||
|
||||
if !self.base_dir.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let tracked_files: std::collections::HashSet<PathBuf> =
|
||||
self.files.values().flatten().cloned().collect();
|
||||
|
||||
let entries = fs::read_dir(&self.base_dir)
|
||||
.map_err(|e| format!("Failed to read temp directory: {}", e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() && !tracked_files.contains(&path) {
|
||||
if let Err(e) = fs::remove_file(&path) {
|
||||
eprintln!("Warning: Failed to remove orphaned file {:?}: {}", path, e);
|
||||
} else {
|
||||
cleaned_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cleaned_count)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TempFileManager {
|
||||
fn default() -> Self {
|
||||
Self::new().expect("Failed to create TempFileManager")
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedTempFileManager = Arc<Mutex<TempFileManager>>;
|
||||
|
||||
pub fn create_shared_temp_manager() -> Result<SharedTempFileManager, String> {
|
||||
Ok(Arc::new(Mutex::new(TempFileManager::new()?)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// Helper to create a TempFileManager with a custom base directory for testing
|
||||
fn create_test_manager(base_dir: PathBuf) -> TempFileManager {
|
||||
if !base_dir.exists() {
|
||||
fs::create_dir_all(&base_dir).expect("Failed to create test temp dir");
|
||||
}
|
||||
TempFileManager {
|
||||
base_dir,
|
||||
files: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_creates_base_directory() {
|
||||
let manager = TempFileManager::new().expect("Failed to create TempFileManager");
|
||||
assert!(manager.base_dir.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_base_dir_returns_correct_path() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let base_path = temp_dir.path().join("hikari-test");
|
||||
let manager = create_test_manager(base_path.clone());
|
||||
|
||||
assert_eq!(manager.get_base_dir(), base_path.as_path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_file_creates_file_with_content() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let base_path = temp_dir.path().join("hikari-test");
|
||||
let mut manager = create_test_manager(base_path);
|
||||
|
||||
let data = b"Hello, world!";
|
||||
let result = manager.save_file("conv-1", data, Some("test.txt"));
|
||||
|
||||
assert!(result.is_ok());
|
||||
let file_path = result.unwrap();
|
||||
assert!(file_path.exists());
|
||||
|
||||
let content = fs::read(&file_path).expect("Failed to read file");
|
||||
assert_eq!(content, data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_file_uses_correct_extension() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let base_path = temp_dir.path().join("hikari-test");
|
||||
let mut manager = create_test_manager(base_path);
|
||||
|
||||
let data = b"test data";
|
||||
let result = manager.save_file("conv-1", data, Some("document.pdf"));
|
||||
|
||||
assert!(result.is_ok());
|
||||
let file_path = result.unwrap();
|
||||
assert_eq!(file_path.extension().unwrap(), "pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_file_uses_bin_extension_when_no_filename() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let base_path = temp_dir.path().join("hikari-test");
|
||||
let mut manager = create_test_manager(base_path);
|
||||
|
||||
let data = b"binary data";
|
||||
let result = manager.save_file("conv-1", data, None);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let file_path = result.unwrap();
|
||||
assert_eq!(file_path.extension().unwrap(), "bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_file_tracks_file_path() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let base_path = temp_dir.path().join("hikari-test");
|
||||
let mut manager = create_test_manager(base_path);
|
||||
|
||||
let file_path = PathBuf::from("/some/path/file.txt");
|
||||
manager.register_file("conv-1", file_path.clone());
|
||||
|
||||
let files = manager.get_files_for_conversation("conv-1");
|
||||
assert_eq!(files.len(), 1);
|
||||
assert_eq!(files[0], file_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_files_for_conversation_returns_empty_for_unknown() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let base_path = temp_dir.path().join("hikari-test");
|
||||
let manager = create_test_manager(base_path);
|
||||
|
||||
let files = manager.get_files_for_conversation("unknown-conv");
|
||||
assert!(files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_files_for_conversation_returns_all_files() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let base_path = temp_dir.path().join("hikari-test");
|
||||
let mut manager = create_test_manager(base_path);
|
||||
|
||||
let data = b"test";
|
||||
manager.save_file("conv-1", data, Some("file1.txt")).unwrap();
|
||||
manager.save_file("conv-1", data, Some("file2.txt")).unwrap();
|
||||
manager.save_file("conv-2", data, Some("file3.txt")).unwrap();
|
||||
|
||||
let files_conv1 = manager.get_files_for_conversation("conv-1");
|
||||
let files_conv2 = manager.get_files_for_conversation("conv-2");
|
||||
|
||||
assert_eq!(files_conv1.len(), 2);
|
||||
assert_eq!(files_conv2.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_conversation_removes_files() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let base_path = temp_dir.path().join("hikari-test");
|
||||
let mut manager = create_test_manager(base_path);
|
||||
|
||||
let data = b"test";
|
||||
let file_path = manager.save_file("conv-1", data, Some("test.txt")).unwrap();
|
||||
assert!(file_path.exists());
|
||||
|
||||
let result = manager.cleanup_conversation("conv-1");
|
||||
assert!(result.is_ok());
|
||||
assert!(!file_path.exists());
|
||||
assert!(manager.get_files_for_conversation("conv-1").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_conversation_handles_missing_files() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let base_path = temp_dir.path().join("hikari-test");
|
||||
let mut manager = create_test_manager(base_path);
|
||||
|
||||
// Register a file that doesn't exist
|
||||
manager.register_file("conv-1", PathBuf::from("/nonexistent/file.txt"));
|
||||
|
||||
// Should not error, just skip missing files
|
||||
let result = manager.cleanup_conversation("conv-1");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_conversation_for_unknown_returns_ok() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let base_path = temp_dir.path().join("hikari-test");
|
||||
let mut manager = create_test_manager(base_path);
|
||||
|
||||
let result = manager.cleanup_conversation("unknown-conv");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_all_removes_all_files() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let base_path = temp_dir.path().join("hikari-test");
|
||||
let mut manager = create_test_manager(base_path);
|
||||
|
||||
let data = b"test";
|
||||
let file1 = manager.save_file("conv-1", data, Some("f1.txt")).unwrap();
|
||||
let file2 = manager.save_file("conv-2", data, Some("f2.txt")).unwrap();
|
||||
|
||||
assert!(file1.exists());
|
||||
assert!(file2.exists());
|
||||
|
||||
let result = manager.cleanup_all();
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert!(!file1.exists());
|
||||
assert!(!file2.exists());
|
||||
assert!(manager.files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_orphaned_files_removes_untracked() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let base_path = temp_dir.path().join("hikari-test");
|
||||
let mut manager = create_test_manager(base_path.clone());
|
||||
|
||||
// Create a tracked file
|
||||
let data = b"tracked";
|
||||
let tracked_path = manager.save_file("conv-1", data, Some("tracked.txt")).unwrap();
|
||||
|
||||
// Create an untracked (orphaned) file directly in the temp directory
|
||||
let orphan_path = base_path.join("orphan.txt");
|
||||
fs::write(&orphan_path, b"orphan").expect("Failed to create orphan file");
|
||||
|
||||
assert!(tracked_path.exists());
|
||||
assert!(orphan_path.exists());
|
||||
|
||||
let result = manager.cleanup_orphaned_files();
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), 1); // One orphan removed
|
||||
|
||||
assert!(tracked_path.exists()); // Tracked file still exists
|
||||
assert!(!orphan_path.exists()); // Orphan removed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_orphaned_returns_zero_when_none() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let base_path = temp_dir.path().join("hikari-test");
|
||||
let mut manager = create_test_manager(base_path);
|
||||
|
||||
let data = b"test";
|
||||
manager.save_file("conv-1", data, Some("test.txt")).unwrap();
|
||||
|
||||
let result = manager.cleanup_orphaned_files();
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_orphaned_returns_zero_when_dir_missing() {
|
||||
let mut manager = TempFileManager {
|
||||
base_dir: PathBuf::from("/nonexistent/dir"),
|
||||
files: HashMap::new(),
|
||||
};
|
||||
|
||||
let result = manager.cleanup_orphaned_files();
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_creates_manager() {
|
||||
// Default should work as long as we can create temp directories
|
||||
let manager = TempFileManager::default();
|
||||
assert!(manager.base_dir.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_shared_temp_manager() {
|
||||
let result = create_shared_temp_manager();
|
||||
assert!(result.is_ok());
|
||||
|
||||
let shared = result.unwrap();
|
||||
let manager = shared.lock();
|
||||
assert!(manager.base_dir.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_files_same_conversation() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let base_path = temp_dir.path().join("hikari-test");
|
||||
let mut manager = create_test_manager(base_path);
|
||||
|
||||
// Save multiple files to same conversation
|
||||
for i in 0..5 {
|
||||
let data = format!("content {}", i);
|
||||
manager
|
||||
.save_file("conv-1", data.as_bytes(), Some(&format!("file{}.txt", i)))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let files = manager.get_files_for_conversation("conv-1");
|
||||
assert_eq!(files.len(), 5);
|
||||
|
||||
// Each file should have unique content
|
||||
for (i, file_path) in files.iter().enumerate() {
|
||||
let content = fs::read_to_string(file_path).expect("Failed to read");
|
||||
assert_eq!(content, format!("content {}", i));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_paths_contain_conversation_id() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let base_path = temp_dir.path().join("hikari-test");
|
||||
let mut manager = create_test_manager(base_path);
|
||||
|
||||
let file_path = manager
|
||||
.save_file("my-conversation-id", b"test", Some("test.txt"))
|
||||
.unwrap();
|
||||
|
||||
let filename = file_path.file_name().unwrap().to_str().unwrap();
|
||||
assert!(filename.starts_with("my-conversation-id_"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
AppHandle, Manager,
|
||||
};
|
||||
|
||||
use crate::config::HikariConfig;
|
||||
|
||||
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||
let show_item = MenuItem::with_id(app, "show", "Show Hikari", true, None::<&str>)?;
|
||||
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||
|
||||
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
|
||||
|
||||
let _tray = TrayIconBuilder::with_id("main")
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.tooltip("Hikari - Claude Code Assistant")
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"show" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn should_minimize_to_tray(app: &AppHandle) -> bool {
|
||||
let config_path = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.ok()
|
||||
.map(|p| p.join("hikari-config.json"));
|
||||
|
||||
if let Some(path) = config_path {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
if let Ok(config) = serde_json::from_str::<HikariConfig>(&content) {
|
||||
return config.minimize_to_tray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
@@ -216,6 +216,24 @@ pub struct WorkingDirectoryEvent {
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QuestionOption {
|
||||
pub label: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserQuestionEvent {
|
||||
pub id: String,
|
||||
pub question: String,
|
||||
pub header: Option<String>,
|
||||
pub options: Vec<QuestionOption>,
|
||||
pub multi_select: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::process::Command;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
use std::process::Command;
|
||||
use tauri::command;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[command]
|
||||
pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> {
|
||||
@@ -17,8 +17,8 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
|
||||
);
|
||||
|
||||
// Create a temporary VBS file
|
||||
let mut temp_file = NamedTempFile::new()
|
||||
.map_err(|e| format!("Failed to create temp file: {}", e))?;
|
||||
let mut temp_file =
|
||||
NamedTempFile::new().map_err(|e| format!("Failed to create temp file: {}", e))?;
|
||||
|
||||
temp_file
|
||||
.write_all(vbs_content.as_bytes())
|
||||
@@ -40,10 +40,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
|
||||
} else if temp_path.starts_with("/tmp/") {
|
||||
// WSL temp files might be in a different location
|
||||
// Try to use wslpath to convert
|
||||
let output = Command::new("wslpath")
|
||||
.arg("-w")
|
||||
.arg(&temp_path)
|
||||
.output();
|
||||
let output = Command::new("wslpath").arg("-w").arg(&temp_path).output();
|
||||
|
||||
if let Ok(result) = output {
|
||||
if result.status.success() {
|
||||
@@ -71,4 +68,4 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use tauri::command;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use windows::{
|
||||
core::{HSTRING, Result as WindowsResult},
|
||||
core::{Result as WindowsResult, HSTRING},
|
||||
Data::Xml::Dom::*,
|
||||
UI::Notifications::*,
|
||||
};
|
||||
@@ -38,7 +38,8 @@ fn show_toast_notification(title: &str, body: &str) -> WindowsResult<()> {
|
||||
let toast = ToastNotification::CreateToastNotification(&xml_doc)?;
|
||||
|
||||
// Create a toast notifier with an application ID
|
||||
let notifier = ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from("Hikari Desktop"))?;
|
||||
let notifier =
|
||||
ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from("Hikari Desktop"))?;
|
||||
|
||||
// Show the notification
|
||||
notifier.Show(&toast)?;
|
||||
@@ -60,4 +61,4 @@ fn escape_xml(text: &str) -> String {
|
||||
#[command]
|
||||
pub async fn send_windows_toast(_title: String, _body: String) -> Result<(), String> {
|
||||
Err("Windows toast notifications are only available on Windows".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,15 @@ use tempfile::NamedTempFile;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
use crate::config::ClaudeStartOptions;
|
||||
use crate::stats::{UsageStats, StatsUpdateEvent};
|
||||
use parking_lot::RwLock;
|
||||
use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent, ConnectionEvent, SessionEvent, WorkingDirectoryEvent};
|
||||
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
|
||||
use crate::config::ClaudeStartOptions;
|
||||
use crate::stats::{StatsUpdateEvent, UsageStats};
|
||||
use crate::types::{
|
||||
CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, OutputEvent,
|
||||
PermissionPromptEvent, QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent,
|
||||
WorkingDirectoryEvent,
|
||||
};
|
||||
use parking_lot::RwLock;
|
||||
|
||||
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
|
||||
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
|
||||
@@ -103,26 +107,42 @@ impl WslBridge {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
|
||||
if self.process.is_some() {
|
||||
return Err("Process already running".to_string());
|
||||
}
|
||||
|
||||
// Load saved achievements when starting a new session
|
||||
// Load saved achievements and stats when starting a new session
|
||||
let app_clone = app.clone();
|
||||
let stats = self.stats.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
println!("Loading saved achievements...");
|
||||
let achievements = crate::achievements::load_achievements(&app_clone).await;
|
||||
println!("Loaded {} unlocked achievements", achievements.unlocked.len());
|
||||
stats.write().achievements = achievements;
|
||||
println!(
|
||||
"Loaded {} unlocked achievements",
|
||||
achievements.unlocked.len()
|
||||
);
|
||||
|
||||
println!("Loading saved stats...");
|
||||
let persisted_stats = crate::stats::load_stats(&app_clone).await;
|
||||
|
||||
let mut stats_guard = stats.write();
|
||||
stats_guard.achievements = achievements;
|
||||
|
||||
if let Some(persisted) = persisted_stats {
|
||||
println!("Applying persisted lifetime stats");
|
||||
stats_guard.apply_persisted(persisted);
|
||||
}
|
||||
});
|
||||
|
||||
let working_dir = &options.working_dir;
|
||||
self.working_directory = working_dir.clone();
|
||||
|
||||
emit_connection_status(&app, ConnectionStatus::Connecting, self.conversation_id.clone());
|
||||
emit_connection_status(
|
||||
&app,
|
||||
ConnectionStatus::Connecting,
|
||||
self.conversation_id.clone(),
|
||||
);
|
||||
|
||||
// Create temp file for MCP config if provided
|
||||
let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json {
|
||||
@@ -158,16 +178,19 @@ impl WslBridge {
|
||||
let mut command = if is_wsl {
|
||||
// Running inside WSL - call claude directly
|
||||
// Try to find claude in common locations since GUI apps may not inherit shell PATH
|
||||
let claude_path = find_claude_binary()
|
||||
.ok_or_else(|| "Could not find claude binary. Is Claude Code installed?".to_string())?;
|
||||
let claude_path = find_claude_binary().ok_or_else(|| {
|
||||
"Could not find claude binary. Is Claude Code installed?".to_string()
|
||||
})?;
|
||||
|
||||
eprintln!("[DEBUG] Found claude at: {}", claude_path);
|
||||
eprintln!("[DEBUG] Working dir: {}", working_dir);
|
||||
|
||||
let mut cmd = Command::new(&claude_path);
|
||||
cmd.args([
|
||||
"--output-format", "stream-json",
|
||||
"--input-format", "stream-json",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--input-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
]);
|
||||
|
||||
@@ -195,6 +218,13 @@ impl WslBridge {
|
||||
cmd.args(["--mcp-config", mcp_path]);
|
||||
}
|
||||
|
||||
// Add resume flag if session ID provided
|
||||
if let Some(ref session_id) = options.resume_session_id {
|
||||
if !session_id.is_empty() {
|
||||
cmd.args(["--resume", session_id]);
|
||||
}
|
||||
}
|
||||
|
||||
cmd.current_dir(working_dir);
|
||||
|
||||
// Set API key as environment variable if specified
|
||||
@@ -211,10 +241,7 @@ impl WslBridge {
|
||||
let mut cmd = Command::new("wsl");
|
||||
|
||||
// Build the claude command with all arguments
|
||||
let mut claude_cmd = format!(
|
||||
"cd '{}' && ",
|
||||
working_dir
|
||||
);
|
||||
let mut claude_cmd = format!("cd '{}' && ", working_dir);
|
||||
|
||||
// Set API key as environment variable if specified
|
||||
if let Some(ref api_key) = options.api_key {
|
||||
@@ -223,7 +250,9 @@ impl WslBridge {
|
||||
}
|
||||
}
|
||||
|
||||
claude_cmd.push_str("claude --output-format stream-json --input-format stream-json --verbose");
|
||||
claude_cmd.push_str(
|
||||
"claude --output-format stream-json --input-format stream-json --verbose",
|
||||
);
|
||||
|
||||
// Add model if specified
|
||||
if let Some(ref model) = options.model {
|
||||
@@ -251,6 +280,13 @@ impl WslBridge {
|
||||
claude_cmd.push_str(&format!(" --mcp-config '{}'", mcp_path));
|
||||
}
|
||||
|
||||
// Add resume flag if session ID provided
|
||||
if let Some(ref session_id) = options.resume_session_id {
|
||||
if !session_id.is_empty() {
|
||||
claude_cmd.push_str(&format!(" --resume '{}'", session_id));
|
||||
}
|
||||
}
|
||||
|
||||
// Use bash -lc to load login profile (ensures PATH includes claude)
|
||||
cmd.args(["-e", "bash", "-lc", &claude_cmd]);
|
||||
|
||||
@@ -278,8 +314,8 @@ impl WslBridge {
|
||||
self.stdin = stdin;
|
||||
self.process = Some(child);
|
||||
|
||||
// Reset session stats when starting new session
|
||||
self.stats.write().reset_session();
|
||||
// Note: We no longer reset stats here - stats persist across reconnects
|
||||
// Stats are only reset when explicitly disconnecting via stop()
|
||||
|
||||
// Load saved achievements
|
||||
let app_handle = app.clone();
|
||||
@@ -306,7 +342,11 @@ impl WslBridge {
|
||||
});
|
||||
}
|
||||
|
||||
emit_connection_status(&app, ConnectionStatus::Connected, self.conversation_id.clone());
|
||||
emit_connection_status(
|
||||
&app,
|
||||
ConnectionStatus::Connected,
|
||||
self.conversation_id.clone(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -331,7 +371,44 @@ impl WslBridge {
|
||||
.write_all(format!("{}\n", json_line).as_bytes())
|
||||
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
|
||||
|
||||
stdin.flush().map_err(|e| format!("Failed to flush stdin: {}", e))?;
|
||||
stdin
|
||||
.flush()
|
||||
.map_err(|e| format!("Failed to flush stdin: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send_tool_result(
|
||||
&mut self,
|
||||
tool_use_id: &str,
|
||||
result: serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
let stdin = self.stdin.as_mut().ok_or("Process not running")?;
|
||||
|
||||
// The content should be a JSON string representation of the result
|
||||
let content_str = serde_json::to_string(&result).map_err(|e| e.to_string())?;
|
||||
|
||||
let input = serde_json::json!({
|
||||
"type": "user",
|
||||
"message": {
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_use_id,
|
||||
"content": content_str
|
||||
}]
|
||||
}
|
||||
});
|
||||
|
||||
let json_line = serde_json::to_string(&input).map_err(|e| e.to_string())?;
|
||||
|
||||
stdin
|
||||
.write_all(format!("{}\n", json_line).as_bytes())
|
||||
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
|
||||
|
||||
stdin
|
||||
.flush()
|
||||
.map_err(|e| format!("Failed to flush stdin: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -352,7 +429,11 @@ impl WslBridge {
|
||||
// The user will see what session was interrupted
|
||||
|
||||
// Emit disconnected status
|
||||
emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone());
|
||||
emit_connection_status(
|
||||
app,
|
||||
ConnectionStatus::Disconnected,
|
||||
self.conversation_id.clone(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -368,7 +449,27 @@ impl WslBridge {
|
||||
self.stdin = None;
|
||||
self.session_id = None;
|
||||
self.mcp_config_file = None; // Temp file is automatically deleted when dropped
|
||||
emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone());
|
||||
|
||||
// Save lifetime stats before resetting session
|
||||
let stats_snapshot = self.stats.read().clone();
|
||||
let app_clone = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
println!("Saving stats on session stop...");
|
||||
if let Err(e) = crate::stats::save_stats(&app_clone, &stats_snapshot).await {
|
||||
eprintln!("Failed to save stats: {}", e);
|
||||
} else {
|
||||
println!("Stats saved successfully on session stop");
|
||||
}
|
||||
});
|
||||
|
||||
// Reset session stats on explicit disconnect
|
||||
self.stats.write().reset_session();
|
||||
|
||||
emit_connection_status(
|
||||
app,
|
||||
ConnectionStatus::Disconnected,
|
||||
self.conversation_id.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
@@ -382,7 +483,6 @@ impl WslBridge {
|
||||
pub fn get_stats(&self) -> UsageStats {
|
||||
self.stats.read().clone()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl Default for WslBridge {
|
||||
@@ -391,7 +491,12 @@ impl Default for WslBridge {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<RwLock<UsageStats>>, conversation_id: Option<String>) {
|
||||
fn handle_stdout(
|
||||
stdout: std::process::ChildStdout,
|
||||
app: AppHandle,
|
||||
stats: Arc<RwLock<UsageStats>>,
|
||||
conversation_id: Option<String>,
|
||||
) {
|
||||
let reader = BufReader::new(stdout);
|
||||
|
||||
for line in reader.lines() {
|
||||
@@ -412,18 +517,25 @@ fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<R
|
||||
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
|
||||
}
|
||||
|
||||
fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle, conversation_id: Option<String>) {
|
||||
fn handle_stderr(
|
||||
stderr: std::process::ChildStderr,
|
||||
app: AppHandle,
|
||||
conversation_id: Option<String>,
|
||||
) {
|
||||
let reader = BufReader::new(stderr);
|
||||
|
||||
for line in reader.lines() {
|
||||
match line {
|
||||
Ok(line) if !line.is_empty() => {
|
||||
let _ = app.emit("claude:output", OutputEvent {
|
||||
line_type: "error".to_string(),
|
||||
content: line,
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
});
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
OutputEvent {
|
||||
line_type: "error".to_string(),
|
||||
content: line,
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(_) => break,
|
||||
_ => {}
|
||||
@@ -431,24 +543,40 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle, conversation
|
||||
}
|
||||
}
|
||||
|
||||
fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>>, conversation_id: &Option<String>) -> Result<(), String> {
|
||||
fn process_json_line(
|
||||
line: &str,
|
||||
app: &AppHandle,
|
||||
stats: &Arc<RwLock<UsageStats>>,
|
||||
conversation_id: &Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let message: ClaudeMessage = serde_json::from_str(line)
|
||||
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
|
||||
|
||||
match &message {
|
||||
ClaudeMessage::System { subtype, session_id, cwd, .. } => {
|
||||
ClaudeMessage::System {
|
||||
subtype,
|
||||
session_id,
|
||||
cwd,
|
||||
..
|
||||
} => {
|
||||
if subtype == "init" {
|
||||
if let Some(id) = session_id {
|
||||
let _ = app.emit("claude:session", SessionEvent {
|
||||
session_id: id.clone(),
|
||||
conversation_id: conversation_id.clone(),
|
||||
});
|
||||
let _ = app.emit(
|
||||
"claude:session",
|
||||
SessionEvent {
|
||||
session_id: id.clone(),
|
||||
conversation_id: conversation_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some(dir) = cwd {
|
||||
let _ = app.emit("claude:cwd", WorkingDirectoryEvent {
|
||||
directory: dir.clone(),
|
||||
conversation_id: conversation_id.clone(),
|
||||
});
|
||||
let _ = app.emit(
|
||||
"claude:cwd",
|
||||
WorkingDirectoryEvent {
|
||||
directory: dir.clone(),
|
||||
conversation_id: conversation_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
emit_state_change(app, CharacterState::Idle, None, conversation_id.clone());
|
||||
}
|
||||
@@ -500,12 +628,15 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
||||
}
|
||||
|
||||
let desc = format_tool_description(name, input);
|
||||
let _ = app.emit("claude:output", OutputEvent {
|
||||
line_type: "tool".to_string(),
|
||||
content: desc,
|
||||
tool_name: Some(name.clone()),
|
||||
conversation_id: conversation_id.clone(),
|
||||
});
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
OutputEvent {
|
||||
line_type: "tool".to_string(),
|
||||
content: desc,
|
||||
tool_name: Some(name.clone()),
|
||||
conversation_id: conversation_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
ContentBlock::Text { text } => {
|
||||
// Count code blocks in the text
|
||||
@@ -514,21 +645,27 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
||||
stats.write().increment_code_blocks();
|
||||
}
|
||||
|
||||
let _ = app.emit("claude:output", OutputEvent {
|
||||
line_type: "assistant".to_string(),
|
||||
content: text.clone(),
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
});
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
OutputEvent {
|
||||
line_type: "assistant".to_string(),
|
||||
content: text.clone(),
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
ContentBlock::Thinking { thinking } => {
|
||||
state = CharacterState::Thinking;
|
||||
let _ = app.emit("claude:output", OutputEvent {
|
||||
line_type: "system".to_string(),
|
||||
content: format!("[Thinking] {}", thinking),
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
});
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
OutputEvent {
|
||||
line_type: "system".to_string(),
|
||||
content: format!("[Thinking] {}", thinking),
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -563,7 +700,13 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
||||
}
|
||||
}
|
||||
|
||||
ClaudeMessage::Result { subtype, result, permission_denials, usage: _, .. } => {
|
||||
ClaudeMessage::Result {
|
||||
subtype,
|
||||
result,
|
||||
permission_denials,
|
||||
usage: _,
|
||||
..
|
||||
} => {
|
||||
let state = if subtype == "success" {
|
||||
CharacterState::Success
|
||||
} else {
|
||||
@@ -584,9 +727,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
||||
// Emit achievement events for any newly unlocked achievements
|
||||
for achievement_id in &newly_unlocked {
|
||||
let info = get_achievement_info(achievement_id);
|
||||
let _ = app.emit("achievement:unlocked", AchievementUnlockedEvent {
|
||||
achievement: info,
|
||||
});
|
||||
let _ = app.emit(
|
||||
"achievement:unlocked",
|
||||
AchievementUnlockedEvent { achievement: info },
|
||||
);
|
||||
}
|
||||
|
||||
// Save achievements after unlocking new ones
|
||||
@@ -598,7 +742,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
||||
// Use Tauri's async runtime instead of tokio::spawn
|
||||
tauri::async_runtime::spawn(async move {
|
||||
println!("Spawned save task for achievements");
|
||||
if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress).await {
|
||||
if let Err(e) =
|
||||
crate::achievements::save_achievements(&app_handle, &achievements_progress)
|
||||
.await
|
||||
{
|
||||
eprintln!("Failed to save achievements: {}", e);
|
||||
} else {
|
||||
println!("Achievement save task completed successfully");
|
||||
@@ -608,38 +755,127 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
||||
|
||||
let current_stats = stats.read().clone();
|
||||
let stats_event = StatsUpdateEvent {
|
||||
stats: current_stats,
|
||||
stats: current_stats.clone(),
|
||||
};
|
||||
let _ = app.emit("claude:stats", stats_event);
|
||||
|
||||
// Save stats periodically (every 10 messages to avoid excessive disk writes)
|
||||
if current_stats.session_messages_exchanged.is_multiple_of(10)
|
||||
&& current_stats.session_messages_exchanged > 0
|
||||
{
|
||||
let app_handle = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
println!("Periodic stats save (every 10 messages)...");
|
||||
if let Err(e) = crate::stats::save_stats(&app_handle, ¤t_stats).await {
|
||||
eprintln!("Failed to save stats: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Only emit error results - success content is already sent via Assistant message
|
||||
if subtype != "success" {
|
||||
if let Some(text) = result {
|
||||
let _ = app.emit("claude:output", OutputEvent {
|
||||
line_type: "error".to_string(),
|
||||
content: text.clone(),
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
});
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
OutputEvent {
|
||||
line_type: "error".to_string(),
|
||||
content: text.clone(),
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for permission denials and emit prompts for each
|
||||
if let Some(denials) = permission_denials {
|
||||
let mut has_regular_denials = false;
|
||||
|
||||
for denial in denials {
|
||||
let description = format_tool_description(&denial.tool_name, &denial.tool_input);
|
||||
let _ = app.emit("claude:permission", PermissionPromptEvent {
|
||||
id: denial.tool_use_id.clone(),
|
||||
tool_name: denial.tool_name.clone(),
|
||||
tool_input: denial.tool_input.clone(),
|
||||
description,
|
||||
conversation_id: conversation_id.clone(),
|
||||
});
|
||||
// Special handling for AskUserQuestion tool
|
||||
if denial.tool_name == "AskUserQuestion" {
|
||||
if let Some(questions) = denial
|
||||
.tool_input
|
||||
.get("questions")
|
||||
.and_then(|q| q.as_array())
|
||||
{
|
||||
// For now, handle the first question (most common case)
|
||||
if let Some(first_question) = questions.first() {
|
||||
let question_text = first_question
|
||||
.get("question")
|
||||
.and_then(|q| q.as_str())
|
||||
.unwrap_or("Claude has a question for you")
|
||||
.to_string();
|
||||
|
||||
let header = first_question
|
||||
.get("header")
|
||||
.and_then(|h| h.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let multi_select = first_question
|
||||
.get("multiSelect")
|
||||
.and_then(|m| m.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let options: Vec<QuestionOption> = first_question
|
||||
.get("options")
|
||||
.and_then(|opts| opts.as_array())
|
||||
.map(|opts| {
|
||||
opts.iter()
|
||||
.filter_map(|opt| {
|
||||
let label =
|
||||
opt.get("label").and_then(|l| l.as_str())?;
|
||||
let description = opt
|
||||
.get("description")
|
||||
.and_then(|d| d.as_str())
|
||||
.map(|s| s.to_string());
|
||||
Some(QuestionOption {
|
||||
label: label.to_string(),
|
||||
description,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let _ = app.emit(
|
||||
"claude:question",
|
||||
UserQuestionEvent {
|
||||
id: denial.tool_use_id.clone(),
|
||||
question: question_text,
|
||||
header,
|
||||
options,
|
||||
multi_select,
|
||||
conversation_id: conversation_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
has_regular_denials = true;
|
||||
let description =
|
||||
format_tool_description(&denial.tool_name, &denial.tool_input);
|
||||
let _ = app.emit(
|
||||
"claude:permission",
|
||||
PermissionPromptEvent {
|
||||
id: denial.tool_use_id.clone(),
|
||||
tool_name: denial.tool_name.clone(),
|
||||
tool_input: denial.tool_input.clone(),
|
||||
description,
|
||||
conversation_id: conversation_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Show permission state if there were denials
|
||||
if !denials.is_empty() {
|
||||
emit_state_change(app, CharacterState::Permission, None, conversation_id.clone());
|
||||
// Show permission state if there were any denials (questions or regular)
|
||||
if has_regular_denials || !denials.is_empty() {
|
||||
emit_state_change(
|
||||
app,
|
||||
CharacterState::Permission,
|
||||
None,
|
||||
conversation_id.clone(),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -652,7 +888,9 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
||||
stats.write().increment_messages();
|
||||
|
||||
// Extract text content from the message
|
||||
let message_text = message.content.iter()
|
||||
let message_text = message
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|block| match block {
|
||||
crate::types::ContentBlock::Text { text } => Some(text.clone()),
|
||||
_ => None,
|
||||
@@ -682,9 +920,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
||||
for achievement_id in &newly_unlocked {
|
||||
println!("User message unlocked achievement: {:?}", achievement_id);
|
||||
let info = get_achievement_info(achievement_id);
|
||||
let _ = app.emit("achievement:unlocked", AchievementUnlockedEvent {
|
||||
achievement: info,
|
||||
});
|
||||
let _ = app.emit(
|
||||
"achievement:unlocked",
|
||||
AchievementUnlockedEvent { achievement: info },
|
||||
);
|
||||
}
|
||||
|
||||
// Save achievements after unlocking new ones
|
||||
@@ -693,7 +932,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
||||
let app_handle = app.clone();
|
||||
let achievements_progress = stats.read().achievements.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress).await {
|
||||
if let Err(e) =
|
||||
crate::achievements::save_achievements(&app_handle, &achievements_progress)
|
||||
.await
|
||||
{
|
||||
eprintln!("Failed to save achievements: {}", e);
|
||||
} else {
|
||||
println!("Achievements saved after user message");
|
||||
@@ -768,15 +1010,36 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option<String>, conversation_id: Option<String>) {
|
||||
let _ = app.emit("claude:state", StateChangeEvent { state, tool_name, conversation_id });
|
||||
fn emit_state_change(
|
||||
app: &AppHandle,
|
||||
state: CharacterState,
|
||||
tool_name: Option<String>,
|
||||
conversation_id: Option<String>,
|
||||
) {
|
||||
let _ = app.emit(
|
||||
"claude:state",
|
||||
StateChangeEvent {
|
||||
state,
|
||||
tool_name,
|
||||
conversation_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_connection_status(app: &AppHandle, status: ConnectionStatus, conversation_id: Option<String>) {
|
||||
let _ = app.emit("claude:connection", ConnectionEvent { status, conversation_id });
|
||||
fn emit_connection_status(
|
||||
app: &AppHandle,
|
||||
status: ConnectionStatus,
|
||||
conversation_id: Option<String>,
|
||||
) {
|
||||
let _ = app.emit(
|
||||
"claude:connection",
|
||||
ConnectionEvent {
|
||||
status,
|
||||
conversation_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -786,21 +1049,36 @@ mod tests {
|
||||
assert!(matches!(get_tool_state("Read"), CharacterState::Searching));
|
||||
assert!(matches!(get_tool_state("Glob"), CharacterState::Searching));
|
||||
assert!(matches!(get_tool_state("Grep"), CharacterState::Searching));
|
||||
assert!(matches!(get_tool_state("WebSearch"), CharacterState::Searching));
|
||||
assert!(matches!(get_tool_state("WebFetch"), CharacterState::Searching));
|
||||
assert!(matches!(
|
||||
get_tool_state("WebSearch"),
|
||||
CharacterState::Searching
|
||||
));
|
||||
assert!(matches!(
|
||||
get_tool_state("WebFetch"),
|
||||
CharacterState::Searching
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_tool_state_coding_tools() {
|
||||
assert!(matches!(get_tool_state("Edit"), CharacterState::Coding));
|
||||
assert!(matches!(get_tool_state("Write"), CharacterState::Coding));
|
||||
assert!(matches!(get_tool_state("NotebookEdit"), CharacterState::Coding));
|
||||
assert!(matches!(
|
||||
get_tool_state("NotebookEdit"),
|
||||
CharacterState::Coding
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_tool_state_mcp_tools() {
|
||||
assert!(matches!(get_tool_state("mcp__github__create_issue"), CharacterState::Mcp));
|
||||
assert!(matches!(get_tool_state("mcp__notion__search"), CharacterState::Mcp));
|
||||
assert!(matches!(
|
||||
get_tool_state("mcp__github__create_issue"),
|
||||
CharacterState::Mcp
|
||||
));
|
||||
assert!(matches!(
|
||||
get_tool_state("mcp__notion__search"),
|
||||
CharacterState::Mcp
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -810,7 +1088,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_get_tool_state_unknown() {
|
||||
assert!(matches!(get_tool_state("SomeUnknownTool"), CharacterState::Typing));
|
||||
assert!(matches!(
|
||||
get_tool_state("SomeUnknownTool"),
|
||||
CharacterState::Typing
|
||||
));
|
||||
assert!(matches!(get_tool_state("Bash"), CharacterState::Typing));
|
||||
}
|
||||
|
||||
|
||||
@@ -81,4 +81,4 @@ $notifier.Show($toast)
|
||||
|
||||
// If all methods fail, return an error
|
||||
Err("All WSL notification methods failed".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "hikari-desktop",
|
||||
"version": "0.2.0",
|
||||
"version": "1.0.0",
|
||||
"identifier": "com.naomi.hikari-desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
@@ -22,6 +22,12 @@
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"trayIcon": {
|
||||
"id": "main",
|
||||
"iconPath": "icons/32x32.png",
|
||||
"iconAsTemplate": false,
|
||||
"tooltip": "Hikari - Claude Code Assistant"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
--bg-secondary: #16213e;
|
||||
--bg-terminal: #0f0f1a;
|
||||
--bg-hover: #2a2a4a;
|
||||
--bg-code: #1e1e2e;
|
||||
--accent-primary: #e94560;
|
||||
--accent-secondary: #ff6b9d;
|
||||
--text-primary: #ffffff;
|
||||
@@ -13,11 +14,40 @@
|
||||
--text-tertiary: #6b7280;
|
||||
--border-color: #2a2a4a;
|
||||
|
||||
/* Trans pride colors */
|
||||
--trans-blue: #5bcefa;
|
||||
--trans-pink: #f5a9b8;
|
||||
--trans-white: #ffffff;
|
||||
--trans-gradient: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 50%,
|
||||
var(--trans-white) 100%
|
||||
);
|
||||
--trans-gradient-vibrant: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 35%,
|
||||
var(--trans-white) 50%,
|
||||
var(--trans-pink) 65%,
|
||||
var(--trans-blue) 100%
|
||||
);
|
||||
|
||||
/* Terminal specific colors */
|
||||
--terminal-user: #22d3ee;
|
||||
--terminal-tool: #c084fc;
|
||||
--terminal-tool-name: #ddd6fe;
|
||||
--terminal-error: #f87171;
|
||||
|
||||
/* Syntax highlighting colors (dark) */
|
||||
--hljs-keyword: #f472b6;
|
||||
--hljs-string: #a3e635;
|
||||
--hljs-number: #fbbf24;
|
||||
--hljs-comment: #6b7280;
|
||||
--hljs-function: #c084fc;
|
||||
--hljs-type: #22d3ee;
|
||||
--hljs-variable: #fb923c;
|
||||
--hljs-meta: #94a3b8;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
@@ -25,6 +55,7 @@
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-terminal: #f1f3f4;
|
||||
--bg-hover: #e8e8e8;
|
||||
--bg-code: #f5f5f5;
|
||||
--accent-primary: #e94560;
|
||||
--accent-secondary: #ff6b9d;
|
||||
--text-primary: #1a1a2e;
|
||||
@@ -32,11 +63,89 @@
|
||||
--text-tertiary: #9ca3af;
|
||||
--border-color: #d0d0e0;
|
||||
|
||||
/* Trans pride colors */
|
||||
--trans-blue: #5bcefa;
|
||||
--trans-pink: #f5a9b8;
|
||||
--trans-white: #ffffff;
|
||||
--trans-gradient: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 50%,
|
||||
var(--trans-white) 100%
|
||||
);
|
||||
--trans-gradient-vibrant: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 35%,
|
||||
var(--trans-white) 50%,
|
||||
var(--trans-pink) 65%,
|
||||
var(--trans-blue) 100%
|
||||
);
|
||||
|
||||
/* Terminal specific colors */
|
||||
--terminal-user: #0891b2;
|
||||
--terminal-tool: #7c3aed;
|
||||
--terminal-tool-name: #8b5cf6;
|
||||
--terminal-error: #dc2626;
|
||||
|
||||
/* Syntax highlighting colors (light) */
|
||||
--hljs-keyword: #d946ef;
|
||||
--hljs-string: #16a34a;
|
||||
--hljs-number: #d97706;
|
||||
--hljs-comment: #9ca3af;
|
||||
--hljs-function: #7c3aed;
|
||||
--hljs-type: #0891b2;
|
||||
--hljs-variable: #ea580c;
|
||||
--hljs-meta: #64748b;
|
||||
}
|
||||
|
||||
[data-theme="high-contrast"] {
|
||||
--bg-primary: #000000;
|
||||
--bg-secondary: #0a0a0a;
|
||||
--bg-terminal: #000000;
|
||||
--bg-hover: #1a1a1a;
|
||||
--bg-code: #0a0a0a;
|
||||
--accent-primary: #ff4d6d;
|
||||
--accent-secondary: #ff85a1;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #e0e0e0;
|
||||
--text-tertiary: #b0b0b0;
|
||||
--border-color: #ffffff;
|
||||
|
||||
/* Trans pride colors (high contrast) */
|
||||
--trans-blue: #00d4ff;
|
||||
--trans-pink: #ff99cc;
|
||||
--trans-white: #ffffff;
|
||||
--trans-gradient: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 50%,
|
||||
var(--trans-white) 100%
|
||||
);
|
||||
--trans-gradient-vibrant: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 35%,
|
||||
var(--trans-white) 50%,
|
||||
var(--trans-pink) 65%,
|
||||
var(--trans-blue) 100%
|
||||
);
|
||||
|
||||
/* Terminal specific colors - bright and saturated */
|
||||
--terminal-user: #00ffff;
|
||||
--terminal-tool: #ff00ff;
|
||||
--terminal-tool-name: #ffaaff;
|
||||
--terminal-error: #ff5555;
|
||||
|
||||
/* Syntax highlighting colors (high contrast) */
|
||||
--hljs-keyword: #ff66ff;
|
||||
--hljs-string: #66ff66;
|
||||
--hljs-number: #ffff00;
|
||||
--hljs-comment: #aaaaaa;
|
||||
--hljs-function: #ff99ff;
|
||||
--hljs-type: #00ffff;
|
||||
--hljs-variable: #ffaa00;
|
||||
--hljs-meta: #cccccc;
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -79,3 +188,52 @@ body {
|
||||
background: var(--accent-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Trans gradient button - primary action buttons */
|
||||
.btn-trans-gradient {
|
||||
background: var(--trans-gradient-vibrant) !important;
|
||||
border: none !important;
|
||||
color: #1a1a2e !important;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 0 2px rgba(255, 255, 255, 0.5);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-trans-gradient:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
box-shadow:
|
||||
0 0 20px rgba(91, 206, 250, 0.4),
|
||||
0 0 30px rgba(245, 169, 184, 0.3);
|
||||
}
|
||||
|
||||
.btn-trans-gradient:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(0.3);
|
||||
}
|
||||
|
||||
/* Trans gradient focus border for inputs */
|
||||
.input-trans-focus {
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.input-trans-focus:focus {
|
||||
border-color: var(--trans-pink) !important;
|
||||
box-shadow:
|
||||
0 0 0 1px var(--trans-blue),
|
||||
0 0 12px rgba(91, 206, 250, 0.3),
|
||||
0 0 20px rgba(245, 169, 184, 0.2) !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* Trans gradient hover for icon buttons */
|
||||
.icon-trans-hover {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.icon-trans-hover:hover {
|
||||
color: var(--trans-pink) !important;
|
||||
filter: drop-shadow(0 0 6px rgba(91, 206, 250, 0.5))
|
||||
drop-shadow(0 0 10px rgba(245, 169, 184, 0.4));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
slashCommands,
|
||||
parseSlashCommand,
|
||||
getMatchingCommands,
|
||||
isSlashCommand,
|
||||
type SlashCommand,
|
||||
} from "./slashCommands";
|
||||
|
||||
// Mock all external dependencies
|
||||
vi.mock("svelte/store", () => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/claude", () => ({
|
||||
claudeStore: {
|
||||
addLine: vi.fn(),
|
||||
clearTerminal: vi.fn(),
|
||||
activeConversationId: { subscribe: vi.fn() },
|
||||
currentWorkingDirectory: { subscribe: vi.fn() },
|
||||
setWorkingDirectory: vi.fn(),
|
||||
getConversationHistory: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/character", () => ({
|
||||
characterState: {
|
||||
setState: vi.fn(),
|
||||
setTemporaryState: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/tauri", () => ({
|
||||
setSkipNextGreeting: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/search", () => ({
|
||||
searchState: {
|
||||
setQuery: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("slashCommands", () => {
|
||||
describe("slashCommands array", () => {
|
||||
it("contains expected commands", () => {
|
||||
const commandNames = slashCommands.map((cmd) => cmd.name);
|
||||
expect(commandNames).toContain("cd");
|
||||
expect(commandNames).toContain("clear");
|
||||
expect(commandNames).toContain("new");
|
||||
expect(commandNames).toContain("help");
|
||||
expect(commandNames).toContain("search");
|
||||
expect(commandNames).toContain("summarise");
|
||||
expect(commandNames).toContain("skill");
|
||||
});
|
||||
|
||||
it("has 7 commands total", () => {
|
||||
expect(slashCommands.length).toBe(7);
|
||||
});
|
||||
|
||||
it("each command has required properties", () => {
|
||||
slashCommands.forEach((cmd) => {
|
||||
expect(cmd.name).toBeDefined();
|
||||
expect(typeof cmd.name).toBe("string");
|
||||
expect(cmd.name.length).toBeGreaterThan(0);
|
||||
|
||||
expect(cmd.description).toBeDefined();
|
||||
expect(typeof cmd.description).toBe("string");
|
||||
expect(cmd.description.length).toBeGreaterThan(0);
|
||||
|
||||
expect(cmd.usage).toBeDefined();
|
||||
expect(typeof cmd.usage).toBe("string");
|
||||
expect(cmd.usage.startsWith("/")).toBe(true);
|
||||
|
||||
expect(cmd.execute).toBeDefined();
|
||||
expect(typeof cmd.execute).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
it("cd command has correct metadata", () => {
|
||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd");
|
||||
expect(cdCmd).toBeDefined();
|
||||
expect(cdCmd!.description).toBe("Change the working directory");
|
||||
expect(cdCmd!.usage).toBe("/cd <path>");
|
||||
});
|
||||
|
||||
it("clear command has correct metadata", () => {
|
||||
const clearCmd = slashCommands.find((cmd) => cmd.name === "clear");
|
||||
expect(clearCmd).toBeDefined();
|
||||
expect(clearCmd!.description).toBe("Clear the terminal display (keeps conversation context)");
|
||||
expect(clearCmd!.usage).toBe("/clear");
|
||||
});
|
||||
|
||||
it("new command has correct metadata", () => {
|
||||
const newCmd = slashCommands.find((cmd) => cmd.name === "new");
|
||||
expect(newCmd).toBeDefined();
|
||||
expect(newCmd!.description).toBe("Start a fresh conversation (resets context)");
|
||||
expect(newCmd!.usage).toBe("/new");
|
||||
});
|
||||
|
||||
it("help command has correct metadata", () => {
|
||||
const helpCmd = slashCommands.find((cmd) => cmd.name === "help");
|
||||
expect(helpCmd).toBeDefined();
|
||||
expect(helpCmd!.description).toBe("Show available slash commands");
|
||||
expect(helpCmd!.usage).toBe("/help");
|
||||
});
|
||||
|
||||
it("search command has correct metadata", () => {
|
||||
const searchCmd = slashCommands.find((cmd) => cmd.name === "search");
|
||||
expect(searchCmd).toBeDefined();
|
||||
expect(searchCmd!.description).toBe("Search within the conversation (use /search to clear)");
|
||||
expect(searchCmd!.usage).toBe("/search [query]");
|
||||
});
|
||||
|
||||
it("summarise command has correct metadata", () => {
|
||||
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise");
|
||||
expect(summariseCmd).toBeDefined();
|
||||
expect(summariseCmd!.description).toBe("Get a summary of the entire conversation");
|
||||
expect(summariseCmd!.usage).toBe("/summarise");
|
||||
});
|
||||
|
||||
it("skill command has correct metadata", () => {
|
||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill");
|
||||
expect(skillCmd).toBeDefined();
|
||||
expect(skillCmd!.description).toBe("Invoke a Claude Code skill from ~/.claude/skills/");
|
||||
expect(skillCmd!.usage).toBe("/skill [name] [data]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSlashCommand", () => {
|
||||
it("returns null for non-slash input", () => {
|
||||
const result = parseSlashCommand("hello world");
|
||||
expect(result.command).toBeNull();
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
const result = parseSlashCommand("");
|
||||
expect(result.command).toBeNull();
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("returns null for whitespace only", () => {
|
||||
const result = parseSlashCommand(" ");
|
||||
expect(result.command).toBeNull();
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("parses /cd command without args", () => {
|
||||
const result = parseSlashCommand("/cd");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("cd");
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("parses /cd command with path argument", () => {
|
||||
const result = parseSlashCommand("/cd /home/naomi/code");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("cd");
|
||||
expect(result.args).toBe("/home/naomi/code");
|
||||
});
|
||||
|
||||
it("parses /clear command", () => {
|
||||
const result = parseSlashCommand("/clear");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("clear");
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("parses /new command", () => {
|
||||
const result = parseSlashCommand("/new");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("new");
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("parses /help command", () => {
|
||||
const result = parseSlashCommand("/help");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("help");
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("parses /search command with query", () => {
|
||||
const result = parseSlashCommand("/search hello world");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("search");
|
||||
expect(result.args).toBe("hello world");
|
||||
});
|
||||
|
||||
it("parses /search command without query", () => {
|
||||
const result = parseSlashCommand("/search");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("search");
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("parses /summarise command", () => {
|
||||
const result = parseSlashCommand("/summarise");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("summarise");
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("parses /skill command with name and data", () => {
|
||||
const result = parseSlashCommand("/skill onboard-mentee john@example.com");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("skill");
|
||||
expect(result.args).toBe("onboard-mentee john@example.com");
|
||||
});
|
||||
|
||||
it("parses /skill command with name only", () => {
|
||||
const result = parseSlashCommand("/skill onboard-mentee");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("skill");
|
||||
expect(result.args).toBe("onboard-mentee");
|
||||
});
|
||||
|
||||
it("parses /skill command without arguments", () => {
|
||||
const result = parseSlashCommand("/skill");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("skill");
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("returns null for unknown command", () => {
|
||||
const result = parseSlashCommand("/unknown");
|
||||
expect(result.command).toBeNull();
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("is case insensitive for command names", () => {
|
||||
const result1 = parseSlashCommand("/CD /path");
|
||||
expect(result1.command).not.toBeNull();
|
||||
expect(result1.command!.name).toBe("cd");
|
||||
|
||||
const result2 = parseSlashCommand("/CLEAR");
|
||||
expect(result2.command).not.toBeNull();
|
||||
expect(result2.command!.name).toBe("clear");
|
||||
|
||||
const result3 = parseSlashCommand("/Help");
|
||||
expect(result3.command).not.toBeNull();
|
||||
expect(result3.command!.name).toBe("help");
|
||||
});
|
||||
|
||||
it("handles leading whitespace", () => {
|
||||
const result = parseSlashCommand(" /cd /path");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("cd");
|
||||
expect(result.args).toBe("/path");
|
||||
});
|
||||
|
||||
it("handles trailing whitespace", () => {
|
||||
const result = parseSlashCommand("/cd /path ");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("cd");
|
||||
expect(result.args).toBe("/path");
|
||||
});
|
||||
|
||||
it("handles multiple spaces between args", () => {
|
||||
const result = parseSlashCommand("/search hello world");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("search");
|
||||
expect(result.args).toBe("hello world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMatchingCommands", () => {
|
||||
it("returns empty array for non-slash input", () => {
|
||||
const result = getMatchingCommands("hello");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty string", () => {
|
||||
const result = getMatchingCommands("");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all commands for just slash", () => {
|
||||
const result = getMatchingCommands("/");
|
||||
expect(result.length).toBe(slashCommands.length);
|
||||
});
|
||||
|
||||
it("returns matching commands for partial input", () => {
|
||||
const result = getMatchingCommands("/c");
|
||||
const names = result.map((cmd) => cmd.name);
|
||||
expect(names).toContain("cd");
|
||||
expect(names).toContain("clear");
|
||||
expect(names).not.toContain("help");
|
||||
});
|
||||
|
||||
it("returns single command for exact match", () => {
|
||||
const result = getMatchingCommands("/cd");
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].name).toBe("cd");
|
||||
});
|
||||
|
||||
it("returns single command for partial unique match", () => {
|
||||
const result = getMatchingCommands("/cl");
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].name).toBe("clear");
|
||||
});
|
||||
|
||||
it("returns matching commands for /s prefix", () => {
|
||||
const result = getMatchingCommands("/s");
|
||||
const names = result.map((cmd) => cmd.name);
|
||||
expect(names).toContain("search");
|
||||
expect(names).toContain("summarise");
|
||||
expect(names).toContain("skill");
|
||||
});
|
||||
|
||||
it("is case insensitive", () => {
|
||||
const result1 = getMatchingCommands("/C");
|
||||
const result2 = getMatchingCommands("/c");
|
||||
expect(result1.length).toBe(result2.length);
|
||||
});
|
||||
|
||||
it("returns empty array for no matches", () => {
|
||||
const result = getMatchingCommands("/xyz");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles whitespace correctly", () => {
|
||||
const result = getMatchingCommands(" /c");
|
||||
const names = result.map((cmd) => cmd.name);
|
||||
expect(names).toContain("cd");
|
||||
expect(names).toContain("clear");
|
||||
});
|
||||
|
||||
it("returns command for full command name", () => {
|
||||
const result = getMatchingCommands("/help");
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].name).toBe("help");
|
||||
});
|
||||
|
||||
it("returns command for /new", () => {
|
||||
const result = getMatchingCommands("/n");
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].name).toBe("new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSlashCommand", () => {
|
||||
it("returns true for input starting with slash", () => {
|
||||
expect(isSlashCommand("/cd")).toBe(true);
|
||||
expect(isSlashCommand("/")).toBe(true);
|
||||
expect(isSlashCommand("/help")).toBe(true);
|
||||
expect(isSlashCommand("/unknown")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-slash input", () => {
|
||||
expect(isSlashCommand("hello")).toBe(false);
|
||||
expect(isSlashCommand("")).toBe(false);
|
||||
expect(isSlashCommand("cd")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles whitespace correctly", () => {
|
||||
expect(isSlashCommand(" /cd")).toBe(true);
|
||||
expect(isSlashCommand(" hello")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for slash in middle of string", () => {
|
||||
expect(isSlashCommand("hello/world")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SlashCommand interface", () => {
|
||||
it("can create a valid slash command object", () => {
|
||||
const testCommand: SlashCommand = {
|
||||
name: "test",
|
||||
description: "A test command",
|
||||
usage: "/test [arg]",
|
||||
execute: vi.fn(),
|
||||
};
|
||||
|
||||
expect(testCommand.name).toBe("test");
|
||||
expect(testCommand.description).toBe("A test command");
|
||||
expect(testCommand.usage).toBe("/test [arg]");
|
||||
expect(typeof testCommand.execute).toBe("function");
|
||||
});
|
||||
|
||||
it("execute can be async function", () => {
|
||||
const asyncCommand: SlashCommand = {
|
||||
name: "async",
|
||||
description: "An async command",
|
||||
usage: "/async",
|
||||
execute: async () => {
|
||||
await Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
expect(asyncCommand.execute("")).toBeInstanceOf(Promise);
|
||||
});
|
||||
|
||||
it("execute can be sync function", () => {
|
||||
const syncCommand: SlashCommand = {
|
||||
name: "sync",
|
||||
description: "A sync command",
|
||||
usage: "/sync",
|
||||
execute: () => {
|
||||
// Synchronous execution
|
||||
},
|
||||
};
|
||||
|
||||
const result = syncCommand.execute("");
|
||||
// Sync function returns undefined, not a Promise
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import { get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { setSkipNextGreeting } from "$lib/tauri";
|
||||
import { searchState } from "$lib/stores/search";
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
usage: string;
|
||||
execute: (args: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
async function changeDirectory(path: string): Promise<void> {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
claudeStore.addLine("error", "No active conversation");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!path.trim()) {
|
||||
const currentDir = get(claudeStore.currentWorkingDirectory);
|
||||
claudeStore.addLine("system", `Current directory: ${currentDir}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
characterState.setState("thinking");
|
||||
claudeStore.addLine("system", `Changing directory to: ${path}`);
|
||||
|
||||
const currentDir = get(claudeStore.currentWorkingDirectory);
|
||||
const validatedPath = await invoke<string>("validate_directory", { path, currentDir });
|
||||
|
||||
// Capture conversation history before disconnecting
|
||||
const conversationHistory = claudeStore.getConversationHistory();
|
||||
|
||||
await invoke("stop_claude", { conversationId });
|
||||
|
||||
// Wait for clean shutdown
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
claudeStore.setWorkingDirectory(validatedPath);
|
||||
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: validatedPath,
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for connection to establish
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Restore context if there was conversation history
|
||||
if (conversationHistory) {
|
||||
const contextMessage = `[CONTEXT RESTORATION]
|
||||
I just changed the working directory from ${currentDir} to ${validatedPath}. Here's our conversation so far:
|
||||
|
||||
${conversationHistory}
|
||||
|
||||
Please continue where we left off. You are now operating in the new directory.`;
|
||||
|
||||
await invoke("send_prompt", {
|
||||
conversationId,
|
||||
message: contextMessage,
|
||||
});
|
||||
}
|
||||
|
||||
claudeStore.addLine("system", `Changed directory to: ${validatedPath}`);
|
||||
characterState.setState("idle");
|
||||
} catch (error) {
|
||||
claudeStore.addLine("error", `Failed to change directory: ${error}`);
|
||||
characterState.setTemporaryState("error", 3000);
|
||||
}
|
||||
}
|
||||
|
||||
async function startNewConversation(): Promise<void> {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
claudeStore.addLine("error", "No active conversation");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const workingDir = await invoke<string>("get_working_directory", {
|
||||
conversationId,
|
||||
});
|
||||
|
||||
claudeStore.addLine("system", "Starting new conversation...");
|
||||
characterState.setState("thinking");
|
||||
|
||||
await invoke("interrupt_claude", { conversationId });
|
||||
|
||||
claudeStore.clearTerminal();
|
||||
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDir,
|
||||
},
|
||||
});
|
||||
|
||||
claudeStore.addLine("system", "New conversation started!");
|
||||
characterState.setState("idle");
|
||||
} catch (error) {
|
||||
claudeStore.addLine("error", `Failed to start new conversation: ${error}`);
|
||||
characterState.setTemporaryState("error", 3000);
|
||||
}
|
||||
}
|
||||
|
||||
export const slashCommands: SlashCommand[] = [
|
||||
{
|
||||
name: "cd",
|
||||
description: "Change the working directory",
|
||||
usage: "/cd <path>",
|
||||
execute: changeDirectory,
|
||||
},
|
||||
{
|
||||
name: "clear",
|
||||
description: "Clear the terminal display (keeps conversation context)",
|
||||
usage: "/clear",
|
||||
execute: () => {
|
||||
claudeStore.clearTerminal();
|
||||
claudeStore.addLine("system", "Terminal cleared");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "new",
|
||||
description: "Start a fresh conversation (resets context)",
|
||||
usage: "/new",
|
||||
execute: startNewConversation,
|
||||
},
|
||||
{
|
||||
name: "help",
|
||||
description: "Show available slash commands",
|
||||
usage: "/help",
|
||||
execute: () => {
|
||||
const helpText = slashCommands
|
||||
.map((cmd) => ` ${cmd.usage.padEnd(12)} - ${cmd.description}`)
|
||||
.join("\n");
|
||||
claudeStore.addLine("system", `Available commands:\n${helpText}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "search",
|
||||
description: "Search within the conversation (use /search to clear)",
|
||||
usage: "/search [query]",
|
||||
execute: (args: string) => {
|
||||
if (!args.trim()) {
|
||||
searchState.clear();
|
||||
claudeStore.addLine("system", "Search cleared");
|
||||
return;
|
||||
}
|
||||
searchState.setQuery(args.trim());
|
||||
claudeStore.addLine("system", `Searching for: "${args.trim()}"`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "summarise",
|
||||
description: "Get a summary of the entire conversation",
|
||||
usage: "/summarise",
|
||||
execute: async () => {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
claudeStore.addLine("error", "No active conversation");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
claudeStore.addLine("system", "Requesting conversation summary...");
|
||||
await invoke("send_prompt", {
|
||||
conversationId,
|
||||
message:
|
||||
"Please provide a comprehensive summary of our entire conversation so far, including the key topics we've discussed, decisions made, and any important context.",
|
||||
});
|
||||
} catch (error) {
|
||||
claudeStore.addLine("error", `Failed to request summary: ${error}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skill",
|
||||
description: "Invoke a Claude Code skill from ~/.claude/skills/",
|
||||
usage: "/skill [name] [data]",
|
||||
execute: async (args: string) => {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
claudeStore.addLine("error", "No active conversation");
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = args.trim().split(/\s+/);
|
||||
const skillName = parts[0];
|
||||
const skillData = parts.slice(1).join(" ");
|
||||
|
||||
// If no skill name provided, list available skills
|
||||
if (!skillName) {
|
||||
try {
|
||||
const skills = await invoke<string[]>("list_skills");
|
||||
if (skills.length === 0) {
|
||||
claudeStore.addLine(
|
||||
"system",
|
||||
"No skills found in ~/.claude/skills/\nCreate a skill by adding a folder with a SKILL.md file."
|
||||
);
|
||||
} else {
|
||||
const skillList = skills.map((s) => ` • ${s}`).join("\n");
|
||||
claudeStore.addLine(
|
||||
"system",
|
||||
`Available skills:\n${skillList}\n\nUsage: /skill <skill-name> [data]`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
claudeStore.addLine("error", `Failed to list skills: ${error}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
claudeStore.addLine("system", `Invoking skill: ${skillName}`);
|
||||
characterState.setState("thinking");
|
||||
|
||||
const message = skillData
|
||||
? `Please run the /${skillName} skill with the following data:\n\n${skillData}`
|
||||
: `Please run the /${skillName} skill.`;
|
||||
|
||||
await invoke("send_prompt", {
|
||||
conversationId,
|
||||
message,
|
||||
});
|
||||
} catch (error) {
|
||||
claudeStore.addLine("error", `Failed to invoke skill: ${error}`);
|
||||
characterState.setTemporaryState("error", 3000);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function parseSlashCommand(input: string): {
|
||||
command: SlashCommand | null;
|
||||
args: string;
|
||||
} {
|
||||
const trimmed = input.trim();
|
||||
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return { command: null, args: "" };
|
||||
}
|
||||
|
||||
const parts = trimmed.slice(1).split(/\s+/);
|
||||
const commandName = parts[0]?.toLowerCase();
|
||||
const args = parts.slice(1).join(" ");
|
||||
|
||||
const command = slashCommands.find((cmd) => cmd.name.toLowerCase() === commandName);
|
||||
|
||||
return { command: command || null, args };
|
||||
}
|
||||
|
||||
export function getMatchingCommands(input: string): SlashCommand[] {
|
||||
const trimmed = input.trim();
|
||||
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const partial = trimmed.slice(1).toLowerCase();
|
||||
|
||||
if (partial === "") {
|
||||
return slashCommands;
|
||||
}
|
||||
|
||||
return slashCommands.filter((cmd) => cmd.name.toLowerCase().startsWith(partial));
|
||||
}
|
||||
|
||||
export function isSlashCommand(input: string): boolean {
|
||||
return input.trim().startsWith("/");
|
||||
}
|
||||
@@ -40,10 +40,12 @@
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 id="about-title" class="text-xl font-semibold text-gray-100">About Hikari Desktop</h2>
|
||||
<h2 id="about-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
About Hikari Desktop
|
||||
</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-gray-500 hover:text-gray-300 transition-colors"
|
||||
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">
|
||||
@@ -59,16 +61,16 @@
|
||||
|
||||
<div class="space-y-4 text-sm">
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-200 mb-2">What is Hikari Desktop?</h3>
|
||||
<p class="text-gray-400">
|
||||
<h3 class="font-medium text-[var(--text-primary)] mb-2">What is Hikari Desktop?</h3>
|
||||
<p class="text-[var(--text-secondary)]">
|
||||
Hikari Desktop is an AI-powered desktop assistant that brings Claude directly to your
|
||||
desktop. Built with love using Tauri, Svelte, and Rust for a fast, native experience.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-200 mb-2">Version</h3>
|
||||
<p class="text-gray-400 mb-1">
|
||||
<h3 class="font-medium text-[var(--text-primary)] mb-2">Version</h3>
|
||||
<p class="text-[var(--text-secondary)] mb-1">
|
||||
{appVersion || "Loading..."}
|
||||
</p>
|
||||
<button
|
||||
@@ -80,7 +82,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-200 mb-2">Source Code</h3>
|
||||
<h3 class="font-medium text-[var(--text-primary)] mb-2">Source Code</h3>
|
||||
<button
|
||||
onclick={() => openUrl(links.source)}
|
||||
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
|
||||
@@ -90,8 +92,8 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-200 mb-2">Support & Community</h3>
|
||||
<p class="text-gray-400 mb-1">Found a bug or have a suggestion?</p>
|
||||
<h3 class="font-medium text-[var(--text-primary)] mb-2">Support & Community</h3>
|
||||
<p class="text-[var(--text-secondary)] mb-1">Found a bug or have a suggestion?</p>
|
||||
<button
|
||||
onclick={() => openUrl(links.discord)}
|
||||
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
|
||||
@@ -101,7 +103,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-200 mb-2">Built with 💕 by</h3>
|
||||
<h3 class="font-medium text-[var(--text-primary)] mb-2">Built with 💕 by</h3>
|
||||
<button
|
||||
onclick={() => openUrl(links.website)}
|
||||
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
|
||||
@@ -111,8 +113,8 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-200 mb-2">License</h3>
|
||||
<p class="text-gray-400 mb-1">
|
||||
<h3 class="font-medium text-[var(--text-primary)] mb-2">License</h3>
|
||||
<p class="text-[var(--text-secondary)] mb-1">
|
||||
This project is open source and available under our license terms.
|
||||
</p>
|
||||
<button
|
||||
@@ -124,7 +126,7 @@
|
||||
</div>
|
||||
|
||||
<div class="pt-4 mt-4 border-t border-[var(--border-color)]">
|
||||
<p class="text-xs text-gray-500 text-center">
|
||||
<p class="text-xs text-[var(--text-tertiary)] text-center">
|
||||
Copyright © {new Date().getFullYear()} Naomi Carrigan. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -34,53 +34,34 @@
|
||||
return "animate-idle";
|
||||
}
|
||||
}
|
||||
|
||||
function getBackgroundGlow(): string {
|
||||
switch (currentState) {
|
||||
case "thinking":
|
||||
return "shadow-thinking";
|
||||
case "typing":
|
||||
return "shadow-typing";
|
||||
case "searching":
|
||||
return "shadow-searching";
|
||||
case "coding":
|
||||
return "shadow-coding";
|
||||
case "mcp":
|
||||
return "shadow-mcp";
|
||||
case "success":
|
||||
return "shadow-success";
|
||||
case "error":
|
||||
return "shadow-error";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="anime-girl-container flex flex-col items-center justify-end h-full p-4">
|
||||
<div class="character-frame relative {getBackgroundGlow()} w-full max-w-md">
|
||||
<div class="sprite-container {getAnimationClass()}">
|
||||
<div
|
||||
class="anime-girl-container flex flex-col items-center justify-between h-full p-4 overflow-hidden"
|
||||
>
|
||||
<div class="character-frame relative flex-1 flex items-center justify-center min-h-0">
|
||||
<div class="sprite-container {getAnimationClass()} h-full flex items-center justify-center">
|
||||
<img
|
||||
src="/sprites/{info.spriteFile}"
|
||||
alt="Hikari - {info.label}"
|
||||
class="character-sprite w-full h-auto object-contain"
|
||||
class="character-sprite h-full w-auto max-w-full object-contain"
|
||||
onerror={(e) => {
|
||||
const target = e.currentTarget as HTMLImageElement;
|
||||
target.src = "/sprites/placeholder.svg";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="state-indicator absolute -bottom-2 left-1/2 transform -translate-x-1/2">
|
||||
<div
|
||||
class="px-3 py-1 rounded-full text-xs font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[var(--accent-primary)]"
|
||||
>
|
||||
{info.label}
|
||||
</div>
|
||||
<div class="state-indicator mt-2">
|
||||
<div
|
||||
class="px-3 py-1 rounded-full text-xs font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[var(--accent-primary)]"
|
||||
>
|
||||
{info.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="speech-bubble mt-4 max-w-xs">
|
||||
<div class="speech-bubble mt-2 max-w-xs flex-shrink-0">
|
||||
<div
|
||||
class="relative bg-[var(--bg-secondary)] rounded-lg px-4 py-2 border border-[var(--border-color)]"
|
||||
>
|
||||
@@ -93,37 +74,12 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.anime-girl-container {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.character-frame {
|
||||
border-radius: 50%;
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.shadow-thinking {
|
||||
box-shadow: 0 0 30px rgba(147, 51, 234, 0.5);
|
||||
}
|
||||
|
||||
.shadow-typing {
|
||||
box-shadow: 0 0 30px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.shadow-searching {
|
||||
box-shadow: 0 0 30px rgba(234, 179, 8, 0.5);
|
||||
}
|
||||
|
||||
.shadow-coding {
|
||||
box-shadow: 0 0 30px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.shadow-mcp {
|
||||
box-shadow: 0 0 30px rgba(236, 72, 153, 0.5);
|
||||
}
|
||||
|
||||
.shadow-success {
|
||||
box-shadow: 0 0 30px rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
|
||||
.shadow-error {
|
||||
box-shadow: 0 0 30px rgba(239, 68, 68, 0.5);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes idle-bob {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<script lang="ts">
|
||||
import type { Attachment } from "$lib/types/messages";
|
||||
|
||||
interface Props {
|
||||
attachments: Attachment[];
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
let { attachments, onRemove }: Props = $props();
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function getFileIcon(type: Attachment["type"]): string {
|
||||
switch (type) {
|
||||
case "image":
|
||||
return "🖼️";
|
||||
case "document":
|
||||
return "📄";
|
||||
default:
|
||||
return "📎";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if attachments.length > 0}
|
||||
<div class="attachment-preview-container">
|
||||
<div class="attachment-header">
|
||||
<span class="attachment-count"
|
||||
>{attachments.length} attachment{attachments.length !== 1 ? "s" : ""}</span
|
||||
>
|
||||
</div>
|
||||
<div class="attachment-list">
|
||||
{#each attachments as attachment (attachment.id)}
|
||||
<div class="attachment-item" class:is-image={attachment.type === "image"}>
|
||||
{#if attachment.type === "image" && attachment.previewUrl}
|
||||
<div class="image-preview">
|
||||
<img src={attachment.previewUrl} alt={attachment.filename} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="file-icon">
|
||||
{getFileIcon(attachment.type)}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="attachment-info">
|
||||
<span class="attachment-filename" title={attachment.filename}>
|
||||
{attachment.filename}
|
||||
</span>
|
||||
<span class="attachment-size">
|
||||
{formatFileSize(attachment.size)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="remove-button"
|
||||
onclick={() => onRemove(attachment.id)}
|
||||
title="Remove attachment"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.attachment-preview-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.attachment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attachment-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.attachment-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
max-width: 200px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.attachment-item.is-image {
|
||||
flex-direction: column;
|
||||
padding: 4px;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 100%;
|
||||
max-width: 110px;
|
||||
max-height: 80px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 80px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.attachment-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.is-image .attachment-info {
|
||||
width: 100%;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.attachment-filename {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.attachment-size {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 50%;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
background 0.2s,
|
||||
color 0.2s;
|
||||
}
|
||||
|
||||
.attachment-item:hover .remove-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.remove-button:hover {
|
||||
background: var(--error-color, #ef4444);
|
||||
border-color: var(--error-color, #ef4444);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,497 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { clipboardStore, type ClipboardEntry } from "$lib/stores/clipboard";
|
||||
|
||||
export let isOpen = false;
|
||||
export let onClose: () => void;
|
||||
export let onInsert: (content: string) => void = () => {};
|
||||
|
||||
let searchQuery = "";
|
||||
let selectedLanguage: string | null = null;
|
||||
let confirmingDeleteId: string | null = null;
|
||||
let copiedId: string | null = null;
|
||||
|
||||
// Subscribe to derived stores
|
||||
const filteredEntries = clipboardStore.filteredEntries;
|
||||
const languagesStore = clipboardStore.languages;
|
||||
const isLoadingStore = clipboardStore.isLoading;
|
||||
|
||||
$: entries = $filteredEntries;
|
||||
$: languages = $languagesStore;
|
||||
$: isLoading = $isLoadingStore;
|
||||
|
||||
onMount(() => {
|
||||
if (isOpen) {
|
||||
clipboardStore.loadEntries();
|
||||
}
|
||||
});
|
||||
|
||||
$: if (isOpen) {
|
||||
clipboardStore.loadEntries();
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
clipboardStore.setSearchQuery(searchQuery);
|
||||
}
|
||||
|
||||
function handleLanguageFilter(lang: string | null) {
|
||||
selectedLanguage = lang;
|
||||
clipboardStore.setLanguageFilter(lang);
|
||||
}
|
||||
|
||||
async function handleCopy(entry: ClipboardEntry) {
|
||||
const success = await clipboardStore.copyToClipboard(entry.content);
|
||||
if (success) {
|
||||
copiedId = entry.id;
|
||||
setTimeout(() => {
|
||||
copiedId = null;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleInsert(entry: ClipboardEntry) {
|
||||
onInsert(entry.content);
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
await clipboardStore.deleteEntry(id);
|
||||
confirmingDeleteId = null;
|
||||
}
|
||||
|
||||
async function handleTogglePin(id: string) {
|
||||
await clipboardStore.togglePin(id);
|
||||
}
|
||||
|
||||
async function handleClearHistory() {
|
||||
if (confirm("Clear all non-pinned clipboard entries?")) {
|
||||
await clipboardStore.clearHistory();
|
||||
}
|
||||
}
|
||||
|
||||
function truncateContent(content: string, maxLength: number = 200): string {
|
||||
if (content.length <= maxLength) return content;
|
||||
return content.substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
function getLanguageIcon(language: string | null): string {
|
||||
const icons: Record<string, string> = {
|
||||
typescript: "TS",
|
||||
javascript: "JS",
|
||||
python: "PY",
|
||||
rust: "RS",
|
||||
go: "GO",
|
||||
java: "JV",
|
||||
c: "C",
|
||||
cpp: "C++",
|
||||
csharp: "C#",
|
||||
php: "PHP",
|
||||
ruby: "RB",
|
||||
swift: "SW",
|
||||
kotlin: "KT",
|
||||
sql: "SQL",
|
||||
html: "HTML",
|
||||
css: "CSS",
|
||||
json: "JSON",
|
||||
yaml: "YAML",
|
||||
bash: "SH",
|
||||
shell: "SH",
|
||||
};
|
||||
return language ? icons[language.toLowerCase()] || language.toUpperCase().slice(0, 3) : "TXT";
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="clipboard-overlay" on:click={onClose}>
|
||||
<div class="clipboard-panel" on:click|stopPropagation>
|
||||
<div class="clipboard-header">
|
||||
<h2>📋 Clipboard History</h2>
|
||||
<div class="header-actions">
|
||||
{#if entries.length > 0}
|
||||
<button
|
||||
class="clear-btn"
|
||||
on:click={handleClearHistory}
|
||||
title="Clear non-pinned entries"
|
||||
>
|
||||
🗑️ Clear
|
||||
</button>
|
||||
{/if}
|
||||
<button class="close-btn" on:click={onClose}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clipboard-controls">
|
||||
<div class="search-box">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search clipboard..."
|
||||
bind:value={searchQuery}
|
||||
on:input={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
<div class="language-filter">
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:active={selectedLanguage === null}
|
||||
on:click={() => handleLanguageFilter(null)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{#each languages as lang (lang)}
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:active={selectedLanguage === lang}
|
||||
on:click={() => handleLanguageFilter(lang)}
|
||||
>
|
||||
{getLanguageIcon(lang)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clipboard-content">
|
||||
{#if isLoading}
|
||||
<div class="loading">Loading...</div>
|
||||
{:else if entries.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>📭 No clipboard entries yet</p>
|
||||
<p class="hint">
|
||||
Copy code from Claude's responses or use the copy button on code blocks to save them
|
||||
here.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="entries-list">
|
||||
{#each entries as entry (entry.id)}
|
||||
<div class="entry" class:pinned={entry.is_pinned}>
|
||||
<div class="entry-header">
|
||||
<div class="entry-meta">
|
||||
<span class="language-badge">{getLanguageIcon(entry.language)}</span>
|
||||
<span class="timestamp">{clipboardStore.formatTimestamp(entry.timestamp)}</span>
|
||||
{#if entry.is_pinned}
|
||||
<span class="pin-badge">📌</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="entry-actions">
|
||||
<button
|
||||
class="action-btn"
|
||||
title={entry.is_pinned ? "Unpin" : "Pin"}
|
||||
on:click={() => handleTogglePin(entry.id)}
|
||||
>
|
||||
{entry.is_pinned ? "📌" : "📍"}
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
title="Copy to clipboard"
|
||||
on:click={() => handleCopy(entry)}
|
||||
>
|
||||
{copiedId === entry.id ? "✓" : "📋"}
|
||||
</button>
|
||||
<button
|
||||
class="action-btn insert-btn"
|
||||
title="Insert"
|
||||
on:click={() => handleInsert(entry)}
|
||||
>
|
||||
➡️
|
||||
</button>
|
||||
{#if confirmingDeleteId === entry.id}
|
||||
<button
|
||||
class="action-btn confirm-delete"
|
||||
on:click={() => handleDelete(entry.id)}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button class="action-btn" on:click={() => (confirmingDeleteId = null)}>
|
||||
✕
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="action-btn delete-btn"
|
||||
title="Delete"
|
||||
on:click={() => (confirmingDeleteId = entry.id)}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<pre class="entry-content"><code>{truncateContent(entry.content)}</code></pre>
|
||||
{#if entry.source}
|
||||
<div class="entry-source">From: {entry.source}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.clipboard-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.clipboard-panel {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 700px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.clipboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.clipboard-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.clipboard-controls {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
outline: none;
|
||||
border-color: var(--trans-pink);
|
||||
box-shadow: 0 0 0 2px rgba(245, 169, 184, 0.2);
|
||||
}
|
||||
|
||||
.language-filter {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--trans-gradient-vibrant);
|
||||
color: #1a1a2e;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.clipboard-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.empty-state .hint {
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.entries-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.entry:hover {
|
||||
border-color: var(--trans-pink);
|
||||
}
|
||||
|
||||
.entry.pinned {
|
||||
border-color: var(--trans-blue);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(91, 206, 250, 0.05) 0%,
|
||||
rgba(245, 169, 184, 0.05) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.entry-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.entry-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.language-badge {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.pin-badge {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.entry-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.insert-btn {
|
||||
background: var(--trans-gradient-vibrant);
|
||||
border-radius: 4px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.confirm-delete {
|
||||
color: #ff6b6b;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.entry-content {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.entry-content code {
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.entry-source {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
tabName: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const { isOpen, tabName, onConfirm, onCancel }: Props = $props();
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
onConfirm();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onCancel}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === " " && onCancel()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-md w-full"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="confirm-title"
|
||||
aria-describedby="confirm-message"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-yellow-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 id="confirm-title" class="text-lg font-semibold text-[var(--text-primary)] mb-1">
|
||||
Close Connected Tab?
|
||||
</h3>
|
||||
<p id="confirm-message" class="text-sm text-[var(--text-secondary)]">
|
||||
The tab "{tabName}" is currently connected to Claude. Are you sure you want to close
|
||||
it? This will disconnect the session.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6 justify-end">
|
||||
<button
|
||||
onclick={onCancel}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-300 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={onConfirm}
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
|
||||
>
|
||||
Close Tab
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,563 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||
import { characterState, characterInfo } from "$lib/stores/character";
|
||||
import { isStreamerMode } from "$lib/stores/config";
|
||||
import { handleNewUserMessage } from "$lib/notifications/rules";
|
||||
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
|
||||
|
||||
interface Props {
|
||||
onExpand: () => void;
|
||||
}
|
||||
|
||||
let { onExpand }: Props = $props();
|
||||
|
||||
let inputValue = $state("");
|
||||
let isSubmitting = $state(false);
|
||||
let isConnected = $state(false);
|
||||
let isProcessing = $state(false);
|
||||
let streamerModeActive = $state(false);
|
||||
let currentState: CharacterState = $state("idle");
|
||||
let info: CharacterStateInfo = $state({
|
||||
state: "idle",
|
||||
label: "Ready",
|
||||
description: "Waiting for your command~",
|
||||
spriteFile: "idle.png",
|
||||
});
|
||||
|
||||
// Recent messages for compact display
|
||||
let recentMessages = $state<Array<{ type: string; content: string }>>([]);
|
||||
const MAX_RECENT_MESSAGES = 3;
|
||||
|
||||
claudeStore.connectionStatus.subscribe((status) => {
|
||||
isConnected = status === "connected";
|
||||
});
|
||||
|
||||
isClaudeProcessing.subscribe((processing) => {
|
||||
isProcessing = processing;
|
||||
});
|
||||
|
||||
isStreamerMode.subscribe((value) => {
|
||||
streamerModeActive = value;
|
||||
});
|
||||
|
||||
characterState.subscribe((state) => {
|
||||
currentState = state;
|
||||
});
|
||||
|
||||
characterInfo.subscribe((i) => {
|
||||
info = i;
|
||||
});
|
||||
|
||||
// Track recent terminal output
|
||||
claudeStore.terminalLines.subscribe((lines) => {
|
||||
const recent = lines.slice(-MAX_RECENT_MESSAGES).map((line) => ({
|
||||
type: line.type,
|
||||
content: line.content.substring(0, 100) + (line.content.length > 100 ? "..." : ""),
|
||||
}));
|
||||
recentMessages = recent;
|
||||
});
|
||||
|
||||
function getAnimationClass(): string {
|
||||
switch (currentState) {
|
||||
case "thinking":
|
||||
return "animate-thinking";
|
||||
case "typing":
|
||||
return "animate-typing";
|
||||
case "searching":
|
||||
return "animate-searching";
|
||||
case "success":
|
||||
return "animate-celebrate";
|
||||
case "error":
|
||||
return "animate-shake";
|
||||
default:
|
||||
return "animate-idle";
|
||||
}
|
||||
}
|
||||
|
||||
function getStateGlow(): string {
|
||||
switch (currentState) {
|
||||
case "thinking":
|
||||
return "glow-thinking";
|
||||
case "typing":
|
||||
return "glow-typing";
|
||||
case "success":
|
||||
return "glow-success";
|
||||
case "error":
|
||||
return "glow-error";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
const message = inputValue.trim();
|
||||
if (!message || isSubmitting || !isConnected) return;
|
||||
|
||||
isSubmitting = true;
|
||||
inputValue = "";
|
||||
|
||||
handleNewUserMessage();
|
||||
claudeStore.addLine("user", message);
|
||||
characterState.setState("thinking");
|
||||
|
||||
try {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
await invoke("send_prompt", {
|
||||
conversationId,
|
||||
message,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send prompt:", error);
|
||||
claudeStore.addLine("error", `Failed to send: ${error}`);
|
||||
characterState.setTemporaryState("error", 3000);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInterrupt() {
|
||||
try {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) return;
|
||||
|
||||
await invoke("interrupt_claude", { conversationId });
|
||||
claudeStore.addLine("system", "Interrupted");
|
||||
characterState.setState("idle");
|
||||
} catch (error) {
|
||||
console.error("Failed to interrupt:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
handleSubmit(event);
|
||||
}
|
||||
// Escape expands to full mode
|
||||
if (event.key === "Escape") {
|
||||
onExpand();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="compact-container {getStateGlow()}">
|
||||
<!-- Character sprite (smaller) -->
|
||||
<div class="compact-character">
|
||||
<div class="sprite-wrapper {getAnimationClass()}">
|
||||
<img
|
||||
src="/sprites/{info.spriteFile}"
|
||||
alt="Hikari - {info.label}"
|
||||
class="compact-sprite"
|
||||
onerror={(e) => {
|
||||
const target = e.currentTarget as HTMLImageElement;
|
||||
target.src = "/sprites/placeholder.svg";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="state-badge">{info.label}</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent message preview -->
|
||||
<div class="message-preview">
|
||||
{#if recentMessages.length > 0}
|
||||
{#each recentMessages.slice(-1) as msg (msg.content)}
|
||||
<div class="preview-message {msg.type}">
|
||||
{msg.content}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="preview-message system">Ask me anything~</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Compact input -->
|
||||
<form onsubmit={handleSubmit} class="compact-input-form">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={inputValue}
|
||||
onkeydown={handleKeyDown}
|
||||
placeholder={isConnected ? "Quick message..." : "Not connected"}
|
||||
disabled={isSubmitting || !isConnected}
|
||||
class="compact-input"
|
||||
/>
|
||||
|
||||
<div class="compact-buttons">
|
||||
{#if isProcessing}
|
||||
<button type="button" onclick={handleInterrupt} class="compact-btn stop-btn" title="Stop">
|
||||
■
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isConnected || isSubmitting || !inputValue.trim()}
|
||||
class="compact-btn send-btn"
|
||||
title="Send"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button type="button" onclick={onExpand} class="compact-btn expand-btn" title="Expand (Esc)">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<polyline points="9 21 3 21 3 15"></polyline>
|
||||
<line x1="21" y1="3" x2="14" y2="10"></line>
|
||||
<line x1="3" y1="21" x2="10" y2="14"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Streamer mode indicator -->
|
||||
{#if streamerModeActive}
|
||||
<div class="compact-live-indicator" title="Streamer mode active"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.compact-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%);
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.compact-container::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
pointer-events: none;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.glow-thinking {
|
||||
box-shadow: inset 0 0 30px rgba(147, 51, 234, 0.15);
|
||||
}
|
||||
|
||||
.glow-thinking::before {
|
||||
background: linear-gradient(180deg, #9333ea, var(--trans-blue));
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.glow-typing {
|
||||
box-shadow: inset 0 0 30px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.glow-typing::before {
|
||||
background: linear-gradient(180deg, #3b82f6, var(--trans-pink));
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.glow-success {
|
||||
box-shadow: inset 0 0 30px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.glow-success::before {
|
||||
background: linear-gradient(180deg, #10b981, var(--trans-blue));
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.glow-error {
|
||||
box-shadow: inset 0 0 30px rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.glow-error::before {
|
||||
background: linear-gradient(180deg, #ef4444, var(--trans-pink));
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.compact-character {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sprite-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.compact-sprite {
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.state-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.message-preview {
|
||||
min-height: 24px;
|
||||
max-height: 48px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-message {
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.preview-message.user {
|
||||
color: var(--trans-pink);
|
||||
}
|
||||
|
||||
.preview-message.assistant {
|
||||
color: var(--trans-blue);
|
||||
}
|
||||
|
||||
.preview-message.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.compact-input-form {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.compact-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.compact-input:focus {
|
||||
border-color: var(--trans-blue);
|
||||
box-shadow: 0 0 0 2px rgba(91, 206, 250, 0.2);
|
||||
}
|
||||
|
||||
.compact-input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.compact-input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.compact-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.compact-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.compact-btn:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.compact-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.send-btn:not(:disabled) {
|
||||
background: var(--trans-gradient-vibrant);
|
||||
border-color: transparent;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.stop-btn {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border-color: rgb(239, 68, 68);
|
||||
color: rgb(248, 113, 113);
|
||||
}
|
||||
|
||||
.stop-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.compact-live-indicator {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgb(239, 68, 68);
|
||||
animation: pulse-live 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-live {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Character animations (smaller scale for compact) */
|
||||
@keyframes idle-bob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes thinking-sway {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-1deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(1deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typing-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-2px) scale(1.01);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes searching-look {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes celebrate {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05) rotate(3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
25%,
|
||||
75% {
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-idle {
|
||||
animation: idle-bob 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-thinking {
|
||||
animation: thinking-sway 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-typing {
|
||||
animation: typing-bounce 0.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-searching {
|
||||
animation: searching-look 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-celebrate {
|
||||
animation: celebrate 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-shake {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { configStore, type HikariConfig, type Theme } from "$lib/stores/config";
|
||||
import {
|
||||
configStore,
|
||||
type HikariConfig,
|
||||
type Theme,
|
||||
type CustomThemeColors,
|
||||
applyFontSize,
|
||||
applyCustomThemeColors,
|
||||
MIN_FONT_SIZE,
|
||||
MAX_FONT_SIZE,
|
||||
DEFAULT_FONT_SIZE,
|
||||
} from "$lib/stores/config";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
let config: HikariConfig = $state({
|
||||
model: null,
|
||||
@@ -13,8 +24,31 @@
|
||||
greeting_custom_prompt: null,
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: false,
|
||||
minimize_to_tray: false,
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: null,
|
||||
font_size: 14,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
profile_name: null,
|
||||
profile_avatar_path: null,
|
||||
profile_bio: null,
|
||||
custom_theme_colors: {
|
||||
bg_primary: null,
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
});
|
||||
|
||||
let showCustomThemeEditor = $state(false);
|
||||
|
||||
let isOpen = $state(false);
|
||||
let isSaving = $state(false);
|
||||
let saveError: string | null = $state(null);
|
||||
@@ -61,6 +95,7 @@
|
||||
saveError = null;
|
||||
try {
|
||||
await configStore.saveConfig(config);
|
||||
configStore.closeSidebar();
|
||||
} catch {
|
||||
// Error is handled by the store
|
||||
} finally {
|
||||
@@ -70,9 +105,33 @@
|
||||
|
||||
async function handleThemeChange(theme: Theme) {
|
||||
config.theme = theme;
|
||||
await configStore.setTheme(theme);
|
||||
showCustomThemeEditor = theme === "custom";
|
||||
await configStore.setTheme(theme, config.custom_theme_colors);
|
||||
}
|
||||
|
||||
function handleCustomColorChange(key: keyof CustomThemeColors, value: string) {
|
||||
config.custom_theme_colors = {
|
||||
...config.custom_theme_colors,
|
||||
[key]: value || null,
|
||||
};
|
||||
// Live preview
|
||||
if (config.theme === "custom") {
|
||||
applyCustomThemeColors(config.custom_theme_colors);
|
||||
}
|
||||
}
|
||||
|
||||
// Default dark theme colors for color picker defaults
|
||||
const defaultDarkColors: Required<Record<keyof CustomThemeColors, string>> = {
|
||||
bg_primary: "#1a1a2e",
|
||||
bg_secondary: "#16213e",
|
||||
bg_terminal: "#0f0f1a",
|
||||
accent_primary: "#e94560",
|
||||
accent_secondary: "#ff6b9d",
|
||||
text_primary: "#ffffff",
|
||||
text_secondary: "#a0a0a0",
|
||||
border_color: "#2a2a4a",
|
||||
};
|
||||
|
||||
function toggleTool(tool: string) {
|
||||
if (config.auto_granted_tools.includes(tool)) {
|
||||
config.auto_granted_tools = config.auto_granted_tools.filter((t) => t !== tool);
|
||||
@@ -95,6 +154,13 @@
|
||||
function importFromSession() {
|
||||
config.auto_granted_tools = [...new Set([...config.auto_granted_tools, ...grantedTools])];
|
||||
}
|
||||
|
||||
async function handleAlwaysOnTopChange(enabled: boolean) {
|
||||
config.always_on_top = enabled;
|
||||
const window = getCurrentWindow();
|
||||
await window.setAlwaysOnTop(enabled);
|
||||
await configStore.updateConfig({ always_on_top: enabled });
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Backdrop -->
|
||||
@@ -121,7 +187,7 @@
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Settings</h2>
|
||||
<button
|
||||
onclick={configStore.closeSidebar}
|
||||
class="p-1 text-gray-400 hover:text-white transition-colors"
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close settings"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -149,7 +215,7 @@
|
||||
|
||||
<!-- Model Selection -->
|
||||
<div class="mb-4">
|
||||
<label for="model" class="block text-sm text-gray-400 mb-1">Model</label>
|
||||
<label for="model" class="block text-sm text-[var(--text-secondary)] mb-1">Model</label>
|
||||
<select
|
||||
id="model"
|
||||
bind:value={config.model}
|
||||
@@ -163,55 +229,68 @@
|
||||
|
||||
<!-- API Key -->
|
||||
<div class="mb-4">
|
||||
<label for="api-key" class="block text-sm text-gray-400 mb-1">
|
||||
API Key <span class="text-gray-600">(optional override)</span>
|
||||
<label for="api-key" class="block text-sm text-[var(--text-secondary)] mb-1">
|
||||
API Key <span class="text-[var(--text-tertiary)]">(optional override)</span>
|
||||
{#if config.streamer_mode}
|
||||
<span class="text-yellow-500 text-xs ml-2">🔒 Hidden (Streamer Mode)</span>
|
||||
{/if}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="api-key"
|
||||
type={showApiKey ? "text" : "password"}
|
||||
bind:value={config.api_key}
|
||||
placeholder="Falls back to ~/.claude settings"
|
||||
class="w-full px-3 py-2 pr-10 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showApiKey = !showApiKey)}
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
{#if showApiKey}
|
||||
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{#if config.streamer_mode}
|
||||
<input
|
||||
id="api-key"
|
||||
type="password"
|
||||
value="••••••••••••••••••••••••"
|
||||
disabled
|
||||
class="w-full px-3 py-2 pr-10 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-tertiary)] focus:outline-none cursor-not-allowed"
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
id="api-key"
|
||||
type={showApiKey ? "text" : "password"}
|
||||
bind:value={config.api_key}
|
||||
placeholder="Falls back to ~/.claude settings"
|
||||
class="w-full px-3 py-2 pr-10 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showApiKey = !showApiKey)}
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
{#if showApiKey}
|
||||
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Instructions -->
|
||||
<div class="mb-4">
|
||||
<label for="instructions" class="block text-sm text-gray-400 mb-1"
|
||||
<label for="instructions" class="block text-sm text-[var(--text-secondary)] mb-1"
|
||||
>Custom Instructions</label
|
||||
>
|
||||
<textarea
|
||||
@@ -238,9 +317,9 @@
|
||||
bind:checked={config.greeting_enabled}
|
||||
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">Send greeting on connect</span>
|
||||
<span class="text-sm text-[var(--text-primary)]">Send greeting on connect</span>
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1 ml-7">
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||
Automatically greet you when a session starts with time-based messages
|
||||
</p>
|
||||
</div>
|
||||
@@ -248,8 +327,8 @@
|
||||
<!-- Custom Greeting Prompt -->
|
||||
{#if config.greeting_enabled}
|
||||
<div class="mb-4">
|
||||
<label for="greeting-prompt" class="block text-sm text-gray-400 mb-1">
|
||||
Custom Greeting Prompt <span class="text-gray-600">(optional)</span>
|
||||
<label for="greeting-prompt" class="block text-sm text-[var(--text-secondary)] mb-1">
|
||||
Custom Greeting Prompt <span class="text-[var(--text-tertiary)]">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="greeting-prompt"
|
||||
@@ -268,8 +347,8 @@
|
||||
MCP Servers
|
||||
</h3>
|
||||
<div class="mb-2">
|
||||
<label for="mcp-config" class="block text-sm text-gray-400 mb-1">
|
||||
Server Configuration <span class="text-gray-600">(JSON)</span>
|
||||
<label for="mcp-config" class="block text-sm text-[var(--text-secondary)] mb-1">
|
||||
Server Configuration <span class="text-[var(--text-tertiary)]">(JSON)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="mcp-config"
|
||||
@@ -286,14 +365,14 @@
|
||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||
Auto-Granted Tools
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 mb-3">
|
||||
<p class="text-xs text-[var(--text-tertiary)] mb-3">
|
||||
These tools will be pre-approved for every session (no permission prompts).
|
||||
</p>
|
||||
|
||||
<!-- Common tools checkboxes -->
|
||||
<div class="grid grid-cols-2 gap-2 mb-3">
|
||||
{#each commonTools as tool (tool)}
|
||||
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
|
||||
<label class="flex items-center gap-2 text-sm text-[var(--text-primary)] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.auto_granted_tools.includes(tool)}
|
||||
@@ -309,7 +388,7 @@
|
||||
{#if grantedTools.length > 0}
|
||||
<div class="mb-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-500">Session-granted tools:</span>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">Session-granted tools:</span>
|
||||
<button
|
||||
onclick={importFromSession}
|
||||
class="text-xs text-[var(--accent-primary)] hover:text-[var(--accent-secondary)] transition-colors"
|
||||
@@ -332,7 +411,7 @@
|
||||
<!-- Custom tools list -->
|
||||
{#if config.auto_granted_tools.filter((t) => !commonTools.includes(t)).length > 0}
|
||||
<div class="mb-3">
|
||||
<span class="text-xs text-gray-500 block mb-2">Custom tools:</span>
|
||||
<span class="text-xs text-[var(--text-tertiary)] block mb-2">Custom tools:</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each config.auto_granted_tools.filter((t) => !commonTools.includes(t)) as tool (tool)}
|
||||
<span
|
||||
@@ -341,7 +420,7 @@
|
||||
{tool}
|
||||
<button
|
||||
onclick={() => removeTool(tool)}
|
||||
class="text-gray-500 hover:text-red-400"
|
||||
class="text-[var(--text-tertiary)] hover:text-red-400"
|
||||
aria-label="Remove {tool}"
|
||||
>
|
||||
×
|
||||
@@ -364,7 +443,7 @@
|
||||
<button
|
||||
onclick={addCustomTool}
|
||||
disabled={!newToolName.trim()}
|
||||
class="px-3 py-1.5 text-sm bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)] text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="btn-trans-gradient px-3 py-1.5 text-sm rounded-lg"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
@@ -376,24 +455,327 @@
|
||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||
Appearance
|
||||
</h3>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => handleThemeChange("dark")}
|
||||
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'dark'
|
||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-gray-400 hover:border-[var(--accent-primary)]'}"
|
||||
>
|
||||
Dark
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleThemeChange("light")}
|
||||
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'light'
|
||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-gray-400 hover:border-[var(--accent-primary)]'}"
|
||||
>
|
||||
Light
|
||||
</button>
|
||||
|
||||
<!-- Theme Selection -->
|
||||
<div class="mb-4">
|
||||
<span class="block text-sm text-[var(--text-secondary)] mb-2">Theme</span>
|
||||
<div class="flex flex-wrap gap-2" role="group" aria-label="Theme selection">
|
||||
<button
|
||||
onclick={() => handleThemeChange("dark")}
|
||||
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
||||
'dark'
|
||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||
>
|
||||
Dark
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleThemeChange("light")}
|
||||
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
||||
'light'
|
||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||
>
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleThemeChange("high-contrast")}
|
||||
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
||||
'high-contrast'
|
||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||
title="High contrast mode for improved accessibility"
|
||||
>
|
||||
Contrast
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleThemeChange("custom")}
|
||||
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
||||
'custom'
|
||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||
title="Create your own custom theme"
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Theme Editor -->
|
||||
{#if config.theme === "custom" || showCustomThemeEditor}
|
||||
<div class="mb-4 p-3 bg-[var(--bg-primary)] rounded-lg border border-[var(--border-color)]">
|
||||
<h4 class="text-sm font-medium text-[var(--text-primary)] mb-3">Custom Theme Colors</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="color-input-group">
|
||||
<label class="text-xs text-[var(--text-secondary)]" for="color-bg-primary"
|
||||
>Background</label
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="color-bg-primary"
|
||||
type="color"
|
||||
value={config.custom_theme_colors.bg_primary || defaultDarkColors.bg_primary}
|
||||
oninput={(e) => handleCustomColorChange("bg_primary", e.currentTarget.value)}
|
||||
class="color-picker"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||
{config.custom_theme_colors.bg_primary || defaultDarkColors.bg_primary}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-input-group">
|
||||
<label class="text-xs text-[var(--text-secondary)]" for="color-bg-secondary"
|
||||
>Secondary BG</label
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="color-bg-secondary"
|
||||
type="color"
|
||||
value={config.custom_theme_colors.bg_secondary || defaultDarkColors.bg_secondary}
|
||||
oninput={(e) => handleCustomColorChange("bg_secondary", e.currentTarget.value)}
|
||||
class="color-picker"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||
{config.custom_theme_colors.bg_secondary || defaultDarkColors.bg_secondary}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-input-group">
|
||||
<label class="text-xs text-[var(--text-secondary)]" for="color-bg-terminal"
|
||||
>Terminal BG</label
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="color-bg-terminal"
|
||||
type="color"
|
||||
value={config.custom_theme_colors.bg_terminal || defaultDarkColors.bg_terminal}
|
||||
oninput={(e) => handleCustomColorChange("bg_terminal", e.currentTarget.value)}
|
||||
class="color-picker"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||
{config.custom_theme_colors.bg_terminal || defaultDarkColors.bg_terminal}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-input-group">
|
||||
<label class="text-xs text-[var(--text-secondary)]" for="color-border">Border</label>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="color-border"
|
||||
type="color"
|
||||
value={config.custom_theme_colors.border_color || defaultDarkColors.border_color}
|
||||
oninput={(e) => handleCustomColorChange("border_color", e.currentTarget.value)}
|
||||
class="color-picker"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||
{config.custom_theme_colors.border_color || defaultDarkColors.border_color}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-input-group">
|
||||
<label class="text-xs text-[var(--text-secondary)]" for="color-accent-primary"
|
||||
>Accent Primary</label
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="color-accent-primary"
|
||||
type="color"
|
||||
value={config.custom_theme_colors.accent_primary ||
|
||||
defaultDarkColors.accent_primary}
|
||||
oninput={(e) => handleCustomColorChange("accent_primary", e.currentTarget.value)}
|
||||
class="color-picker"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||
{config.custom_theme_colors.accent_primary || defaultDarkColors.accent_primary}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-input-group">
|
||||
<label class="text-xs text-[var(--text-secondary)]" for="color-accent-secondary"
|
||||
>Accent Secondary</label
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="color-accent-secondary"
|
||||
type="color"
|
||||
value={config.custom_theme_colors.accent_secondary ||
|
||||
defaultDarkColors.accent_secondary}
|
||||
oninput={(e) =>
|
||||
handleCustomColorChange("accent_secondary", e.currentTarget.value)}
|
||||
class="color-picker"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||
{config.custom_theme_colors.accent_secondary ||
|
||||
defaultDarkColors.accent_secondary}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-input-group">
|
||||
<label class="text-xs text-[var(--text-secondary)]" for="color-text-primary"
|
||||
>Text Primary</label
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="color-text-primary"
|
||||
type="color"
|
||||
value={config.custom_theme_colors.text_primary || defaultDarkColors.text_primary}
|
||||
oninput={(e) => handleCustomColorChange("text_primary", e.currentTarget.value)}
|
||||
class="color-picker"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||
{config.custom_theme_colors.text_primary || defaultDarkColors.text_primary}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-input-group">
|
||||
<label class="text-xs text-[var(--text-secondary)]" for="color-text-secondary"
|
||||
>Text Secondary</label
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="color-text-secondary"
|
||||
type="color"
|
||||
value={config.custom_theme_colors.text_secondary ||
|
||||
defaultDarkColors.text_secondary}
|
||||
oninput={(e) => handleCustomColorChange("text_secondary", e.currentTarget.value)}
|
||||
class="color-picker"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||
{config.custom_theme_colors.text_secondary || defaultDarkColors.text_secondary}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-3">
|
||||
Changes preview live. Click Save Settings to persist.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Font Size -->
|
||||
<div class="mb-4">
|
||||
<label for="font-size" class="block text-sm text-[var(--text-secondary)] mb-2">
|
||||
Terminal Font Size
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
id="font-size"
|
||||
type="range"
|
||||
bind:value={config.font_size}
|
||||
oninput={() => applyFontSize(config.font_size)}
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
step="1"
|
||||
class="flex-1 h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<span class="text-sm text-gray-300 w-12 text-right">{config.font_size}px</span>
|
||||
<button
|
||||
onclick={() => {
|
||||
config.font_size = DEFAULT_FONT_SIZE;
|
||||
applyFontSize(DEFAULT_FONT_SIZE);
|
||||
}}
|
||||
class="px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:border-[var(--accent-primary)] text-[var(--text-secondary)] transition-colors"
|
||||
title="Reset to default (14px)"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Window Section -->
|
||||
<section class="mb-6">
|
||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||
Window
|
||||
</h3>
|
||||
|
||||
<!-- Always on Top Toggle -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.always_on_top}
|
||||
onchange={(e) => handleAlwaysOnTopChange(e.currentTarget.checked)}
|
||||
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
|
||||
/>
|
||||
<span class="text-sm text-[var(--text-primary)]">Always on top</span>
|
||||
</label>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||
Keep the window above other windows
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Minimize to Tray Toggle -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={config.minimize_to_tray}
|
||||
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
|
||||
/>
|
||||
<span class="text-sm text-[var(--text-primary)]">Minimize to system tray</span>
|
||||
</label>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||
Hide to tray instead of closing when you click the X button
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Update Checks Toggle -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={config.update_checks_enabled}
|
||||
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
|
||||
/>
|
||||
<span class="text-sm text-[var(--text-primary)]">Check for updates on startup</span>
|
||||
</label>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||
Notify when a new version is available
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Privacy / Streamer Mode Section -->
|
||||
<section class="mb-6">
|
||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||
Privacy / Streamer Mode
|
||||
</h3>
|
||||
|
||||
<!-- Streamer Mode Toggle -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={config.streamer_mode}
|
||||
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
|
||||
/>
|
||||
<span class="text-sm text-[var(--text-primary)]">Enable streamer mode</span>
|
||||
</label>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||
Hide sensitive information like API keys when streaming (Ctrl+Shift+S to toggle)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Hide Paths Toggle -->
|
||||
{#if config.streamer_mode}
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={config.streamer_hide_paths}
|
||||
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
|
||||
/>
|
||||
<span class="text-sm text-[var(--text-primary)]">Also hide file paths</span>
|
||||
</label>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||
Mask directory paths (e.g., /home/user → /home/****)
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Notifications Section -->
|
||||
@@ -410,13 +792,13 @@
|
||||
bind:checked={config.notifications_enabled}
|
||||
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">Enable sound notifications</span>
|
||||
<span class="text-sm text-[var(--text-primary)]">Enable sound notifications</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Volume Control -->
|
||||
<div class="mb-4">
|
||||
<label for="notification-volume" class="block text-sm text-gray-400 mb-2">
|
||||
<label for="notification-volume" class="block text-sm text-[var(--text-secondary)] mb-2">
|
||||
Notification Volume
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -436,7 +818,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500">
|
||||
<div class="text-xs text-[var(--text-tertiary)]">
|
||||
Sound notifications will play when I complete tasks, encounter errors, or need permissions.
|
||||
</div>
|
||||
</section>
|
||||
@@ -446,7 +828,7 @@
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={isSaving}
|
||||
class="w-full py-3 bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)] text-white font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
class="btn-trans-gradient w-full py-3 font-medium rounded-lg"
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save Settings"}
|
||||
</button>
|
||||
@@ -483,4 +865,39 @@
|
||||
background: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Color picker styling */
|
||||
.color-input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.color-picker::-webkit-color-swatch-wrapper {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.color-picker::-webkit-color-swatch {
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.color-picker::-moz-color-swatch {
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.color-picker:hover {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,49 +3,35 @@
|
||||
import { onMount } from "svelte";
|
||||
import type { Conversation } from "$lib/stores/conversations";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import CloseTabConfirmModal from "./CloseTabConfirmModal.svelte";
|
||||
|
||||
let conversations: Map<string, Conversation> = new Map();
|
||||
let activeConversationId: string | null = null;
|
||||
let editingTabId: string | null = null;
|
||||
let editingName = "";
|
||||
// Use store subscriptions with $ syntax
|
||||
const conversations = $derived(claudeStore.conversations);
|
||||
const activeConversationId = $derived(claudeStore.activeConversationId);
|
||||
|
||||
// Track which conversation actually has the Claude connection
|
||||
let connectedConversationId: string | null = null;
|
||||
let editingTabId = $state<string | null>(null);
|
||||
let editingName = $state("");
|
||||
|
||||
// Track last seen message count for each conversation
|
||||
let lastSeenMessageCount = new SvelteMap<string, number>();
|
||||
|
||||
claudeStore.conversations.subscribe((convs) => {
|
||||
conversations = convs;
|
||||
// Confirmation modal state
|
||||
let showConfirmModal = $state(false);
|
||||
let tabToClose = $state<string | null>(null);
|
||||
let tabToCloseName = $state("");
|
||||
|
||||
// Update the last seen count for the active conversation
|
||||
if (activeConversationId) {
|
||||
const activeConv = convs.get(activeConversationId);
|
||||
// Update last seen count when active conversation changes
|
||||
$effect(() => {
|
||||
if ($activeConversationId) {
|
||||
const activeConv = $conversations.get($activeConversationId);
|
||||
if (activeConv) {
|
||||
lastSeenMessageCount.set(activeConversationId, activeConv.terminalLines.length);
|
||||
lastSeenMessageCount.set($activeConversationId, activeConv.terminalLines.length);
|
||||
// Trigger reactivity
|
||||
lastSeenMessageCount = lastSeenMessageCount;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
claudeStore.activeConversationId.subscribe((id) => {
|
||||
activeConversationId = id;
|
||||
});
|
||||
|
||||
// Find the connected conversation
|
||||
$: {
|
||||
let foundConnected = false;
|
||||
for (const [id, conv] of conversations) {
|
||||
if (conv.connectionStatus === "connected" || conv.connectionStatus === "connecting") {
|
||||
connectedConversationId = id;
|
||||
foundConnected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!foundConnected) {
|
||||
connectedConversationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function createNewTab() {
|
||||
claudeStore.createConversation();
|
||||
}
|
||||
@@ -57,7 +43,7 @@
|
||||
await claudeStore.switchConversation(id);
|
||||
|
||||
// Mark messages as seen when switching to this tab
|
||||
const conv = conversations.get(id);
|
||||
const conv = $conversations.get(id);
|
||||
if (conv) {
|
||||
lastSeenMessageCount.set(id, conv.terminalLines.length);
|
||||
// Trigger reactivity
|
||||
@@ -67,11 +53,35 @@
|
||||
|
||||
function deleteTab(id: string, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
if (conversations.size > 1) {
|
||||
claudeStore.deleteConversation(id);
|
||||
if ($conversations.size > 1) {
|
||||
const conversation = $conversations.get(id);
|
||||
if (conversation && conversation.connectionStatus === "connected") {
|
||||
// Show confirmation modal for connected tabs
|
||||
tabToClose = id;
|
||||
tabToCloseName = conversation.name;
|
||||
showConfirmModal = true;
|
||||
} else {
|
||||
// Close disconnected tabs immediately
|
||||
claudeStore.deleteConversation(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function confirmCloseTab() {
|
||||
if (tabToClose) {
|
||||
claudeStore.deleteConversation(tabToClose);
|
||||
}
|
||||
showConfirmModal = false;
|
||||
tabToClose = null;
|
||||
tabToCloseName = "";
|
||||
}
|
||||
|
||||
function cancelCloseTab() {
|
||||
showConfirmModal = false;
|
||||
tabToClose = null;
|
||||
tabToCloseName = "";
|
||||
}
|
||||
|
||||
function startEditing(id: string, name: string, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
editingTabId = id;
|
||||
@@ -105,7 +115,7 @@
|
||||
}
|
||||
|
||||
function hasUnreadMessages(id: string, conversation: Conversation): boolean {
|
||||
if (id === activeConversationId) return false; // Active tab never has unread
|
||||
if (id === $activeConversationId) return false; // Active tab never has unread
|
||||
const lastSeen = lastSeenMessageCount.get(id) || 0;
|
||||
return conversation.terminalLines.length > lastSeen;
|
||||
}
|
||||
@@ -116,6 +126,8 @@
|
||||
} else if (event.key === "Escape") {
|
||||
editingTabId = null;
|
||||
editingName = "";
|
||||
} else if (event.key === " ") {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,15 +149,24 @@
|
||||
// Ctrl/Cmd + W: Close current tab
|
||||
else if ((event.ctrlKey || event.metaKey) && event.key === "w") {
|
||||
event.preventDefault();
|
||||
if (activeConversationId && conversations.size > 1) {
|
||||
claudeStore.deleteConversation(activeConversationId);
|
||||
if ($activeConversationId && $conversations.size > 1) {
|
||||
const conversation = $conversations.get($activeConversationId);
|
||||
if (conversation && conversation.connectionStatus === "connected") {
|
||||
// Show confirmation modal for connected tabs
|
||||
tabToClose = $activeConversationId;
|
||||
tabToCloseName = conversation.name;
|
||||
showConfirmModal = true;
|
||||
} else {
|
||||
// Close disconnected tabs immediately
|
||||
claudeStore.deleteConversation($activeConversationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ctrl/Cmd + Tab: Next tab
|
||||
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const tabs = Array.from(conversations.keys());
|
||||
const currentIndex = tabs.findIndex((id) => id === activeConversationId);
|
||||
const tabs = Array.from($conversations.keys());
|
||||
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
|
||||
if (currentIndex !== -1) {
|
||||
const nextIndex = (currentIndex + 1) % tabs.length;
|
||||
claudeStore.switchConversation(tabs[nextIndex]);
|
||||
@@ -154,8 +175,8 @@
|
||||
// Ctrl/Cmd + Shift + Tab: Previous tab
|
||||
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const tabs = Array.from(conversations.keys());
|
||||
const currentIndex = tabs.findIndex((id) => id === activeConversationId);
|
||||
const tabs = Array.from($conversations.keys());
|
||||
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
|
||||
if (currentIndex !== -1) {
|
||||
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
||||
claudeStore.switchConversation(tabs[prevIndex]);
|
||||
@@ -171,17 +192,17 @@
|
||||
<div
|
||||
class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
|
||||
>
|
||||
{#each Array.from(conversations.entries()) as [id, conversation] (id)}
|
||||
{#each Array.from($conversations.entries()) as [id, conversation] (id)}
|
||||
<div
|
||||
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all
|
||||
{id === activeConversationId
|
||||
{id === $activeConversationId
|
||||
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
|
||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
|
||||
onclick={() => switchTab(id)}
|
||||
onkeydown={(e) => handleTabKeydown(id, e)}
|
||||
role="tab"
|
||||
tabindex={0}
|
||||
aria-selected={id === activeConversationId}
|
||||
aria-selected={id === $activeConversationId}
|
||||
>
|
||||
{#if editingTabId === id}
|
||||
<input
|
||||
@@ -196,58 +217,51 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full {getConnectionStatusColor(conversation.connectionStatus)}"
|
||||
title="Connection: {conversation.connectionStatus}{id !== connectedConversationId &&
|
||||
connectedConversationId
|
||||
? ' (Another tab is connected)'
|
||||
: ''}"
|
||||
title="Connection: {conversation.connectionStatus}"
|
||||
></div>
|
||||
<span
|
||||
class="text-sm pr-6 max-w-[150px] truncate"
|
||||
class="text-sm pr-2 max-w-[150px] truncate"
|
||||
ondblclick={(e) => startEditing(id, conversation.name, e)}
|
||||
role="button"
|
||||
tabindex={-1}
|
||||
>
|
||||
{conversation.name}
|
||||
</span>
|
||||
{#if id !== activeConversationId && id === connectedConversationId}
|
||||
<span
|
||||
class="text-xs text-[var(--text-tertiary)]"
|
||||
title="This tab has the Claude connection"
|
||||
>
|
||||
(connected)
|
||||
</span>
|
||||
{/if}
|
||||
{#if hasUnreadMessages(id, conversation)}
|
||||
<div
|
||||
class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse"
|
||||
class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse pointer-events-none"
|
||||
title="New messages"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if conversations.size > 1}
|
||||
<button
|
||||
onclick={(e) => deleteTab(id, e)}
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2 w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)] opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Close tab"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<div
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{#if $conversations.size > 1}
|
||||
<button
|
||||
onclick={(e) => deleteTab(id, e)}
|
||||
class="w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)]"
|
||||
title="Close tab"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<svg
|
||||
class="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -268,6 +282,13 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CloseTabConfirmModal
|
||||
isOpen={showConfirmModal}
|
||||
tabName={tabToCloseName}
|
||||
onConfirm={confirmCloseTab}
|
||||
onCancel={cancelCloseTab}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.terminal-tabs {
|
||||
min-height: 36px;
|
||||
|
||||
@@ -43,14 +43,7 @@
|
||||
"🔒 Grant tool permissions as needed for security",
|
||||
"📌 Pin important conversations for quick access",
|
||||
"🎨 Customize your theme and preferences in Settings",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Keyboard Shortcuts",
|
||||
items: [
|
||||
"Ctrl/Cmd + Enter: Send message",
|
||||
"Ctrl/Cmd + K: Clear chat (when supported)",
|
||||
"Escape: Close modals and panels",
|
||||
"⌨️ Check the keyboard icon for available shortcuts",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -72,10 +65,12 @@
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<h2 id="help-title" class="text-xl font-semibold text-gray-100">How to Use Hikari Desktop</h2>
|
||||
<h2 id="help-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
How to Use Hikari Desktop
|
||||
</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-gray-500 hover:text-gray-300 transition-colors"
|
||||
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">
|
||||
@@ -92,8 +87,8 @@
|
||||
<div class="overflow-y-auto flex-1 p-6 space-y-6">
|
||||
{#each sections as section (section.title)}
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-200 mb-3">{section.title}</h3>
|
||||
<ul class="space-y-2 text-sm text-gray-400">
|
||||
<h3 class="font-medium text-[var(--text-primary)] mb-3">{section.title}</h3>
|
||||
<ul class="space-y-2 text-sm text-[var(--text-secondary)]">
|
||||
{#each section.items as item (item)}
|
||||
<li class="flex items-start">
|
||||
<span class="text-[var(--accent-primary)] mr-2 mt-0.5">•</span>
|
||||
@@ -105,7 +100,7 @@
|
||||
{/each}
|
||||
|
||||
<div class="pt-4 border-t border-[var(--border-color)]">
|
||||
<p class="text-sm text-gray-500">
|
||||
<p class="text-sm text-[var(--text-tertiary)]">
|
||||
<strong>Need more help?</strong> Join our Discord community for support and updates!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
export let content: string;
|
||||
export let searchQuery: string;
|
||||
|
||||
interface TextPart {
|
||||
text: string;
|
||||
isMatch: boolean;
|
||||
}
|
||||
|
||||
function getHighlightedParts(text: string, query: string): TextPart[] {
|
||||
if (!query) {
|
||||
return [{ text, isMatch: false }];
|
||||
}
|
||||
|
||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`(${escapedQuery})`, "gi");
|
||||
const parts: TextPart[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// Add non-matching text before the match
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({
|
||||
text: text.slice(lastIndex, match.index),
|
||||
isMatch: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Add the matching text
|
||||
parts.push({
|
||||
text: match[1],
|
||||
isMatch: true,
|
||||
});
|
||||
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
// Add any remaining text after the last match
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({
|
||||
text: text.slice(lastIndex),
|
||||
isMatch: false,
|
||||
});
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
$: parts = getHighlightedParts(content, searchQuery);
|
||||
</script>
|
||||
|
||||
<span class="whitespace-pre-wrap">
|
||||
{#each parts as part, index (index)}
|
||||
{#if part.isMatch}
|
||||
<mark class="search-highlight">{part.text}</mark>
|
||||
{:else}
|
||||
{part.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</span>
|
||||
@@ -0,0 +1,179 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
category: "General",
|
||||
items: [
|
||||
{ keys: ["Escape"], description: "Close modals and panels" },
|
||||
{ keys: ["Ctrl", "L"], description: "Clear the terminal" },
|
||||
{ keys: ["Ctrl", ","], description: "Open settings" },
|
||||
{ keys: ["Ctrl", "Shift", "M"], description: "Toggle compact mode" },
|
||||
{ keys: ["Ctrl", "Shift", "S"], description: "Toggle streamer mode" },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Chat",
|
||||
items: [
|
||||
{ keys: ["Enter"], description: "Send message" },
|
||||
{ keys: ["Shift", "Enter"], description: "New line in message" },
|
||||
{ keys: ["Ctrl", "C"], description: "Interrupt/stop response" },
|
||||
{ keys: ["↑"], description: "Previous input from history" },
|
||||
{ keys: ["↓"], description: "Next input from history" },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Slash Commands",
|
||||
items: [
|
||||
{ keys: ["↑", "↓"], description: "Navigate command menu" },
|
||||
{ keys: ["Tab"], description: "Complete selected command" },
|
||||
{ keys: ["Escape"], description: "Close command menu" },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Permission Prompts",
|
||||
items: [
|
||||
{ keys: ["Enter"], description: "Allow & reconnect" },
|
||||
{ keys: ["Escape"], description: "Dismiss" },
|
||||
],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onClose}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="shortcuts-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-lg bg-[var(--accent-primary)]/20 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--accent-primary)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 3C10.22 3 8.47 3.23 6.86 3.68A2 2 0 005 5.57V18.43a2 2 0 001.86 1.89C8.47 20.77 10.22 21 12 21s3.53-.23 5.14-.68A2 2 0 0019 18.43V5.57a2 2 0 00-1.86-1.89C15.53 3.23 13.78 3 12 3z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7h.01M12 7h.01M16 7h.01M8 11h.01M12 11h.01M16 11h.01M8 15h8"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 id="shortcuts-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1 p-6 space-y-6">
|
||||
{#each shortcuts as section (section.category)}
|
||||
<div>
|
||||
<h3
|
||||
class="text-sm font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-3"
|
||||
>
|
||||
{section.category}
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
{#each section.items as item (item.description)}
|
||||
<div
|
||||
class="flex items-center justify-between py-2 px-3 bg-[var(--bg-secondary)] rounded-lg"
|
||||
>
|
||||
<span class="text-sm text-[var(--text-primary)]">{item.description}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
{#each item.keys as key, i (key)}
|
||||
{#if i > 0}
|
||||
<span class="text-[var(--text-secondary)] text-xs">+</span>
|
||||
{/if}
|
||||
<kbd
|
||||
class="px-2 py-1 text-xs font-mono bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] shadow-sm min-w-[24px] text-center"
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.overflow-y-auto {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,441 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import hljs from "highlight.js";
|
||||
import { onMount } from "svelte";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { clipboardStore } from "$lib/stores/clipboard";
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
let { content, searchQuery = "" }: Props = $props();
|
||||
let containerElement: HTMLDivElement;
|
||||
|
||||
const renderer = new marked.Renderer();
|
||||
|
||||
renderer.code = ({ text, lang }) => {
|
||||
const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
|
||||
const highlighted = hljs.highlight(text, { language }).value;
|
||||
const escapedText = text.replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
return `<div class="code-block-wrapper">
|
||||
<div class="code-block-header">
|
||||
<span class="code-block-lang">${language}</span>
|
||||
<button class="copy-code-btn" data-code="${escapedText}" title="Copy code">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<span class="copy-text">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre class="hljs-code-block"><code class="hljs language-${language}">${highlighted}</code></pre>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
renderer.codespan = ({ text }) => {
|
||||
return `<code class="hljs-inline">${text}</code>`;
|
||||
};
|
||||
|
||||
marked.setOptions({
|
||||
renderer,
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
});
|
||||
|
||||
function processSpoilers(html: string): string {
|
||||
const codeBlockPlaceholders: string[] = [];
|
||||
|
||||
// Temporarily replace code blocks and inline code with placeholders
|
||||
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
|
||||
codeBlockPlaceholders.push(match);
|
||||
return `__CODE_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
|
||||
});
|
||||
|
||||
// Apply spoiler transformation only to non-code content
|
||||
processed = processed.replace(
|
||||
/\|\|(.+?)\|\|/g,
|
||||
'<span class="spoiler" role="button" tabindex="0">$1</span>'
|
||||
);
|
||||
|
||||
// Restore code blocks
|
||||
processed = processed.replace(/__CODE_PLACEHOLDER_(\d+)__/g, (_, index) => {
|
||||
return codeBlockPlaceholders[parseInt(index)];
|
||||
});
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
function highlightSearchMatches(html: string, query: string): string {
|
||||
if (!query) return html;
|
||||
|
||||
const codeBlockPlaceholders: string[] = [];
|
||||
const tagPlaceholders: string[] = [];
|
||||
|
||||
// Temporarily replace code blocks with placeholders (don't highlight in code)
|
||||
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
|
||||
codeBlockPlaceholders.push(match);
|
||||
return `__CODE_SEARCH_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
|
||||
});
|
||||
|
||||
// Temporarily replace all HTML tags with placeholders
|
||||
processed = processed.replace(/<[^>]+>/g, (match) => {
|
||||
tagPlaceholders.push(match);
|
||||
return `__TAG_PLACEHOLDER_${tagPlaceholders.length - 1}__`;
|
||||
});
|
||||
|
||||
// Apply search highlighting to text content
|
||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`(${escapedQuery})`, "gi");
|
||||
processed = processed.replace(regex, '<mark class="search-highlight">$1</mark>');
|
||||
|
||||
// Restore HTML tags
|
||||
processed = processed.replace(/__TAG_PLACEHOLDER_(\d+)__/g, (_, index) => {
|
||||
return tagPlaceholders[parseInt(index)];
|
||||
});
|
||||
|
||||
// Restore code blocks
|
||||
processed = processed.replace(/__CODE_SEARCH_PLACEHOLDER_(\d+)__/g, (_, index) => {
|
||||
return codeBlockPlaceholders[parseInt(index)];
|
||||
});
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
try {
|
||||
const html = marked.parse(text) as string;
|
||||
const withSpoilers = processSpoilers(html);
|
||||
return highlightSearchMatches(withSpoilers, searchQuery);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSpoilerClick(event: Event) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.classList.contains("spoiler")) {
|
||||
target.classList.toggle("revealed");
|
||||
}
|
||||
}
|
||||
|
||||
function handleSpoilerKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.classList.contains("spoiler") && (event.key === "Enter" || event.key === " ")) {
|
||||
event.preventDefault();
|
||||
target.classList.toggle("revealed");
|
||||
}
|
||||
}
|
||||
|
||||
function handleLinkClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const anchor = target.closest("a");
|
||||
if (anchor?.href) {
|
||||
event.preventDefault();
|
||||
openUrl(anchor.href);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const copyBtn = target.closest(".copy-code-btn") as HTMLButtonElement;
|
||||
if (copyBtn) {
|
||||
event.preventDefault();
|
||||
const code = copyBtn.dataset.code
|
||||
?.replace(/"/g, '"')
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
if (code) {
|
||||
await navigator.clipboard.writeText(code);
|
||||
|
||||
// Capture to clipboard history
|
||||
const langElement = copyBtn.parentElement?.querySelector(".code-block-lang");
|
||||
const language = langElement?.textContent || null;
|
||||
await clipboardStore.captureClipboard(code, language, "Claude response");
|
||||
|
||||
const textSpan = copyBtn.querySelector(".copy-text");
|
||||
if (textSpan) {
|
||||
textSpan.textContent = "Copied!";
|
||||
setTimeout(() => {
|
||||
textSpan.textContent = "Copy";
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (containerElement) {
|
||||
containerElement.querySelectorAll("pre code:not(.hljs)").forEach((block) => {
|
||||
hljs.highlightElement(block as HTMLElement);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={containerElement}
|
||||
class="markdown-content"
|
||||
onclick={(e) => {
|
||||
handleSpoilerClick(e);
|
||||
handleLinkClick(e);
|
||||
handleCopyClick(e);
|
||||
}}
|
||||
onkeydown={handleSpoilerKeydown}
|
||||
role="presentation"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown rendering requires @html; content is from Claude API -->
|
||||
{@html renderMarkdown(content)}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.markdown-content {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.markdown-content :global(p) {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.markdown-content :global(p:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-content :global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-content :global(.code-block-wrapper) {
|
||||
margin: 0.75em 0;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdown-content :global(.code-block-header) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.4em 0.75em;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.markdown-content :global(.code-block-lang) {
|
||||
color: var(--text-secondary);
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.markdown-content :global(.copy-code-btn) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.25em 0.5em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.markdown-content :global(.copy-code-btn:hover) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.markdown-content :global(.copy-code-btn svg) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.markdown-content :global(.hljs-code-block) {
|
||||
background: var(--bg-code, #1e1e2e);
|
||||
border-radius: 0;
|
||||
padding: 1em;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.markdown-content :global(.hljs-code-block code) {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 0.9em;
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
}
|
||||
|
||||
.markdown-content :global(.hljs-inline) {
|
||||
background: var(--bg-code, #1e1e2e);
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
}
|
||||
|
||||
.markdown-content :global(ul),
|
||||
.markdown-content :global(ol) {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.markdown-content :global(li) {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.markdown-content :global(blockquote) {
|
||||
border-left: 3px solid var(--border-color);
|
||||
margin: 0.75em 0;
|
||||
padding-left: 1em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.markdown-content :global(a) {
|
||||
color: var(--accent-primary, #f472b6);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-content :global(a:hover) {
|
||||
color: var(--accent-secondary, #e879f9);
|
||||
}
|
||||
|
||||
.markdown-content :global(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-content :global(h1),
|
||||
.markdown-content :global(h2),
|
||||
.markdown-content :global(h3),
|
||||
.markdown-content :global(h4) {
|
||||
margin: 1em 0 0.5em 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-content :global(h1:first-child),
|
||||
.markdown-content :global(h2:first-child),
|
||||
.markdown-content :global(h3:first-child),
|
||||
.markdown-content :global(h4:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-content :global(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.markdown-content :global(table) {
|
||||
border-collapse: collapse;
|
||||
margin: 0.75em 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.markdown-content :global(th),
|
||||
.markdown-content :global(td) {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.5em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-content :global(th) {
|
||||
background: var(--bg-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Highlight.js theme colors - using CSS variables for light/dark mode support */
|
||||
.markdown-content :global(.hljs) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.markdown-content :global(.hljs-keyword),
|
||||
.markdown-content :global(.hljs-selector-tag),
|
||||
.markdown-content :global(.hljs-built_in),
|
||||
.markdown-content :global(.hljs-name) {
|
||||
color: var(--hljs-keyword);
|
||||
}
|
||||
|
||||
.markdown-content :global(.hljs-string),
|
||||
.markdown-content :global(.hljs-attr),
|
||||
.markdown-content :global(.hljs-symbol),
|
||||
.markdown-content :global(.hljs-bullet) {
|
||||
color: var(--hljs-string);
|
||||
}
|
||||
|
||||
.markdown-content :global(.hljs-number),
|
||||
.markdown-content :global(.hljs-literal) {
|
||||
color: var(--hljs-number);
|
||||
}
|
||||
|
||||
.markdown-content :global(.hljs-comment),
|
||||
.markdown-content :global(.hljs-quote) {
|
||||
color: var(--hljs-comment);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.markdown-content :global(.hljs-function),
|
||||
.markdown-content :global(.hljs-title) {
|
||||
color: var(--hljs-function);
|
||||
}
|
||||
|
||||
.markdown-content :global(.hljs-type),
|
||||
.markdown-content :global(.hljs-class) {
|
||||
color: var(--hljs-type);
|
||||
}
|
||||
|
||||
.markdown-content :global(.hljs-variable),
|
||||
.markdown-content :global(.hljs-template-variable) {
|
||||
color: var(--hljs-variable);
|
||||
}
|
||||
|
||||
.markdown-content :global(.hljs-meta) {
|
||||
color: var(--hljs-meta);
|
||||
}
|
||||
|
||||
.markdown-content :global(.hljs-tag) {
|
||||
color: var(--hljs-keyword);
|
||||
}
|
||||
|
||||
.markdown-content :global(.hljs-attribute) {
|
||||
color: var(--hljs-function);
|
||||
}
|
||||
|
||||
.markdown-content :global(.hljs-params) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Spoiler tag styles */
|
||||
.markdown-content :global(.spoiler) {
|
||||
background: var(--text-primary);
|
||||
color: transparent;
|
||||
border-radius: 4px;
|
||||
padding: 0 0.25em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.markdown-content :global(.spoiler:hover) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.markdown-content :global(.spoiler:focus) {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.markdown-content :global(.spoiler.revealed) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.markdown-content :global(.search-highlight) {
|
||||
background-color: var(--search-highlight, #fbbf24);
|
||||
color: var(--search-highlight-text, #000);
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -109,8 +109,22 @@ Please continue where we left off and retry that action now that you have permis
|
||||
function isToolAlreadyGranted(toolName: string): boolean {
|
||||
return grantedToolsList.includes(toolName);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!isVisible || !permission) return;
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleApproveAndReconnect();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
handleDismiss();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if isVisible && permission}
|
||||
<div
|
||||
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
|
||||
@@ -123,8 +137,8 @@ Please continue where we left off and retry that action now that you have permis
|
||||
<span class="text-xl">🔐</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Permission Blocked</h2>
|
||||
<p class="text-sm text-gray-400">Hikari tried to use a restricted tool</p>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Permission Blocked</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">Hikari tried to use a restricted tool</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,7 +149,7 @@ Please continue where we left off and retry that action now that you have permis
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-sm text-gray-400 mb-1">Tool</div>
|
||||
<div class="text-sm text-[var(--text-secondary)] mb-1">Tool</div>
|
||||
<div
|
||||
class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--accent-primary)] font-mono flex items-center justify-between"
|
||||
>
|
||||
@@ -149,17 +163,17 @@ Please continue where we left off and retry that action now that you have permis
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-sm text-gray-400 mb-1">Description</div>
|
||||
<div class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-gray-300">
|
||||
<div class="text-sm text-[var(--text-secondary)] mb-1">Description</div>
|
||||
<div class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--text-primary)]">
|
||||
{permission.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if Object.keys(permission.input).length > 0}
|
||||
<div class="mb-6">
|
||||
<div class="text-sm text-gray-400 mb-1">Details</div>
|
||||
<div class="text-sm text-[var(--text-secondary)] mb-1">Details</div>
|
||||
<pre
|
||||
class="px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-gray-300 text-xs overflow-x-auto max-h-32">{formatInput(
|
||||
class="px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] text-xs overflow-x-auto max-h-32">{formatInput(
|
||||
permission.input
|
||||
)}</pre>
|
||||
</div>
|
||||
@@ -168,7 +182,7 @@ Please continue where we left off and retry that action now that you have permis
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={handleDismiss}
|
||||
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-gray-400 rounded-lg transition-colors font-medium"
|
||||
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,929 @@
|
||||
<script lang="ts">
|
||||
import { configStore, type HikariConfig } from "$lib/stores/config";
|
||||
import { formattedStats } from "$lib/stores/stats";
|
||||
import { achievementsStore } from "$lib/stores/achievements";
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeFile, readFile } from "@tauri-apps/plugin-fs";
|
||||
|
||||
export let onClose: () => void;
|
||||
|
||||
let config: HikariConfig;
|
||||
configStore.config.subscribe((c) => (config = c));
|
||||
|
||||
let editingName = false;
|
||||
let editingBio = false;
|
||||
let nameInput = "";
|
||||
let bioInput = "";
|
||||
let avatarDataUrl: string | null = null;
|
||||
|
||||
// Initialize inputs when config is loaded
|
||||
$: if (config) {
|
||||
if (!editingName) nameInput = config.profile_name || "";
|
||||
if (!editingBio) bioInput = config.profile_bio || "";
|
||||
}
|
||||
|
||||
// Load avatar on mount and when path changes
|
||||
let lastLoadedPath: string | null = null;
|
||||
|
||||
async function updateAvatarDisplay(path: string | null) {
|
||||
if (path === lastLoadedPath) return;
|
||||
lastLoadedPath = path;
|
||||
if (path) {
|
||||
avatarDataUrl = await loadAvatarAsDataUrl(path);
|
||||
} else {
|
||||
avatarDataUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
$: updateAvatarDisplay(config?.profile_avatar_path ?? null);
|
||||
|
||||
let isGeneratingImage = false;
|
||||
|
||||
$: unlockedCount = Object.values($achievementsStore.achievements).filter(
|
||||
(a) => a.unlocked
|
||||
).length;
|
||||
$: totalAchievements = Object.values($achievementsStore.achievements).length;
|
||||
$: achievementPercentage =
|
||||
totalAchievements > 0 ? Math.round((unlockedCount / totalAchievements) * 100) : 0;
|
||||
|
||||
async function selectAvatar() {
|
||||
try {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "Images",
|
||||
extensions: ["png", "jpg", "jpeg", "gif", "webp"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (selected && typeof selected === "string") {
|
||||
await configStore.updateConfig({ profile_avatar_path: selected });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to select avatar:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAvatar() {
|
||||
await configStore.updateConfig({ profile_avatar_path: null });
|
||||
}
|
||||
|
||||
async function saveName() {
|
||||
await configStore.updateConfig({ profile_name: nameInput || null });
|
||||
editingName = false;
|
||||
}
|
||||
|
||||
async function saveBio() {
|
||||
await configStore.updateConfig({ profile_bio: bioInput || null });
|
||||
editingBio = false;
|
||||
}
|
||||
|
||||
async function loadAvatarAsDataUrl(path: string): Promise<string | null> {
|
||||
try {
|
||||
const data = await readFile(path);
|
||||
const extension = path.split(".").pop()?.toLowerCase() || "png";
|
||||
const mimeType =
|
||||
extension === "jpg" || extension === "jpeg"
|
||||
? "image/jpeg"
|
||||
: extension === "gif"
|
||||
? "image/gif"
|
||||
: extension === "webp"
|
||||
? "image/webp"
|
||||
: "image/png";
|
||||
// Convert Uint8Array to base64 in chunks to avoid stack overflow
|
||||
const blob = new Blob([data], { type: mimeType });
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load avatar:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateShareImage(): Promise<Blob> {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
|
||||
// Card dimensions (1080p for sharing)
|
||||
const width = 1920;
|
||||
const height = 1080;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// Background gradient (dark theme)
|
||||
const bgGradient = ctx.createLinearGradient(0, 0, width, height);
|
||||
bgGradient.addColorStop(0, "#1a1a2e");
|
||||
bgGradient.addColorStop(1, "#16213e");
|
||||
ctx.fillStyle = bgGradient;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Trans flag stripe accent at top
|
||||
const stripeHeight = 25;
|
||||
const stripeColors = ["#5bcefa", "#f5a9b8", "#ffffff", "#f5a9b8", "#5bcefa"];
|
||||
stripeColors.forEach((color, i) => {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, i * (stripeHeight / 5) * 2, width, (stripeHeight / 5) * 2);
|
||||
});
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = "#5bcefa";
|
||||
ctx.lineWidth = 6;
|
||||
ctx.strokeRect(3, 3, width - 6, height - 6);
|
||||
|
||||
// Avatar circle
|
||||
const avatarX = 200;
|
||||
const avatarY = 220;
|
||||
const avatarRadius = 140;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(avatarX, avatarY, avatarRadius, 0, Math.PI * 2);
|
||||
ctx.closePath();
|
||||
ctx.clip();
|
||||
|
||||
// Draw avatar if available, otherwise gradient placeholder
|
||||
let avatarLoaded = false;
|
||||
if (config.profile_avatar_path) {
|
||||
try {
|
||||
const dataUrl = await loadAvatarAsDataUrl(config.profile_avatar_path);
|
||||
if (dataUrl) {
|
||||
const img = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject();
|
||||
img.src = dataUrl;
|
||||
});
|
||||
ctx.drawImage(
|
||||
img,
|
||||
avatarX - avatarRadius,
|
||||
avatarY - avatarRadius,
|
||||
avatarRadius * 2,
|
||||
avatarRadius * 2
|
||||
);
|
||||
avatarLoaded = true;
|
||||
}
|
||||
} catch {
|
||||
// Will use fallback gradient
|
||||
}
|
||||
}
|
||||
|
||||
if (!avatarLoaded) {
|
||||
const avatarGradient = ctx.createLinearGradient(
|
||||
avatarX - avatarRadius,
|
||||
avatarY - avatarRadius,
|
||||
avatarX + avatarRadius,
|
||||
avatarY + avatarRadius
|
||||
);
|
||||
avatarGradient.addColorStop(0, "#5bcefa");
|
||||
avatarGradient.addColorStop(0.5, "#f5a9b8");
|
||||
avatarGradient.addColorStop(1, "#5bcefa");
|
||||
ctx.fillStyle = avatarGradient;
|
||||
ctx.fillRect(
|
||||
avatarX - avatarRadius,
|
||||
avatarY - avatarRadius,
|
||||
avatarRadius * 2,
|
||||
avatarRadius * 2
|
||||
);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// Avatar border
|
||||
ctx.beginPath();
|
||||
ctx.arc(avatarX, avatarY, avatarRadius + 6, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = "#f5a9b8";
|
||||
ctx.lineWidth = 8;
|
||||
ctx.stroke();
|
||||
|
||||
// Name
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "bold 72px system-ui, -apple-system, sans-serif";
|
||||
ctx.fillText(config.profile_name || "Hikari User", 400, 180);
|
||||
|
||||
// Bio (truncated)
|
||||
ctx.fillStyle = "#9ca3af";
|
||||
ctx.font = "36px system-ui, -apple-system, sans-serif";
|
||||
const bio = config.profile_bio || "A Hikari Desktop user";
|
||||
const truncatedBio = bio.length > 100 ? bio.substring(0, 97) + "..." : bio;
|
||||
ctx.fillText(truncatedBio, 400, 260);
|
||||
|
||||
// Stats section
|
||||
const statsY = 420;
|
||||
ctx.fillStyle = "#6b7280";
|
||||
ctx.font = "bold 32px system-ui, -apple-system, sans-serif";
|
||||
ctx.fillText("LIFETIME STATS", 80, statsY);
|
||||
|
||||
// Stats grid
|
||||
const stats = [
|
||||
{ label: "Messages", value: $formattedStats.messagesTotal },
|
||||
{ label: "Tokens", value: $formattedStats.totalTokens },
|
||||
{ label: "Code Blocks", value: $formattedStats.codeBlocksTotal },
|
||||
{ label: "Files Edited", value: $formattedStats.filesEditedTotal },
|
||||
{ label: "Files Created", value: $formattedStats.filesCreatedTotal },
|
||||
{ label: "Total Cost", value: $formattedStats.totalCost },
|
||||
];
|
||||
|
||||
const statBoxWidth = 540;
|
||||
const statBoxHeight = 160;
|
||||
const statsPerRow = 3;
|
||||
const startX = 80;
|
||||
const startY = statsY + 40;
|
||||
|
||||
stats.forEach((stat, i) => {
|
||||
const row = Math.floor(i / statsPerRow);
|
||||
const col = i % statsPerRow;
|
||||
const x = startX + col * (statBoxWidth + 50);
|
||||
const y = startY + row * (statBoxHeight + 30);
|
||||
|
||||
// Stat box background
|
||||
ctx.fillStyle = "rgba(255, 255, 255, 0.05)";
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, statBoxWidth, statBoxHeight, 20);
|
||||
ctx.fill();
|
||||
|
||||
// Stat value
|
||||
ctx.fillStyle = "#5bcefa";
|
||||
ctx.font = "bold 56px system-ui, -apple-system, sans-serif";
|
||||
ctx.fillText(stat.value, x + 32, y + 75);
|
||||
|
||||
// Stat label
|
||||
ctx.fillStyle = "#9ca3af";
|
||||
ctx.font = "28px system-ui, -apple-system, sans-serif";
|
||||
ctx.fillText(stat.label.toUpperCase(), x + 32, y + 128);
|
||||
});
|
||||
|
||||
// Achievement progress
|
||||
const achieveY = 870;
|
||||
ctx.fillStyle = "#6b7280";
|
||||
ctx.font = "bold 32px system-ui, -apple-system, sans-serif";
|
||||
ctx.fillText("ACHIEVEMENTS", 80, achieveY);
|
||||
|
||||
// Progress bar background
|
||||
const progressX = 80;
|
||||
const progressY = achieveY + 30;
|
||||
const progressWidth = 1200;
|
||||
const progressHeight = 44;
|
||||
|
||||
ctx.fillStyle = "rgba(255, 255, 255, 0.1)";
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(progressX, progressY, progressWidth, progressHeight, 22);
|
||||
ctx.fill();
|
||||
|
||||
// Progress bar fill
|
||||
const progressGradient = ctx.createLinearGradient(progressX, 0, progressX + progressWidth, 0);
|
||||
progressGradient.addColorStop(0, "#5bcefa");
|
||||
progressGradient.addColorStop(0.5, "#f5a9b8");
|
||||
progressGradient.addColorStop(1, "#5bcefa");
|
||||
ctx.fillStyle = progressGradient;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(
|
||||
progressX,
|
||||
progressY,
|
||||
progressWidth * (achievementPercentage / 100),
|
||||
progressHeight,
|
||||
22
|
||||
);
|
||||
ctx.fill();
|
||||
|
||||
// Progress text
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "bold 36px system-ui, -apple-system, sans-serif";
|
||||
ctx.fillText(
|
||||
`${unlockedCount} / ${totalAchievements} (${achievementPercentage}%)`,
|
||||
progressX + progressWidth + 40,
|
||||
progressY + 34
|
||||
);
|
||||
|
||||
// Hikari branding
|
||||
ctx.fillStyle = "#f5a9b8";
|
||||
ctx.font = "bold 42px system-ui, -apple-system, sans-serif";
|
||||
ctx.fillText("✨ Hikari Desktop", 80, height - 100);
|
||||
|
||||
// Discord promo
|
||||
ctx.fillStyle = "#9ca3af";
|
||||
ctx.font = "34px system-ui, -apple-system, sans-serif";
|
||||
ctx.fillText("Join our community: chat.nhcarrigan.com", 80, height - 45);
|
||||
|
||||
// Convert canvas to blob
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob((blob) => resolve(blob!), "image/png");
|
||||
});
|
||||
}
|
||||
|
||||
async function shareProfile() {
|
||||
isGeneratingImage = true;
|
||||
try {
|
||||
const blob = await generateShareImage();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
const filePath = await save({
|
||||
filters: [{ name: "PNG Image", extensions: ["png"] }],
|
||||
defaultPath: `hikari-profile-${config.profile_name?.replace(/\s+/g, "-").toLowerCase() || "user"}.png`,
|
||||
});
|
||||
|
||||
if (filePath) {
|
||||
await writeFile(filePath, uint8Array);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to generate share image:", error);
|
||||
} finally {
|
||||
isGeneratingImage = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="profile-overlay" role="dialog" aria-modal="true">
|
||||
<div class="profile-panel">
|
||||
<div class="profile-header">
|
||||
<h2>Profile</h2>
|
||||
<button class="close-btn" on:click={onClose} aria-label="Close profile">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="profile-content">
|
||||
<!-- Avatar Section -->
|
||||
<div class="avatar-section">
|
||||
<div
|
||||
class="avatar-container"
|
||||
on:click={selectAvatar}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:keydown={(e) => e.key === "Enter" && selectAvatar()}
|
||||
>
|
||||
{#if avatarDataUrl}
|
||||
<img src={avatarDataUrl} alt="Profile avatar" class="avatar-image" />
|
||||
<div class="avatar-overlay">
|
||||
<span>Change</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="avatar-placeholder">
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
<span>Add Photo</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if config.profile_avatar_path}
|
||||
<button class="remove-avatar-btn" on:click={removeAvatar}>Remove Photo</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Name Section -->
|
||||
<div class="name-section">
|
||||
{#if editingName}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={nameInput}
|
||||
placeholder="Enter your name"
|
||||
class="name-input"
|
||||
on:keydown={(e) => e.key === "Enter" && saveName()}
|
||||
on:blur={saveName}
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
class="name-display"
|
||||
on:click={() => {
|
||||
editingName = true;
|
||||
nameInput = config.profile_name || "";
|
||||
}}
|
||||
>
|
||||
<span class="name-text">{config.profile_name || "Click to add name"}</span>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Bio Section -->
|
||||
<div class="bio-section">
|
||||
<h3>Bio</h3>
|
||||
{#if editingBio}
|
||||
<textarea
|
||||
bind:value={bioInput}
|
||||
placeholder="Tell us about yourself..."
|
||||
class="bio-input"
|
||||
rows="3"
|
||||
on:blur={saveBio}
|
||||
></textarea>
|
||||
<button class="save-bio-btn btn-trans-gradient" on:click={saveBio}>Save</button>
|
||||
{:else}
|
||||
<button
|
||||
class="bio-display"
|
||||
on:click={() => {
|
||||
editingBio = true;
|
||||
bioInput = config.profile_bio || "";
|
||||
}}
|
||||
>
|
||||
<span class="bio-text">{config.profile_bio || "Click to add a bio..."}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<div class="stats-section">
|
||||
<h3>Lifetime Stats</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{$formattedStats.messagesTotal}</span>
|
||||
<span class="stat-label">Messages</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{$formattedStats.totalTokens}</span>
|
||||
<span class="stat-label">Tokens</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{$formattedStats.codeBlocksTotal}</span>
|
||||
<span class="stat-label">Code Blocks</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{$formattedStats.filesEditedTotal}</span>
|
||||
<span class="stat-label">Files Edited</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{$formattedStats.filesCreatedTotal}</span>
|
||||
<span class="stat-label">Files Created</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{$formattedStats.totalCost}</span>
|
||||
<span class="stat-label">Total Cost</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Achievements Section -->
|
||||
<div class="achievements-section">
|
||||
<h3>Achievements</h3>
|
||||
<div class="achievement-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {achievementPercentage}%"></div>
|
||||
</div>
|
||||
<span class="progress-text"
|
||||
>{unlockedCount} / {totalAchievements} ({achievementPercentage}%)</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share Section -->
|
||||
<div class="share-section">
|
||||
<button
|
||||
class="share-btn btn-trans-gradient"
|
||||
on:click={shareProfile}
|
||||
disabled={isGeneratingImage}
|
||||
>
|
||||
{#if isGeneratingImage}
|
||||
<span class="spinner"></span>
|
||||
Generating...
|
||||
{:else}
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||
<polyline points="16 6 12 2 8 6" />
|
||||
<line x1="12" y1="2" x2="12" y2="15" />
|
||||
</svg>
|
||||
Share Profile
|
||||
{/if}
|
||||
</button>
|
||||
<p class="share-hint">Generate a shareable image of your profile</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.profile-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.profile-panel {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-secondary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.profile-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.profile-content {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Avatar */
|
||||
.avatar-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
background: var(--bg-tertiary);
|
||||
border: 3px solid var(--trans-pink);
|
||||
box-shadow: 0 0 20px rgba(245, 169, 184, 0.3);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.avatar-container:hover {
|
||||
border-color: var(--trans-blue);
|
||||
box-shadow: 0 0 30px rgba(91, 206, 250, 0.4);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.avatar-container:hover .avatar-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.avatar-placeholder span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.remove-avatar-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.remove-avatar-btn:hover {
|
||||
color: var(--trans-pink);
|
||||
}
|
||||
|
||||
/* Name */
|
||||
.name-section {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.name-display {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.name-display:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.name-display svg {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.name-display:hover svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.name-text {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.name-input {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
padding: 12px 16px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
background: var(--bg-tertiary);
|
||||
border: 2px solid var(--trans-pink);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.name-input:focus {
|
||||
border-color: var(--trans-blue);
|
||||
box-shadow: 0 0 10px rgba(91, 206, 250, 0.3);
|
||||
}
|
||||
|
||||
/* Bio */
|
||||
.bio-section h3,
|
||||
.stats-section h3,
|
||||
.achievements-section h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.bio-display {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bio-display:hover {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.bio-text {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bio-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 2px solid var(--trans-pink);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
resize: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.bio-input:focus {
|
||||
border-color: var(--trans-blue);
|
||||
box-shadow: 0 0 10px rgba(91, 206, 250, 0.3);
|
||||
}
|
||||
|
||||
.save-bio-btn {
|
||||
margin-top: 8px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 12px;
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
background: linear-gradient(135deg, var(--trans-blue), var(--trans-pink));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Achievements */
|
||||
.achievement-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--trans-blue),
|
||||
var(--trans-pink),
|
||||
var(--trans-white),
|
||||
var(--trans-pink),
|
||||
var(--trans-blue)
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 3s linear infinite;
|
||||
border-radius: 6px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Share */
|
||||
.share-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.share-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.share-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.share-btn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.share-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,456 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { quickActionsStore, type QuickAction } from "$lib/stores/quickActions";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onAction: (prompt: string) => void;
|
||||
}
|
||||
|
||||
const { onClose, onAction }: Props = $props();
|
||||
|
||||
let editingAction = $state<QuickAction | null>(null);
|
||||
let isCreating = $state(false);
|
||||
let showDeleteConfirm = $state<string | null>(null);
|
||||
|
||||
let editName = $state("");
|
||||
let editPrompt = $state("");
|
||||
let editIcon = $state("zap");
|
||||
|
||||
const actions = $derived(quickActionsStore.actions);
|
||||
const isLoading = $derived(quickActionsStore.isLoading);
|
||||
|
||||
const availableIcons = [
|
||||
{ id: "zap", label: "Lightning" },
|
||||
{ id: "play", label: "Play" },
|
||||
{ id: "file-text", label: "File" },
|
||||
{ id: "alert-circle", label: "Alert" },
|
||||
{ id: "check-square", label: "Check" },
|
||||
{ id: "refresh-cw", label: "Refresh" },
|
||||
{ id: "git-pull-request", label: "Git PR" },
|
||||
{ id: "code", label: "Code" },
|
||||
{ id: "search", label: "Search" },
|
||||
{ id: "terminal", label: "Terminal" },
|
||||
{ id: "bug", label: "Bug" },
|
||||
{ id: "shield", label: "Shield" },
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
quickActionsStore.loadQuickActions();
|
||||
});
|
||||
|
||||
function handleAction(action: QuickAction): void {
|
||||
onAction(action.prompt);
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleStartCreate(): void {
|
||||
isCreating = true;
|
||||
editingAction = null;
|
||||
editName = "";
|
||||
editPrompt = "";
|
||||
editIcon = "zap";
|
||||
}
|
||||
|
||||
function handleStartEdit(action: QuickAction): void {
|
||||
editingAction = action;
|
||||
isCreating = false;
|
||||
editName = action.name;
|
||||
editPrompt = action.prompt;
|
||||
editIcon = action.icon;
|
||||
}
|
||||
|
||||
function handleCancelEdit(): void {
|
||||
editingAction = null;
|
||||
isCreating = false;
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
if (!editName.trim() || !editPrompt.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCreating) {
|
||||
await quickActionsStore.createQuickAction(editName.trim(), editPrompt.trim(), editIcon);
|
||||
} else if (editingAction) {
|
||||
await quickActionsStore.updateQuickAction(
|
||||
editingAction.id,
|
||||
editName.trim(),
|
||||
editPrompt.trim(),
|
||||
editIcon
|
||||
);
|
||||
}
|
||||
|
||||
handleCancelEdit();
|
||||
}
|
||||
|
||||
async function handleDelete(actionId: string): Promise<void> {
|
||||
await quickActionsStore.deleteQuickAction(actionId);
|
||||
showDeleteConfirm = null;
|
||||
}
|
||||
|
||||
function getIconSvg(icon: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
"git-pull-request":
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 3v12M18 9a3 3 0 100 6 3 3 0 000-6zm0 0V3m0 18v-6M6 21a3 3 0 100-6 3 3 0 000 6z" />',
|
||||
play: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
|
||||
"file-text":
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />',
|
||||
"alert-circle":
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
|
||||
"check-square":
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />',
|
||||
"refresh-cw":
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />',
|
||||
zap: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />',
|
||||
code: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />',
|
||||
search:
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />',
|
||||
terminal:
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />',
|
||||
bug: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />',
|
||||
shield:
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />',
|
||||
};
|
||||
return icons[icon] || icons["zap"];
|
||||
}
|
||||
</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 max-h-[80vh] overflow-hidden flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="quick-actions-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if editingAction || isCreating}
|
||||
<button
|
||||
onclick={handleCancelEdit}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Back to list"
|
||||
>
|
||||
<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="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<h2 id="quick-actions-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{#if isCreating}
|
||||
Create Quick Action
|
||||
{:else if editingAction}
|
||||
Edit Quick Action
|
||||
{:else}
|
||||
Quick Actions
|
||||
{/if}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !editingAction && !isCreating}
|
||||
<button
|
||||
onclick={handleStartCreate}
|
||||
class="px-3 py-1.5 text-sm font-medium btn-trans-gradient rounded flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
New Action
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if editingAction || isCreating}
|
||||
<div class="p-6 flex-1 overflow-y-auto">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="action-name"
|
||||
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="action-name"
|
||||
type="text"
|
||||
bind:value={editName}
|
||||
placeholder="Enter action name..."
|
||||
class="w-full px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="action-icon"
|
||||
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||||
>
|
||||
Icon
|
||||
</label>
|
||||
<div class="grid grid-cols-6 gap-2">
|
||||
{#each availableIcons as icon (icon.id)}
|
||||
<button
|
||||
onclick={() => (editIcon = icon.id)}
|
||||
class="p-3 rounded-lg border transition-colors {editIcon === icon.id
|
||||
? 'bg-[var(--accent-primary)]/10 border-[var(--accent-primary)] text-[var(--accent-primary)]'
|
||||
: 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]/50'}"
|
||||
title={icon.label}
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Icons are from controlled internal function -->
|
||||
{@html getIconSvg(icon.id)}
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="action-prompt"
|
||||
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||||
>
|
||||
Prompt
|
||||
</label>
|
||||
<textarea
|
||||
id="action-prompt"
|
||||
bind:value={editPrompt}
|
||||
placeholder="Enter the prompt to send..."
|
||||
rows="4"
|
||||
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none font-mono text-sm"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-4">
|
||||
<button
|
||||
onclick={handleCancelEdit}
|
||||
class="px-4 py-2 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={!editName.trim() || !editPrompt.trim()}
|
||||
class="px-4 py-2 text-sm font-medium btn-trans-gradient rounded-lg"
|
||||
>
|
||||
{isCreating ? "Create" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
{#if $isLoading}
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<div class="text-[var(--text-tertiary)]">Loading quick actions...</div>
|
||||
</div>
|
||||
{:else if $actions.length === 0}
|
||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||
<svg
|
||||
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-[var(--text-secondary)]">No quick actions available</p>
|
||||
<button
|
||||
onclick={handleStartCreate}
|
||||
class="mt-4 px-4 py-2 text-sm font-medium btn-trans-gradient rounded-lg"
|
||||
>
|
||||
Create your first action
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{#each $actions as action (action.id)}
|
||||
<div
|
||||
class="group relative bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg hover:border-[var(--accent-primary)]/50 transition-colors"
|
||||
>
|
||||
<button onclick={() => handleAction(action)} class="w-full p-4 text-left">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg bg-[var(--accent-primary)]/10 flex items-center justify-center text-[var(--accent-primary)]"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Icons are from controlled internal function -->
|
||||
{@html getIconSvg(action.icon)}
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-medium text-[var(--text-primary)] truncate">{action.name}</h3>
|
||||
{#if action.is_default}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">Default</span>
|
||||
{:else}
|
||||
<span class="text-xs text-[var(--accent-secondary)]">Custom</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-tertiary)] line-clamp-2">{action.prompt}</p>
|
||||
</button>
|
||||
<div
|
||||
class="absolute top-2 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStartEdit(action);
|
||||
}}
|
||||
class="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] bg-[var(--bg-primary)] rounded transition-colors"
|
||||
title="Edit action"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if !action.is_default}
|
||||
{#if showDeleteConfirm === action.id}
|
||||
<div class="flex items-center gap-1 bg-[var(--bg-primary)] rounded p-1">
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(action.id);
|
||||
}}
|
||||
class="px-2 py-0.5 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
showDeleteConfirm = null;
|
||||
}}
|
||||
class="px-2 py-0.5 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
showDeleteConfirm = action.id;
|
||||
}}
|
||||
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 bg-[var(--bg-primary)] rounded transition-colors"
|
||||
title="Delete action"
|
||||
>
|
||||
<svg
|
||||
class="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.overflow-y-auto {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,476 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { sessionsStore, type SessionListItem, type SavedSession } from "$lib/stores/sessions";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import type { TerminalLine } from "$lib/types/messages";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
let searchInput = $state("");
|
||||
let selectedSession = $state<SavedSession | null>(null);
|
||||
let showDeleteConfirm = $state<string | null>(null);
|
||||
let showExportMenu = $state<string | null>(null);
|
||||
let isImporting = $state(false);
|
||||
|
||||
const sessions = $derived(sessionsStore.sessions);
|
||||
const isLoading = $derived(sessionsStore.isLoading);
|
||||
|
||||
onMount(() => {
|
||||
sessionsStore.loadSessions();
|
||||
});
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return `Today at ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
||||
} else if (diffDays === 1) {
|
||||
return `Yesterday at ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays} days ago`;
|
||||
} else {
|
||||
return date.toLocaleDateString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearch(): Promise<void> {
|
||||
await sessionsStore.searchSessions(searchInput);
|
||||
}
|
||||
|
||||
async function handleViewSession(session: SessionListItem): Promise<void> {
|
||||
const fullSession = await sessionsStore.loadSession(session.id);
|
||||
selectedSession = fullSession;
|
||||
}
|
||||
|
||||
async function handleResumeSession(session: SessionListItem): Promise<void> {
|
||||
const fullSession = await sessionsStore.loadSession(session.id);
|
||||
if (!fullSession) return;
|
||||
|
||||
const newConvId = conversationsStore.createConversation(fullSession.name);
|
||||
|
||||
for (const msg of fullSession.messages) {
|
||||
const line: TerminalLine = {
|
||||
id: msg.id,
|
||||
type: msg.type as TerminalLine["type"],
|
||||
content: msg.content,
|
||||
timestamp: new Date(msg.timestamp),
|
||||
toolName: msg.tool_name,
|
||||
};
|
||||
conversationsStore.addLineToConversation(newConvId, line.type, line.content, line.toolName);
|
||||
}
|
||||
|
||||
if (fullSession.working_directory) {
|
||||
conversationsStore.setWorkingDirectoryForConversation(
|
||||
newConvId,
|
||||
fullSession.working_directory
|
||||
);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleDeleteSession(sessionId: string): Promise<void> {
|
||||
await sessionsStore.deleteSession(sessionId);
|
||||
showDeleteConfirm = null;
|
||||
if (selectedSession?.id === sessionId) {
|
||||
selectedSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackToList(): void {
|
||||
selectedSession = null;
|
||||
}
|
||||
|
||||
async function handleExportJson(sessionId: string): Promise<void> {
|
||||
showExportMenu = null;
|
||||
await sessionsStore.exportSessionAsJson(sessionId);
|
||||
}
|
||||
|
||||
async function handleExportMarkdown(sessionId: string): Promise<void> {
|
||||
showExportMenu = null;
|
||||
await sessionsStore.exportSessionAsMarkdown(sessionId);
|
||||
}
|
||||
|
||||
async function handleExportHtml(sessionId: string): Promise<void> {
|
||||
showExportMenu = null;
|
||||
await sessionsStore.exportSessionAsHtml(sessionId);
|
||||
}
|
||||
|
||||
async function handleExportPdf(sessionId: string): Promise<void> {
|
||||
showExportMenu = null;
|
||||
await sessionsStore.exportSessionAsPdf(sessionId);
|
||||
}
|
||||
|
||||
async function handleImport(): Promise<void> {
|
||||
isImporting = true;
|
||||
try {
|
||||
await sessionsStore.importSession();
|
||||
} finally {
|
||||
isImporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExportMenu(sessionId: string): void {
|
||||
if (showExportMenu === sessionId) {
|
||||
showExportMenu = null;
|
||||
} else {
|
||||
showExportMenu = sessionId;
|
||||
showDeleteConfirm = null;
|
||||
}
|
||||
}
|
||||
</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-3xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="session-history-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if selectedSession}
|
||||
<button
|
||||
onclick={handleBackToList}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Back to list"
|
||||
>
|
||||
<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="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<h2 id="session-history-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{selectedSession ? selectedSession.name : "Session History"}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !selectedSession}
|
||||
<button
|
||||
onclick={handleImport}
|
||||
disabled={isImporting}
|
||||
class="px-3 py-1.5 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--bg-tertiary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
title="Import session"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
/>
|
||||
</svg>
|
||||
{isImporting ? "Importing..." : "Import"}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedSession}
|
||||
<div class="overflow-y-auto flex-1 p-6">
|
||||
<div class="text-sm text-[var(--text-tertiary)] mb-4">
|
||||
<span>{formatDate(selectedSession.created_at)}</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span>{selectedSession.message_count} messages</span>
|
||||
{#if selectedSession.working_directory}
|
||||
<span class="mx-2">•</span>
|
||||
<span class="font-mono text-xs">{selectedSession.working_directory}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{#each selectedSession.messages as message (message.id)}
|
||||
<div
|
||||
class="p-3 rounded-lg {message.type === 'user'
|
||||
? 'bg-[var(--accent-primary)]/10 border border-[var(--accent-primary)]/20'
|
||||
: message.type === 'assistant'
|
||||
? 'bg-[var(--bg-secondary)]'
|
||||
: message.type === 'error'
|
||||
? 'bg-red-500/10 border border-red-500/20'
|
||||
: 'bg-[var(--bg-tertiary)]'}"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
class="text-xs font-medium {message.type === 'user'
|
||||
? 'text-[var(--accent-primary)]'
|
||||
: message.type === 'assistant'
|
||||
? 'text-[var(--text-primary)]'
|
||||
: message.type === 'error'
|
||||
? 'text-red-400'
|
||||
: 'text-[var(--text-tertiary)]'}"
|
||||
>
|
||||
{message.type === "user"
|
||||
? "You"
|
||||
: message.type === "assistant"
|
||||
? "Hikari"
|
||||
: message.type === "tool"
|
||||
? message.tool_name || "Tool"
|
||||
: message.type}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-secondary)] whitespace-pre-wrap break-words">
|
||||
{message.content.length > 500
|
||||
? message.content.slice(0, 500) + "..."
|
||||
: message.content}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-4 border-b border-[var(--border-color)]">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search sessions..."
|
||||
bind:value={searchInput}
|
||||
oninput={handleSearch}
|
||||
class="w-full px-4 py-2 pl-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
{#if $isLoading}
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<div class="text-[var(--text-tertiary)]">Loading sessions...</div>
|
||||
</div>
|
||||
{:else if $sessions.length === 0}
|
||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||
<svg
|
||||
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-[var(--text-secondary)]">No saved sessions yet</p>
|
||||
<p class="text-sm text-[var(--text-tertiary)] mt-1">
|
||||
Your conversations will appear here once saved
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-[var(--border-color)]">
|
||||
{#each $sessions as session (session.id)}
|
||||
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<button class="flex-1 text-left" onclick={() => handleViewSession(session)}>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h3 class="font-medium text-[var(--text-primary)]">{session.name}</h3>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">
|
||||
{session.message_count} messages
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 mb-2">
|
||||
{session.preview}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
|
||||
<span>{formatDate(session.last_activity_at)}</span>
|
||||
{#if session.working_directory}
|
||||
<span>•</span>
|
||||
<span class="font-mono truncate max-w-[200px]">
|
||||
{session.working_directory}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<button
|
||||
onclick={() => handleResumeSession(session)}
|
||||
class="btn-trans-gradient px-3 py-1.5 text-xs font-medium rounded"
|
||||
title="Resume this session"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => toggleExportMenu(session.id)}
|
||||
class="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
title="Export session"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if showExportMenu === session.id}
|
||||
<div
|
||||
class="absolute right-0 top-full mt-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-lg py-1 z-10 min-w-[140px]"
|
||||
>
|
||||
<button
|
||||
onclick={() => handleExportJson(session.id)}
|
||||
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Export as JSON
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleExportMarkdown(session.id)}
|
||||
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Export as Markdown
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleExportHtml(session.id)}
|
||||
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Export as HTML
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleExportPdf(session.id)}
|
||||
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Export as PDF
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showDeleteConfirm === session.id}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => handleDeleteSession(session.id)}
|
||||
class="px-2 py-1 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showDeleteConfirm = null)}
|
||||
class="px-2 py-1 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (showDeleteConfirm = session.id)}
|
||||
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors"
|
||||
title="Delete session"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.overflow-y-auto {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import type { SlashCommand } from "$lib/commands/slashCommands";
|
||||
|
||||
interface Props {
|
||||
commands: SlashCommand[];
|
||||
selectedIndex: number;
|
||||
onSelect: (command: SlashCommand) => void;
|
||||
}
|
||||
|
||||
let { commands, selectedIndex, onSelect }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if commands.length > 0}
|
||||
<div class="slash-command-menu">
|
||||
<div class="menu-header">Commands</div>
|
||||
{#each commands as command, index (command.name)}
|
||||
<button
|
||||
type="button"
|
||||
class="menu-item"
|
||||
class:selected={index === selectedIndex}
|
||||
onclick={() => onSelect(command)}
|
||||
onmouseenter={() => (selectedIndex = index)}
|
||||
>
|
||||
<span class="command-name">/{command.name}</span>
|
||||
<span class="command-description">{command.description}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.slash-command-menu {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-bottom: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.2);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.menu-header {
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.menu-item:hover,
|
||||
.menu-item.selected {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.command-name {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
color: var(--accent-primary);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.command-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,467 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { snippetsStore, type Snippet } from "$lib/stores/snippets";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onInsert: (content: string) => void;
|
||||
}
|
||||
|
||||
const { onClose, onInsert }: Props = $props();
|
||||
|
||||
let editingSnippet = $state<Snippet | null>(null);
|
||||
let isCreating = $state(false);
|
||||
let showDeleteConfirm = $state<string | null>(null);
|
||||
|
||||
let editName = $state("");
|
||||
let editContent = $state("");
|
||||
let editCategory = $state("");
|
||||
let newCategoryInput = $state("");
|
||||
let showNewCategoryInput = $state(false);
|
||||
|
||||
const snippets = $derived(snippetsStore.filteredSnippets);
|
||||
const categories = $derived(snippetsStore.categories);
|
||||
const selectedCategory = $derived(snippetsStore.selectedCategory);
|
||||
const isLoading = $derived(snippetsStore.isLoading);
|
||||
|
||||
onMount(() => {
|
||||
snippetsStore.loadSnippets();
|
||||
});
|
||||
|
||||
function handleSelectCategory(category: string | null): void {
|
||||
snippetsStore.setSelectedCategory(category);
|
||||
}
|
||||
|
||||
function handleInsert(snippet: Snippet): void {
|
||||
onInsert(snippet.content);
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleStartCreate(): void {
|
||||
isCreating = true;
|
||||
editingSnippet = null;
|
||||
editName = "";
|
||||
editContent = "";
|
||||
editCategory = $categories.length > 0 ? $categories[0] : "Custom";
|
||||
showNewCategoryInput = false;
|
||||
newCategoryInput = "";
|
||||
}
|
||||
|
||||
function handleStartEdit(snippet: Snippet): void {
|
||||
editingSnippet = snippet;
|
||||
isCreating = false;
|
||||
editName = snippet.name;
|
||||
editContent = snippet.content;
|
||||
editCategory = snippet.category;
|
||||
showNewCategoryInput = false;
|
||||
newCategoryInput = "";
|
||||
}
|
||||
|
||||
function handleCancelEdit(): void {
|
||||
editingSnippet = null;
|
||||
isCreating = false;
|
||||
showNewCategoryInput = false;
|
||||
newCategoryInput = "";
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
const finalCategory =
|
||||
showNewCategoryInput && newCategoryInput.trim() ? newCategoryInput.trim() : editCategory;
|
||||
|
||||
if (!editName.trim() || !editContent.trim() || !finalCategory) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCreating) {
|
||||
await snippetsStore.createSnippet(editName.trim(), editContent.trim(), finalCategory);
|
||||
} else if (editingSnippet) {
|
||||
await snippetsStore.updateSnippet(
|
||||
editingSnippet.id,
|
||||
editName.trim(),
|
||||
editContent.trim(),
|
||||
finalCategory
|
||||
);
|
||||
}
|
||||
|
||||
handleCancelEdit();
|
||||
}
|
||||
|
||||
async function handleDelete(snippetId: string): Promise<void> {
|
||||
await snippetsStore.deleteSnippet(snippetId);
|
||||
showDeleteConfirm = null;
|
||||
}
|
||||
|
||||
function toggleNewCategory(): void {
|
||||
showNewCategoryInput = !showNewCategoryInput;
|
||||
if (showNewCategoryInput) {
|
||||
newCategoryInput = "";
|
||||
}
|
||||
}
|
||||
</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-4xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="snippet-library-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if editingSnippet || isCreating}
|
||||
<button
|
||||
onclick={handleCancelEdit}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Back to list"
|
||||
>
|
||||
<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="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<h2 id="snippet-library-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{#if isCreating}
|
||||
Create Snippet
|
||||
{:else if editingSnippet}
|
||||
Edit Snippet
|
||||
{:else}
|
||||
Snippet Library
|
||||
{/if}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !editingSnippet && !isCreating}
|
||||
<button
|
||||
onclick={handleStartCreate}
|
||||
class="btn-trans-gradient px-3 py-1.5 text-sm font-medium rounded flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
New Snippet
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if editingSnippet || isCreating}
|
||||
<div class="p-6 flex-1 overflow-y-auto">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="snippet-name"
|
||||
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="snippet-name"
|
||||
type="text"
|
||||
bind:value={editName}
|
||||
placeholder="Enter snippet name..."
|
||||
class="w-full px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="snippet-category"
|
||||
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||||
>
|
||||
Category
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if showNewCategoryInput}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newCategoryInput}
|
||||
placeholder="Enter new category..."
|
||||
class="flex-1 px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
{:else}
|
||||
<select
|
||||
id="snippet-category"
|
||||
bind:value={editCategory}
|
||||
class="flex-1 px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
>
|
||||
{#each $categories as category (category)}
|
||||
<option value={category}>{category}</option>
|
||||
{/each}
|
||||
<option value="Custom">Custom</option>
|
||||
</select>
|
||||
{/if}
|
||||
<button
|
||||
onclick={toggleNewCategory}
|
||||
class="px-3 py-2 text-sm font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
{showNewCategoryInput ? "Use Existing" : "New Category"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="snippet-content"
|
||||
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||||
>
|
||||
Content
|
||||
</label>
|
||||
<textarea
|
||||
id="snippet-content"
|
||||
bind:value={editContent}
|
||||
placeholder="Enter your prompt snippet..."
|
||||
rows="8"
|
||||
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none font-mono text-sm"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-4">
|
||||
<button
|
||||
onclick={handleCancelEdit}
|
||||
class="px-4 py-2 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={!editName.trim() || !editContent.trim()}
|
||||
class="btn-trans-gradient px-4 py-2 text-sm font-medium rounded-lg"
|
||||
>
|
||||
{isCreating ? "Create" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<div class="w-48 border-r border-[var(--border-color)] p-4 overflow-y-auto">
|
||||
<h3
|
||||
class="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wider mb-3"
|
||||
>
|
||||
Categories
|
||||
</h3>
|
||||
<div class="space-y-1">
|
||||
<button
|
||||
onclick={() => handleSelectCategory(null)}
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors {$selectedCategory ===
|
||||
null
|
||||
? 'bg-[var(--accent-primary)]/10 text-[var(--accent-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]'}"
|
||||
>
|
||||
All Snippets
|
||||
</button>
|
||||
{#each $categories as category (category)}
|
||||
<button
|
||||
onclick={() => handleSelectCategory(category)}
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors {$selectedCategory ===
|
||||
category
|
||||
? 'bg-[var(--accent-primary)]/10 text-[var(--accent-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]'}"
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if $isLoading}
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<div class="text-[var(--text-tertiary)]">Loading snippets...</div>
|
||||
</div>
|
||||
{:else if $snippets.length === 0}
|
||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||
<svg
|
||||
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-[var(--text-secondary)]">No snippets in this category</p>
|
||||
<button
|
||||
onclick={handleStartCreate}
|
||||
class="btn-trans-gradient mt-4 px-4 py-2 text-sm font-medium rounded-lg"
|
||||
>
|
||||
Create your first snippet
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-[var(--border-color)]">
|
||||
{#each $snippets as snippet (snippet.id)}
|
||||
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h3 class="font-medium text-[var(--text-primary)]">{snippet.name}</h3>
|
||||
{#if snippet.is_default}
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-medium bg-[var(--accent-primary)]/10 text-[var(--accent-primary)] rounded"
|
||||
>
|
||||
Default
|
||||
</span>
|
||||
{/if}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">{snippet.category}</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 font-mono">
|
||||
{snippet.content}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<button
|
||||
onclick={() => handleInsert(snippet)}
|
||||
class="btn-trans-gradient px-3 py-1.5 text-xs font-medium rounded"
|
||||
title="Insert this snippet"
|
||||
>
|
||||
Insert
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleStartEdit(snippet)}
|
||||
class="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
title="Edit snippet"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if !snippet.is_default}
|
||||
{#if showDeleteConfirm === snippet.id}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => handleDelete(snippet.id)}
|
||||
class="px-2 py-1 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showDeleteConfirm = null)}
|
||||
class="px-2 py-1 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (showDeleteConfirm = snippet.id)}
|
||||
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors"
|
||||
title="Delete snippet"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.overflow-y-auto {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -14,7 +14,6 @@
|
||||
<div class="stats-row">
|
||||
<span class="stat-label">Messages:</span>
|
||||
<span class="stat-value">{$formattedStats.messagesSession}</span>
|
||||
<span class="stat-secondary">/ {$formattedStats.messagesTotal}</span>
|
||||
</div>
|
||||
|
||||
<div class="stats-section">
|
||||
@@ -32,11 +31,6 @@
|
||||
<span class="stat-label">Output:</span>
|
||||
<span class="stat-value">{$formattedStats.sessionOutputTokens}</span>
|
||||
</div>
|
||||
<div class="stat-row stat-highlight">
|
||||
<span class="stat-label">Total:</span>
|
||||
<span class="stat-value">{$formattedStats.totalTokens}</span>
|
||||
<span class="stat-cost">{$formattedStats.totalCost}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-section">
|
||||
@@ -44,17 +38,14 @@
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Code blocks:</span>
|
||||
<span class="stat-value">{$formattedStats.codeBlocksSession}</span>
|
||||
<span class="stat-secondary">/ {$formattedStats.codeBlocksTotal}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Files edited:</span>
|
||||
<span class="stat-value">{$formattedStats.filesEditedSession}</span>
|
||||
<span class="stat-secondary">/ {$formattedStats.filesEditedTotal}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Files created:</span>
|
||||
<span class="stat-value">{$formattedStats.filesCreatedSession}</span>
|
||||
<span class="stat-secondary">/ {$formattedStats.filesCreatedTotal}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,14 +119,6 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stat-highlight {
|
||||
font-weight: 600;
|
||||
color: var(--accent-primary);
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
onToggleAchievements?: () => void;
|
||||
onToggleCompact?: () => void;
|
||||
}
|
||||
|
||||
const { onToggleAchievements = () => {} }: Props = $props();
|
||||
const { onToggleAchievements = () => {}, onToggleCompact = () => {} }: Props = $props();
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
@@ -11,13 +12,17 @@
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { configStore, type HikariConfig } from "$lib/stores/config";
|
||||
import { configStore, type HikariConfig, isStreamerMode } from "$lib/stores/config";
|
||||
import type { ConnectionStatus } from "$lib/types/messages";
|
||||
import { onMount } from "svelte";
|
||||
import StatsDisplay from "./StatsDisplay.svelte";
|
||||
import AboutPanel from "./AboutPanel.svelte";
|
||||
import HelpPanel from "./HelpPanel.svelte";
|
||||
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
|
||||
import { achievementProgress } from "$lib/stores/achievements";
|
||||
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
|
||||
import GitPanel from "./GitPanel.svelte";
|
||||
import ProfilePanel from "./ProfilePanel.svelte";
|
||||
|
||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||
const DONATE_URL = "https://donate.nhcarrigan.com";
|
||||
@@ -31,6 +36,10 @@
|
||||
let showStats = $state(false);
|
||||
let showAbout = $state(false);
|
||||
let showHelp = $state(false);
|
||||
let showKeyboardShortcuts = $state(false);
|
||||
let showSessionHistory = $state(false);
|
||||
let showGitPanel = $state(false);
|
||||
let showProfile = $state(false);
|
||||
const progress = $derived($achievementProgress);
|
||||
let currentConfig: HikariConfig = $state({
|
||||
model: null,
|
||||
@@ -43,6 +52,32 @@
|
||||
greeting_custom_prompt: null,
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.5,
|
||||
always_on_top: false,
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: null,
|
||||
font_size: 14,
|
||||
minimize_to_tray: false,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
profile_name: null,
|
||||
profile_avatar_path: null,
|
||||
profile_bio: null,
|
||||
custom_theme_colors: {
|
||||
bg_primary: null,
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
});
|
||||
|
||||
let streamerModeActive = $state(false);
|
||||
isStreamerMode.subscribe((value) => {
|
||||
streamerModeActive = value;
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
@@ -196,9 +231,43 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
{#if streamerModeActive}
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse"
|
||||
title="Streamer mode active (Ctrl+Shift+S to toggle)"
|
||||
></div>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => (showProfile = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Profile"
|
||||
>
|
||||
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={onToggleCompact}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Compact Mode (Ctrl+Shift+M)"
|
||||
>
|
||||
<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="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={toggleAchievements}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors relative"
|
||||
class="p-1 text-gray-500 icon-trans-hover relative"
|
||||
title="Achievements"
|
||||
>
|
||||
<span class="text-lg">🏆</span>
|
||||
@@ -210,11 +279,37 @@
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showSessionHistory = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Session History"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showGitPanel = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Git Panel"
|
||||
>
|
||||
<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="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showStats = !showStats)}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors {showStats
|
||||
? 'text-[var(--accent-primary)]'
|
||||
: ''}"
|
||||
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
|
||||
title="Usage Stats"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -228,7 +323,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={configStore.openSidebar}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Settings"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -248,7 +343,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => openUrl(DONATE_URL)}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Support our work"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
@@ -259,7 +354,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showAbout = true)}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="About Hikari Desktop"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -271,9 +366,29 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showKeyboardShortcuts = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Keyboard Shortcuts"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 3C10.22 3 8.47 3.23 6.86 3.68A2 2 0 005 5.57V18.43a2 2 0 001.86 1.89C8.47 20.77 10.22 21 12 21s3.53-.23 5.14-.68A2 2 0 0019 18.43V5.57a2 2 0 00-1.86-1.89C15.53 3.23 13.78 3 12 3z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7h.01M12 7h.01M16 7h.01M8 11h.01M12 11h.01M16 11h.01M8 15h8"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showHelp = true)}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Help"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -287,7 +402,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => openUrl(DISCORD_URL)}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Join our Discord"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
@@ -340,3 +455,19 @@
|
||||
{#if showHelp}
|
||||
<HelpPanel onClose={() => (showHelp = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showKeyboardShortcuts}
|
||||
<KeyboardShortcutsModal onClose={() => (showKeyboardShortcuts = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showSessionHistory}
|
||||
<SessionHistoryPanel onClose={() => (showSessionHistory = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showGitPanel}
|
||||
<GitPanel isOpen={showGitPanel} onClose={() => (showGitPanel = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showProfile}
|
||||
<ProfilePanel onClose={() => (showProfile = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -1,24 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
||||
import { afterUpdate } from "svelte";
|
||||
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
|
||||
import ConversationTabs from "./ConversationTabs.svelte";
|
||||
import Markdown from "./Markdown.svelte";
|
||||
import HighlightedText from "./HighlightedText.svelte";
|
||||
import { searchState, searchQuery } from "$lib/stores/search";
|
||||
import { clipboardStore } from "$lib/stores/clipboard";
|
||||
import { shouldHidePaths, maskPaths } from "$lib/stores/config";
|
||||
|
||||
let terminalElement: HTMLDivElement;
|
||||
let shouldAutoScroll = true;
|
||||
let lines: TerminalLine[] = [];
|
||||
let currentSearchQuery = "";
|
||||
let currentConversationId: string | null = null;
|
||||
let isRestoringScroll = false;
|
||||
|
||||
searchQuery.subscribe((value) => {
|
||||
currentSearchQuery = value;
|
||||
});
|
||||
|
||||
let hidePaths = false;
|
||||
shouldHidePaths.subscribe((value) => {
|
||||
hidePaths = value;
|
||||
});
|
||||
|
||||
claudeStore.terminalLines.subscribe((value) => {
|
||||
lines = value;
|
||||
});
|
||||
|
||||
claudeStore.activeConversationId.subscribe(async (newId) => {
|
||||
if (!newId) return;
|
||||
|
||||
// Save current conversation's scroll position before switching
|
||||
if (currentConversationId && currentConversationId !== newId && terminalElement) {
|
||||
const position = shouldAutoScroll ? -1 : terminalElement.scrollTop;
|
||||
claudeStore.saveScrollPosition(currentConversationId, position);
|
||||
}
|
||||
|
||||
currentConversationId = newId;
|
||||
|
||||
// Restore scroll position for the new conversation after DOM updates
|
||||
await tick();
|
||||
if (terminalElement) {
|
||||
const savedPosition = claudeStore.getScrollPosition(newId);
|
||||
isRestoringScroll = true;
|
||||
if (savedPosition === -1) {
|
||||
// Auto-scroll to bottom
|
||||
shouldAutoScroll = true;
|
||||
terminalElement.scrollTop = terminalElement.scrollHeight;
|
||||
} else {
|
||||
// Restore to saved position
|
||||
shouldAutoScroll = false;
|
||||
terminalElement.scrollTop = savedPosition;
|
||||
}
|
||||
// Small delay to prevent the scroll handler from overriding our restore
|
||||
setTimeout(() => {
|
||||
isRestoringScroll = false;
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
function handleScroll() {
|
||||
if (!terminalElement) return;
|
||||
if (!terminalElement || isRestoringScroll) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = terminalElement;
|
||||
shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100;
|
||||
}
|
||||
|
||||
afterUpdate(() => {
|
||||
if (shouldAutoScroll && terminalElement) {
|
||||
if (shouldAutoScroll && terminalElement && !isRestoringScroll) {
|
||||
terminalElement.scrollTop = terminalElement.scrollHeight;
|
||||
}
|
||||
});
|
||||
@@ -63,6 +112,49 @@
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
$: {
|
||||
if (currentSearchQuery && lines.length > 0) {
|
||||
const escapedQuery = currentSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(escapedQuery, "gi");
|
||||
let totalMatches = 0;
|
||||
for (const line of lines) {
|
||||
const matches = line.content.match(regex);
|
||||
if (matches) {
|
||||
totalMatches += matches.length;
|
||||
}
|
||||
}
|
||||
searchState.setMatchCount(totalMatches);
|
||||
} else {
|
||||
searchState.setMatchCount(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle manual text selection copy events
|
||||
function handleCopy() {
|
||||
const selection = window.getSelection();
|
||||
const selectedText = selection?.toString();
|
||||
|
||||
if (selectedText && selectedText.trim().length > 0) {
|
||||
// Only capture multi-line or longer text (likely code/meaningful content)
|
||||
if (selectedText.includes("\n") || selectedText.length > 50) {
|
||||
clipboardStore.captureClipboard(selectedText, null, "Copied from chat");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Listen for copy events on the terminal
|
||||
if (terminalElement) {
|
||||
terminalElement.addEventListener("copy", handleCopy);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (terminalElement) {
|
||||
terminalElement.removeEventListener("copy", handleCopy);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -84,7 +176,8 @@
|
||||
<div
|
||||
bind:this={terminalElement}
|
||||
onscroll={handleScroll}
|
||||
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono text-sm"
|
||||
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono"
|
||||
style="font-size: var(--terminal-font-size, 14px);"
|
||||
>
|
||||
{#if lines.length === 0}
|
||||
<div class="terminal-waiting italic">
|
||||
@@ -100,7 +193,17 @@
|
||||
{#if line.toolName}
|
||||
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
||||
{/if}
|
||||
<span class="whitespace-pre-wrap">{line.content}</span>
|
||||
{#if line.type === "assistant"}
|
||||
<Markdown
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
searchQuery={currentSearchQuery}
|
||||
/>
|
||||
{:else}
|
||||
<HighlightedText
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
searchQuery={currentSearchQuery}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -157,4 +260,11 @@
|
||||
.terminal-header-text {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
:global(.search-highlight) {
|
||||
background-color: var(--search-highlight, #fbbf24);
|
||||
color: var(--search-highlight-text, #000);
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import type { UpdateInfo } from "$lib/types/messages";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
let updateInfo = $state<UpdateInfo | null>(null);
|
||||
let dismissed = $state(false);
|
||||
|
||||
export async function checkForUpdates() {
|
||||
// Check if update checks are enabled
|
||||
const config = configStore.getConfig();
|
||||
if (!config.update_checks_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await invoke<UpdateInfo>("check_for_updates");
|
||||
if (info.has_update) {
|
||||
updateInfo = info;
|
||||
dismissed = false;
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
console.error("Failed to check for updates:", errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
dismissed = true;
|
||||
}
|
||||
|
||||
async function openRelease() {
|
||||
if (updateInfo?.release_url) {
|
||||
await openUrl(updateInfo.release_url);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if updateInfo && !dismissed}
|
||||
<div
|
||||
class="fixed bottom-4 right-4 max-w-sm bg-[var(--bg-tertiary)] border border-[var(--accent-primary)] rounded-lg shadow-lg p-4 z-50"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-2xl">🎉</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-[var(--text-primary)] font-semibold mb-1">Update Available!</h3>
|
||||
<p class="text-[var(--text-secondary)] text-sm mb-2">
|
||||
A new version of Hikari Desktop is available:
|
||||
<span class="text-[var(--accent-primary)] font-mono">{updateInfo.latest_version}</span>
|
||||
</p>
|
||||
<p class="text-[var(--text-muted)] text-xs mb-3">
|
||||
Current version: {updateInfo.current_version}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button onclick={openRelease} class="btn-trans-gradient px-3 py-1.5 rounded text-sm">
|
||||
View Release
|
||||
</button>
|
||||
<button
|
||||
onclick={dismiss}
|
||||
class="px-3 py-1.5 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded text-sm hover:bg-[var(--bg-primary)] transition-all"
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={dismiss}
|
||||
class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,264 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import { claudeStore, hasQuestionPending } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import type { UserQuestionEvent } from "$lib/types/messages";
|
||||
|
||||
let isVisible = $state(false);
|
||||
let question: UserQuestionEvent | null = $state(null);
|
||||
let selectedOptions: SvelteSet<string> = new SvelteSet();
|
||||
let customAnswer = $state("");
|
||||
let showCustomInput = $state(false);
|
||||
let grantedToolsList: string[] = $state([]);
|
||||
let workingDirectory = $state("");
|
||||
|
||||
hasQuestionPending.subscribe((pending) => {
|
||||
isVisible = pending;
|
||||
if (!pending) {
|
||||
selectedOptions.clear();
|
||||
customAnswer = "";
|
||||
showCustomInput = false;
|
||||
}
|
||||
});
|
||||
|
||||
claudeStore.pendingQuestion.subscribe((q) => {
|
||||
question = q;
|
||||
if (q) {
|
||||
characterState.setState("permission");
|
||||
}
|
||||
});
|
||||
|
||||
claudeStore.grantedTools.subscribe((tools) => {
|
||||
grantedToolsList = Array.from(tools);
|
||||
});
|
||||
|
||||
claudeStore.currentWorkingDirectory.subscribe((dir) => {
|
||||
workingDirectory = dir;
|
||||
});
|
||||
|
||||
function toggleOption(label: string) {
|
||||
if (!question) return;
|
||||
|
||||
if (question.multi_select) {
|
||||
if (selectedOptions.has(label)) {
|
||||
selectedOptions.delete(label);
|
||||
} else {
|
||||
selectedOptions.add(label);
|
||||
}
|
||||
} else {
|
||||
selectedOptions.clear();
|
||||
selectedOptions.add(label);
|
||||
}
|
||||
showCustomInput = false;
|
||||
}
|
||||
|
||||
function selectCustom() {
|
||||
showCustomInput = true;
|
||||
selectedOptions.clear();
|
||||
}
|
||||
|
||||
async function handleSubmitAndReconnect() {
|
||||
if (!question) return;
|
||||
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) return;
|
||||
|
||||
let answerText: string;
|
||||
|
||||
if (showCustomInput && customAnswer.trim()) {
|
||||
answerText = customAnswer.trim();
|
||||
} else if (selectedOptions.size > 0) {
|
||||
if (question.multi_select) {
|
||||
answerText = Array.from(selectedOptions).join(", ");
|
||||
} else {
|
||||
answerText = Array.from(selectedOptions)[0];
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const questionText = question.question;
|
||||
const conversationHistory = claudeStore.getConversationHistory();
|
||||
|
||||
claudeStore.addLine("system", `Answer: ${answerText}. Reconnecting with context...`);
|
||||
claudeStore.clearQuestion();
|
||||
|
||||
try {
|
||||
await invoke("stop_claude", { conversationId });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDirectory || "/home/naomi",
|
||||
allowed_tools: grantedToolsList,
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
if (conversationHistory) {
|
||||
const contextMessage = `[CONTEXT RESTORATION]
|
||||
I just answered your question. Here's our conversation so far:
|
||||
|
||||
${conversationHistory}
|
||||
|
||||
You asked me: "${questionText}"
|
||||
My answer: "${answerText}"
|
||||
|
||||
Please continue where we left off, taking my answer into account.`;
|
||||
|
||||
await invoke("send_prompt", {
|
||||
conversationId,
|
||||
message: contextMessage,
|
||||
});
|
||||
}
|
||||
|
||||
characterState.setTemporaryState("success", 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to reconnect:", error);
|
||||
claudeStore.addLine("error", `Reconnect failed: ${error}`);
|
||||
characterState.setTemporaryState("error", 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
claudeStore.clearQuestion();
|
||||
claudeStore.addLine("system", "Question dismissed");
|
||||
characterState.setTemporaryState("idle", 1000);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!isVisible || !question) return;
|
||||
|
||||
if (event.key === "Enter" && !showCustomInput) {
|
||||
event.preventDefault();
|
||||
if (selectedOptions.size > 0) {
|
||||
handleSubmitAndReconnect();
|
||||
}
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
handleDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
function canSubmit(): boolean {
|
||||
return selectedOptions.size > 0 || (showCustomInput && customAnswer.trim().length > 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if isVisible && question}
|
||||
<div
|
||||
class="question-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
class="question-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
|
||||
<span class="text-xl">?</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{question.header || "Question"}
|
||||
</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">Hikari needs your input</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<p class="text-[var(--text-primary)]">{question.question}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 space-y-2">
|
||||
{#each question.options as option (option.label)}
|
||||
<button
|
||||
onclick={() => toggleOption(option.label)}
|
||||
class="w-full text-left px-4 py-3 rounded-lg border transition-colors {selectedOptions.has(
|
||||
option.label
|
||||
)
|
||||
? 'bg-[var(--accent-primary)]/20 border-[var(--accent-primary)] text-[var(--text-primary)]'
|
||||
: 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50'}"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="mt-0.5 w-5 h-5 rounded-{question.multi_select
|
||||
? 'sm'
|
||||
: 'full'} border-2 flex items-center justify-center {selectedOptions.has(
|
||||
option.label
|
||||
)
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary)]'
|
||||
: 'border-[var(--text-secondary)]'}"
|
||||
>
|
||||
{#if selectedOptions.has(option.label)}
|
||||
<span class="text-white text-xs">{question.multi_select ? "x" : "x"}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">{option.label}</div>
|
||||
{#if option.description}
|
||||
<div class="text-sm text-[var(--text-secondary)] mt-1">{option.description}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
onclick={selectCustom}
|
||||
class="w-full text-left px-4 py-3 rounded-lg border transition-colors {showCustomInput
|
||||
? 'bg-[var(--accent-primary)]/20 border-[var(--accent-primary)] text-[var(--text-primary)]'
|
||||
: 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50'}"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="mt-0.5 w-5 h-5 rounded-full border-2 flex items-center justify-center {showCustomInput
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary)]'
|
||||
: 'border-[var(--text-secondary)]'}"
|
||||
>
|
||||
{#if showCustomInput}
|
||||
<span class="text-white text-xs">x</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">Other</div>
|
||||
<div class="text-sm text-[var(--text-secondary)] mt-1">Provide a custom answer</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showCustomInput}
|
||||
<div class="mb-4">
|
||||
<textarea
|
||||
bind:value={customAnswer}
|
||||
placeholder="Type your answer here..."
|
||||
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-secondary)] resize-none focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={handleDismiss}
|
||||
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSubmitAndReconnect}
|
||||
disabled={!canSubmit()}
|
||||
class="flex-1 px-4 py-2 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Answer & Reconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NotificationType, NOTIFICATION_SOUNDS, type NotificationSound } from "./types";
|
||||
|
||||
// Mock HTMLAudioElement for soundPlayer tests
|
||||
class MockAudioElement {
|
||||
src: string = "";
|
||||
preload: string = "";
|
||||
volume: number = 1;
|
||||
|
||||
constructor(src?: string) {
|
||||
if (src) this.src = src;
|
||||
}
|
||||
|
||||
cloneNode(): MockAudioElement {
|
||||
const clone = new MockAudioElement(this.src);
|
||||
clone.volume = this.volume;
|
||||
return clone;
|
||||
}
|
||||
|
||||
async play(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
// Store original Audio before mocking
|
||||
const OriginalAudio = globalThis.Audio;
|
||||
|
||||
describe("notifications", () => {
|
||||
describe("NotificationType enum", () => {
|
||||
it("has SUCCESS type", () => {
|
||||
expect(NotificationType.SUCCESS).toBe("success");
|
||||
});
|
||||
|
||||
it("has ERROR type", () => {
|
||||
expect(NotificationType.ERROR).toBe("error");
|
||||
});
|
||||
|
||||
it("has PERMISSION type", () => {
|
||||
expect(NotificationType.PERMISSION).toBe("permission");
|
||||
});
|
||||
|
||||
it("has CONNECTION type", () => {
|
||||
expect(NotificationType.CONNECTION).toBe("connection");
|
||||
});
|
||||
|
||||
it("has TASK_START type", () => {
|
||||
expect(NotificationType.TASK_START).toBe("task_start");
|
||||
});
|
||||
|
||||
it("has ACHIEVEMENT type", () => {
|
||||
expect(NotificationType.ACHIEVEMENT).toBe("achievement");
|
||||
});
|
||||
|
||||
it("has exactly 6 notification types", () => {
|
||||
const types = Object.values(NotificationType);
|
||||
expect(types.length).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NOTIFICATION_SOUNDS constant", () => {
|
||||
it("has sounds for all notification types", () => {
|
||||
Object.values(NotificationType).forEach((type) => {
|
||||
expect(NOTIFICATION_SOUNDS[type]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("each sound has required properties", () => {
|
||||
Object.values(NOTIFICATION_SOUNDS).forEach((sound) => {
|
||||
expect(sound.type).toBeDefined();
|
||||
expect(sound.filename).toBeDefined();
|
||||
expect(sound.phrase).toBeDefined();
|
||||
expect(typeof sound.filename).toBe("string");
|
||||
expect(typeof sound.phrase).toBe("string");
|
||||
expect(sound.filename.endsWith(".mp3")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("SUCCESS sound has correct properties", () => {
|
||||
const sound = NOTIFICATION_SOUNDS[NotificationType.SUCCESS];
|
||||
expect(sound.type).toBe(NotificationType.SUCCESS);
|
||||
expect(sound.filename).toBe("im-done.mp3");
|
||||
expect(sound.phrase).toBe("I'm done!");
|
||||
expect(sound.volume).toBe(0.7);
|
||||
});
|
||||
|
||||
it("ERROR sound has correct properties", () => {
|
||||
const sound = NOTIFICATION_SOUNDS[NotificationType.ERROR];
|
||||
expect(sound.type).toBe(NotificationType.ERROR);
|
||||
expect(sound.filename).toBe("oh-no.mp3");
|
||||
expect(sound.phrase).toBe("Oh no...");
|
||||
expect(sound.volume).toBe(0.8);
|
||||
});
|
||||
|
||||
it("PERMISSION sound has correct properties", () => {
|
||||
const sound = NOTIFICATION_SOUNDS[NotificationType.PERMISSION];
|
||||
expect(sound.type).toBe(NotificationType.PERMISSION);
|
||||
expect(sound.filename).toBe("access-please.mp3");
|
||||
expect(sound.phrase).toBe("Access please!");
|
||||
expect(sound.volume).toBe(0.9);
|
||||
});
|
||||
|
||||
it("CONNECTION sound has correct properties", () => {
|
||||
const sound = NOTIFICATION_SOUNDS[NotificationType.CONNECTION];
|
||||
expect(sound.type).toBe(NotificationType.CONNECTION);
|
||||
expect(sound.filename).toBe("connected.mp3");
|
||||
expect(sound.phrase).toBe("Connected!");
|
||||
expect(sound.volume).toBe(0.7);
|
||||
});
|
||||
|
||||
it("TASK_START sound has correct properties", () => {
|
||||
const sound = NOTIFICATION_SOUNDS[NotificationType.TASK_START];
|
||||
expect(sound.type).toBe(NotificationType.TASK_START);
|
||||
expect(sound.filename).toBe("working-on-it.mp3");
|
||||
expect(sound.phrase).toBe("Working on it!");
|
||||
expect(sound.volume).toBe(0.6);
|
||||
});
|
||||
|
||||
it("ACHIEVEMENT sound has correct properties", () => {
|
||||
const sound = NOTIFICATION_SOUNDS[NotificationType.ACHIEVEMENT];
|
||||
expect(sound.type).toBe(NotificationType.ACHIEVEMENT);
|
||||
expect(sound.filename).toBe("achievement.mp3");
|
||||
expect(sound.phrase).toBe("Achievement Get~!");
|
||||
expect(sound.volume).toBe(0.8);
|
||||
});
|
||||
|
||||
it("all volumes are within valid range (0-1)", () => {
|
||||
Object.values(NOTIFICATION_SOUNDS).forEach((sound) => {
|
||||
if (sound.volume !== undefined) {
|
||||
expect(sound.volume).toBeGreaterThanOrEqual(0);
|
||||
expect(sound.volume).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationSound interface", () => {
|
||||
it("can create a valid notification sound object", () => {
|
||||
const sound: NotificationSound = {
|
||||
type: NotificationType.SUCCESS,
|
||||
filename: "test-sound.mp3",
|
||||
phrase: "Test phrase",
|
||||
volume: 0.5,
|
||||
};
|
||||
|
||||
expect(sound.type).toBe(NotificationType.SUCCESS);
|
||||
expect(sound.filename).toBe("test-sound.mp3");
|
||||
expect(sound.phrase).toBe("Test phrase");
|
||||
expect(sound.volume).toBe(0.5);
|
||||
});
|
||||
|
||||
it("volume is optional", () => {
|
||||
const sound: NotificationSound = {
|
||||
type: NotificationType.ERROR,
|
||||
filename: "error.mp3",
|
||||
phrase: "Error occurred",
|
||||
};
|
||||
|
||||
expect(sound.volume).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SoundPlayer class", () => {
|
||||
beforeEach(() => {
|
||||
// Mock Audio constructor
|
||||
globalThis.Audio = MockAudioElement as unknown as typeof Audio;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original Audio
|
||||
globalThis.Audio = OriginalAudio;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("can import soundPlayer singleton", async () => {
|
||||
const { soundPlayer } = await import("./soundPlayer");
|
||||
expect(soundPlayer).toBeDefined();
|
||||
});
|
||||
|
||||
it("setEnabled changes enabled state", async () => {
|
||||
const { soundPlayer } = await import("./soundPlayer");
|
||||
|
||||
soundPlayer.setEnabled(true);
|
||||
expect(soundPlayer.isEnabled()).toBe(true);
|
||||
|
||||
soundPlayer.setEnabled(false);
|
||||
expect(soundPlayer.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("starts disabled by default", async () => {
|
||||
// Need to reimport to get fresh instance behavior
|
||||
// But since it's a singleton, we just test the method
|
||||
const { soundPlayer } = await import("./soundPlayer");
|
||||
|
||||
// Reset to default state
|
||||
soundPlayer.setEnabled(false);
|
||||
expect(soundPlayer.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("setGlobalVolume clamps values to 0-1 range", async () => {
|
||||
const { soundPlayer } = await import("./soundPlayer");
|
||||
|
||||
// Test that it doesn't throw on edge cases
|
||||
soundPlayer.setGlobalVolume(0);
|
||||
soundPlayer.setGlobalVolume(1);
|
||||
soundPlayer.setGlobalVolume(0.5);
|
||||
|
||||
// Test clamping below 0
|
||||
soundPlayer.setGlobalVolume(-0.5);
|
||||
|
||||
// Test clamping above 1
|
||||
soundPlayer.setGlobalVolume(1.5);
|
||||
});
|
||||
|
||||
it("play returns early when disabled", async () => {
|
||||
const { soundPlayer } = await import("./soundPlayer");
|
||||
|
||||
soundPlayer.setEnabled(false);
|
||||
|
||||
// Should not throw when disabled
|
||||
await expect(soundPlayer.play(NotificationType.SUCCESS)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("play attempts to play when enabled", async () => {
|
||||
const { soundPlayer } = await import("./soundPlayer");
|
||||
|
||||
soundPlayer.setEnabled(true);
|
||||
|
||||
// Should not throw
|
||||
await expect(soundPlayer.play(NotificationType.SUCCESS)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationManager class", () => {
|
||||
beforeEach(() => {
|
||||
globalThis.Audio = MockAudioElement as unknown as typeof Audio;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.Audio = OriginalAudio;
|
||||
});
|
||||
|
||||
it("can import notificationManager singleton", async () => {
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
||||
}));
|
||||
|
||||
const { notificationManager } = await import("./notificationManager");
|
||||
expect(notificationManager).toBeDefined();
|
||||
});
|
||||
|
||||
it("has notifySuccess method", async () => {
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
||||
}));
|
||||
|
||||
const { notificationManager } = await import("./notificationManager");
|
||||
expect(typeof notificationManager.notifySuccess).toBe("function");
|
||||
});
|
||||
|
||||
it("has notifyError method", async () => {
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
||||
}));
|
||||
|
||||
const { notificationManager } = await import("./notificationManager");
|
||||
expect(typeof notificationManager.notifyError).toBe("function");
|
||||
});
|
||||
|
||||
it("has notifyPermission method", async () => {
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
||||
}));
|
||||
|
||||
const { notificationManager } = await import("./notificationManager");
|
||||
expect(typeof notificationManager.notifyPermission).toBe("function");
|
||||
});
|
||||
|
||||
it("has notifyConnection method", async () => {
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
||||
}));
|
||||
|
||||
const { notificationManager } = await import("./notificationManager");
|
||||
expect(typeof notificationManager.notifyConnection).toBe("function");
|
||||
});
|
||||
|
||||
it("has notifyTaskStart method", async () => {
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
||||
}));
|
||||
|
||||
const { notificationManager } = await import("./notificationManager");
|
||||
expect(typeof notificationManager.notifyTaskStart).toBe("function");
|
||||
});
|
||||
|
||||
it("has notify method", async () => {
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
||||
}));
|
||||
|
||||
const { notificationManager } = await import("./notificationManager");
|
||||
expect(typeof notificationManager.notify).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("notification sounds file paths", () => {
|
||||
it("all sound files have valid paths", () => {
|
||||
Object.values(NOTIFICATION_SOUNDS).forEach((sound) => {
|
||||
// Check that filename doesn't contain path traversal
|
||||
expect(sound.filename).not.toContain("..");
|
||||
expect(sound.filename).not.toContain("/");
|
||||
expect(sound.filename).not.toContain("\\");
|
||||
});
|
||||
});
|
||||
|
||||
it("sound filenames are unique", () => {
|
||||
const filenames = Object.values(NOTIFICATION_SOUNDS).map((s) => s.filename);
|
||||
const uniqueFilenames = new Set(filenames);
|
||||
expect(uniqueFilenames.size).toBe(filenames.length);
|
||||
});
|
||||
|
||||
it("phrases are unique", () => {
|
||||
const phrases = Object.values(NOTIFICATION_SOUNDS).map((s) => s.phrase);
|
||||
const uniquePhrases = new Set(phrases);
|
||||
expect(uniquePhrases.size).toBe(phrases.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,9 +21,11 @@ export const claudeStore = {
|
||||
currentWorkingDirectory: conversationsStore.currentWorkingDirectory,
|
||||
terminalLines: conversationsStore.terminalLines,
|
||||
pendingPermission: conversationsStore.pendingPermission,
|
||||
pendingQuestion: conversationsStore.pendingQuestion,
|
||||
isProcessing: conversationsStore.isProcessing,
|
||||
grantedTools: conversationsStore.grantedTools,
|
||||
pendingRetryMessage: conversationsStore.pendingRetryMessage,
|
||||
attachments: conversationsStore.attachments,
|
||||
|
||||
// New conversation-aware subscriptions
|
||||
conversations: conversationsStore.conversations,
|
||||
@@ -47,6 +49,12 @@ export const claudeStore = {
|
||||
getConversationHistory: conversationsStore.getConversationHistory,
|
||||
requestPermission: conversationsStore.requestPermission,
|
||||
clearPermission: conversationsStore.clearPermission,
|
||||
requestPermissionForConversation: conversationsStore.requestPermissionForConversation,
|
||||
clearPermissionForConversation: conversationsStore.clearPermissionForConversation,
|
||||
requestQuestion: conversationsStore.requestQuestion,
|
||||
clearQuestion: conversationsStore.clearQuestion,
|
||||
requestQuestionForConversation: conversationsStore.requestQuestionForConversation,
|
||||
clearQuestionForConversation: conversationsStore.clearQuestionForConversation,
|
||||
grantTool: conversationsStore.grantTool,
|
||||
revokeAllTools: conversationsStore.revokeAllTools,
|
||||
isToolGranted: conversationsStore.isToolGranted,
|
||||
@@ -57,6 +65,14 @@ export const claudeStore = {
|
||||
deleteConversation: conversationsStore.deleteConversation,
|
||||
switchConversation: conversationsStore.switchConversation,
|
||||
renameConversation: conversationsStore.renameConversation,
|
||||
saveScrollPosition: conversationsStore.saveScrollPosition,
|
||||
getScrollPosition: conversationsStore.getScrollPosition,
|
||||
|
||||
// Attachment management
|
||||
addAttachment: conversationsStore.addAttachment,
|
||||
removeAttachment: conversationsStore.removeAttachment,
|
||||
clearAttachments: conversationsStore.clearAttachments,
|
||||
getAttachments: conversationsStore.getAttachments,
|
||||
|
||||
getGrantedTools: (): string[] => {
|
||||
let tools: string[] = [];
|
||||
@@ -77,14 +93,20 @@ export const claudeStore = {
|
||||
conversationsStore.setWorkingDirectory("");
|
||||
conversationsStore.setProcessing(false);
|
||||
conversationsStore.revokeAllTools();
|
||||
conversationsStore.clearAttachments();
|
||||
// Also clear history restoration
|
||||
clearHistoryRestore();
|
||||
},
|
||||
};
|
||||
|
||||
export const hasPermissionPending = derived(
|
||||
claudeStore.pendingPermission,
|
||||
($permission) => $permission !== null
|
||||
claudeStore.activeConversation,
|
||||
($conversation) => $conversation?.pendingPermission !== null
|
||||
);
|
||||
|
||||
export const hasQuestionPending = derived(
|
||||
claudeStore.activeConversation,
|
||||
($conversation) => $conversation?.pendingQuestion !== null
|
||||
);
|
||||
|
||||
// Derived store to check if Claude is currently processing (can be interrupted)
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// Clipboard history store for managing copied code snippets
|
||||
// Implements issue #25 - Clipboard History feature
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { writable, derived } from "svelte/store";
|
||||
|
||||
export interface ClipboardEntry {
|
||||
id: string;
|
||||
content: string;
|
||||
language: string | null;
|
||||
source: string | null;
|
||||
timestamp: string;
|
||||
is_pinned: boolean;
|
||||
}
|
||||
|
||||
// Create stores
|
||||
const entriesStore = writable<ClipboardEntry[]>([]);
|
||||
const searchQueryStore = writable<string>("");
|
||||
const languageFilterStore = writable<string | null>(null);
|
||||
const isLoadingStore = writable<boolean>(false);
|
||||
|
||||
// Derived store for filtered entries
|
||||
const filteredEntriesStore = derived(
|
||||
[entriesStore, searchQueryStore, languageFilterStore],
|
||||
([$entries, $searchQuery, $languageFilter]) => {
|
||||
let filtered = $entries;
|
||||
|
||||
// Filter by language
|
||||
if ($languageFilter) {
|
||||
filtered = filtered.filter((e) => e.language === $languageFilter);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if ($searchQuery) {
|
||||
const query = $searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(e) =>
|
||||
e.content.toLowerCase().includes(query) ||
|
||||
e.language?.toLowerCase().includes(query) ||
|
||||
e.source?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
);
|
||||
|
||||
// Derived store for unique languages
|
||||
const languagesStore = derived(entriesStore, ($entries) => {
|
||||
const languages = new Set<string>();
|
||||
for (const entry of $entries) {
|
||||
if (entry.language) {
|
||||
languages.add(entry.language);
|
||||
}
|
||||
}
|
||||
return Array.from(languages).sort();
|
||||
});
|
||||
|
||||
// Helper function to detect language from content
|
||||
function detectLanguage(content: string): string | null {
|
||||
// Common language patterns
|
||||
const patterns: [RegExp, string][] = [
|
||||
[/^(import|export|const|let|var|function|class|interface|type)\s/m, "typescript"],
|
||||
[/^(def|class|import|from|if __name__|async def)\s/m, "python"],
|
||||
[/^(fn|let|mut|impl|struct|enum|use|mod|pub)\s/m, "rust"],
|
||||
[/^(package|import|func|type|var|const)\s/m, "go"],
|
||||
[/<\?php/m, "php"],
|
||||
[/^(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)\s/im, "sql"],
|
||||
[/^<!DOCTYPE|^<html|^<div|^<span/im, "html"],
|
||||
[/^\s*\{[\s\S]*"[\w-]+":/m, "json"],
|
||||
[/^---\s*\n/m, "yaml"],
|
||||
[/^\s*#\s*(include|define|ifdef)/m, "c"],
|
||||
[/^(public|private|protected)\s+(class|interface|static)/m, "java"],
|
||||
[/^\$[\w_]+\s*=/m, "bash"],
|
||||
];
|
||||
|
||||
for (const [pattern, lang] of patterns) {
|
||||
if (pattern.test(content)) {
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Store actions
|
||||
async function loadEntries(): Promise<void> {
|
||||
isLoadingStore.set(true);
|
||||
try {
|
||||
const entries = await invoke<ClipboardEntry[]>("list_clipboard_entries", {
|
||||
language: null,
|
||||
});
|
||||
entriesStore.set(entries);
|
||||
} catch (error) {
|
||||
console.error("Failed to load clipboard entries:", error);
|
||||
} finally {
|
||||
isLoadingStore.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function captureClipboard(
|
||||
content: string,
|
||||
language?: string | null,
|
||||
source?: string | null
|
||||
): Promise<ClipboardEntry | null> {
|
||||
try {
|
||||
// Auto-detect language if not provided
|
||||
const detectedLanguage = language ?? detectLanguage(content);
|
||||
|
||||
const entry = await invoke<ClipboardEntry>("capture_clipboard", {
|
||||
content,
|
||||
language: detectedLanguage,
|
||||
source: source ?? null,
|
||||
});
|
||||
|
||||
// Reload entries to get the updated list
|
||||
await loadEntries();
|
||||
|
||||
return entry;
|
||||
} catch (error) {
|
||||
console.error("Failed to capture clipboard:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEntry(id: string): Promise<void> {
|
||||
try {
|
||||
await invoke("delete_clipboard_entry", { id });
|
||||
entriesStore.update((entries) => entries.filter((e) => e.id !== id));
|
||||
} catch (error) {
|
||||
console.error("Failed to delete clipboard entry:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePin(id: string): Promise<void> {
|
||||
try {
|
||||
const updated = await invoke<ClipboardEntry>("toggle_pin_clipboard_entry", { id });
|
||||
entriesStore.update((entries) =>
|
||||
entries
|
||||
.map((e) => (e.id === id ? updated : e))
|
||||
.sort((a, b) => {
|
||||
// Pinned first, then by timestamp
|
||||
if (a.is_pinned && !b.is_pinned) return -1;
|
||||
if (!a.is_pinned && b.is_pinned) return 1;
|
||||
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle pin:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearHistory(): Promise<void> {
|
||||
try {
|
||||
await invoke("clear_clipboard_history");
|
||||
entriesStore.update((entries) => entries.filter((e) => e.is_pinned));
|
||||
} catch (error) {
|
||||
console.error("Failed to clear clipboard history:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLanguage(id: string, language: string | null): Promise<void> {
|
||||
try {
|
||||
const updated = await invoke<ClipboardEntry>("update_clipboard_language", {
|
||||
id,
|
||||
language,
|
||||
});
|
||||
entriesStore.update((entries) => entries.map((e) => (e.id === id ? updated : e)));
|
||||
} catch (error) {
|
||||
console.error("Failed to update language:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function setSearchQuery(query: string): void {
|
||||
searchQueryStore.set(query);
|
||||
}
|
||||
|
||||
function setLanguageFilter(language: string | null): void {
|
||||
languageFilterStore.set(language);
|
||||
}
|
||||
|
||||
// Copy entry content to clipboard
|
||||
async function copyToClipboard(content: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to copy to clipboard:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Format timestamp for display
|
||||
function formatTimestamp(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// Export the store
|
||||
export const clipboardStore = {
|
||||
subscribe: entriesStore.subscribe,
|
||||
entries: entriesStore,
|
||||
filteredEntries: filteredEntriesStore,
|
||||
languages: languagesStore,
|
||||
searchQuery: searchQueryStore,
|
||||
languageFilter: languageFilterStore,
|
||||
isLoading: isLoadingStore,
|
||||
loadEntries,
|
||||
captureClipboard,
|
||||
deleteEntry,
|
||||
togglePin,
|
||||
clearHistory,
|
||||
updateLanguage,
|
||||
setSearchQuery,
|
||||
setLanguageFilter,
|
||||
copyToClipboard,
|
||||
formatTimestamp,
|
||||
detectLanguage,
|
||||
};
|
||||
@@ -0,0 +1,480 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import {
|
||||
configStore,
|
||||
maskPaths,
|
||||
clampFontSize,
|
||||
applyFontSize,
|
||||
applyTheme,
|
||||
applyCustomThemeColors,
|
||||
clearCustomThemeColors,
|
||||
MIN_FONT_SIZE,
|
||||
MAX_FONT_SIZE,
|
||||
DEFAULT_FONT_SIZE,
|
||||
type HikariConfig,
|
||||
type Theme,
|
||||
type CustomThemeColors,
|
||||
} from "./config";
|
||||
|
||||
// Mock Tauri APIs
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("config store", () => {
|
||||
describe("font size constants", () => {
|
||||
it("has correct MIN_FONT_SIZE", () => {
|
||||
expect(MIN_FONT_SIZE).toBe(10);
|
||||
});
|
||||
|
||||
it("has correct MAX_FONT_SIZE", () => {
|
||||
expect(MAX_FONT_SIZE).toBe(24);
|
||||
});
|
||||
|
||||
it("has correct DEFAULT_FONT_SIZE", () => {
|
||||
expect(DEFAULT_FONT_SIZE).toBe(14);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampFontSize", () => {
|
||||
it("returns the same value when within range", () => {
|
||||
expect(clampFontSize(14)).toBe(14);
|
||||
expect(clampFontSize(10)).toBe(10);
|
||||
expect(clampFontSize(24)).toBe(24);
|
||||
expect(clampFontSize(18)).toBe(18);
|
||||
});
|
||||
|
||||
it("clamps values below minimum", () => {
|
||||
expect(clampFontSize(5)).toBe(MIN_FONT_SIZE);
|
||||
expect(clampFontSize(0)).toBe(MIN_FONT_SIZE);
|
||||
expect(clampFontSize(-10)).toBe(MIN_FONT_SIZE);
|
||||
expect(clampFontSize(9)).toBe(MIN_FONT_SIZE);
|
||||
});
|
||||
|
||||
it("clamps values above maximum", () => {
|
||||
expect(clampFontSize(30)).toBe(MAX_FONT_SIZE);
|
||||
expect(clampFontSize(100)).toBe(MAX_FONT_SIZE);
|
||||
expect(clampFontSize(25)).toBe(MAX_FONT_SIZE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskPaths", () => {
|
||||
it("returns text unchanged when hidePaths is false", () => {
|
||||
const text = "/home/naomi/code/project/file.ts";
|
||||
expect(maskPaths(text, false)).toBe(text);
|
||||
});
|
||||
|
||||
it("masks Unix home paths", () => {
|
||||
const text = "/home/naomi/code/project/file.ts";
|
||||
expect(maskPaths(text, true)).toBe("/home/****/code/project/file.ts");
|
||||
});
|
||||
|
||||
it("masks macOS user paths", () => {
|
||||
const text = "/Users/naomi/Documents/project/file.ts";
|
||||
expect(maskPaths(text, true)).toBe("/Users/****/Documents/project/file.ts");
|
||||
});
|
||||
|
||||
it("masks Windows user paths", () => {
|
||||
const text = "C:\\Users\\naomi\\Documents\\project\\file.ts";
|
||||
expect(maskPaths(text, true)).toBe("C:\\Users\\****\\Documents\\project\\file.ts");
|
||||
});
|
||||
|
||||
it("masks tilde paths", () => {
|
||||
const text = "~/code/project/file.ts";
|
||||
expect(maskPaths(text, true)).toBe("****/code/project/file.ts");
|
||||
});
|
||||
|
||||
it("masks multiple paths in the same text", () => {
|
||||
const text = "Editing /home/naomi/file1.ts and /home/naomi/file2.ts";
|
||||
expect(maskPaths(text, true)).toBe("Editing /home/****/file1.ts and /home/****/file2.ts");
|
||||
});
|
||||
|
||||
it("handles mixed path types", () => {
|
||||
const text = "Unix: /home/user/file, Mac: /Users/user/file, Win: C:\\Users\\user\\file";
|
||||
const expected = "Unix: /home/****/file, Mac: /Users/****/file, Win: C:\\Users\\****\\file";
|
||||
expect(maskPaths(text, true)).toBe(expected);
|
||||
});
|
||||
|
||||
it("handles paths with special characters in username", () => {
|
||||
const text = "/home/user-name_123/project";
|
||||
expect(maskPaths(text, true)).toBe("/home/****/project");
|
||||
});
|
||||
|
||||
it("does not mask non-path text", () => {
|
||||
const text = "This is just regular text without any paths";
|
||||
expect(maskPaths(text, true)).toBe(text);
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
expect(maskPaths("", true)).toBe("");
|
||||
expect(maskPaths("", false)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Theme type", () => {
|
||||
it("accepts valid theme values", () => {
|
||||
const themes: Theme[] = ["dark", "light", "high-contrast", "custom"];
|
||||
themes.forEach((theme) => {
|
||||
expect(["dark", "light", "high-contrast", "custom"]).toContain(theme);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CustomThemeColors interface", () => {
|
||||
it("can create a valid custom theme colors object", () => {
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: "#1a1a2e",
|
||||
bg_secondary: "#16213e",
|
||||
bg_terminal: "#0f0f23",
|
||||
accent_primary: "#e94560",
|
||||
accent_secondary: "#533483",
|
||||
text_primary: "#eaeaea",
|
||||
text_secondary: "#a0a0a0",
|
||||
border_color: "#333355",
|
||||
};
|
||||
|
||||
expect(colors.bg_primary).toBe("#1a1a2e");
|
||||
expect(colors.accent_primary).toBe("#e94560");
|
||||
});
|
||||
|
||||
it("allows null values for optional colors", () => {
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: null,
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: "#e94560",
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
};
|
||||
|
||||
expect(colors.bg_primary).toBeNull();
|
||||
expect(colors.accent_primary).toBe("#e94560");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HikariConfig interface", () => {
|
||||
it("can create a valid config object with all fields", () => {
|
||||
const config: HikariConfig = {
|
||||
model: "claude-sonnet-4",
|
||||
api_key: "test-key",
|
||||
custom_instructions: "Be helpful",
|
||||
mcp_servers_json: "{}",
|
||||
auto_granted_tools: ["Read", "Write"],
|
||||
theme: "dark",
|
||||
greeting_enabled: true,
|
||||
greeting_custom_prompt: "Hello!",
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: false,
|
||||
minimize_to_tray: true,
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: 300,
|
||||
font_size: 14,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
profile_name: "Naomi",
|
||||
profile_avatar_path: "/path/to/avatar.png",
|
||||
profile_bio: "Developer",
|
||||
custom_theme_colors: {
|
||||
bg_primary: null,
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
};
|
||||
|
||||
expect(config.model).toBe("claude-sonnet-4");
|
||||
expect(config.auto_granted_tools).toEqual(["Read", "Write"]);
|
||||
expect(config.theme).toBe("dark");
|
||||
});
|
||||
|
||||
it("allows null values for optional fields", () => {
|
||||
const config: HikariConfig = {
|
||||
model: null,
|
||||
api_key: null,
|
||||
custom_instructions: null,
|
||||
mcp_servers_json: null,
|
||||
auto_granted_tools: [],
|
||||
theme: "dark",
|
||||
greeting_enabled: true,
|
||||
greeting_custom_prompt: null,
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: false,
|
||||
minimize_to_tray: false,
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: null,
|
||||
font_size: 14,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
profile_name: null,
|
||||
profile_avatar_path: null,
|
||||
profile_bio: null,
|
||||
custom_theme_colors: {
|
||||
bg_primary: null,
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
};
|
||||
|
||||
expect(config.model).toBeNull();
|
||||
expect(config.api_key).toBeNull();
|
||||
expect(config.character_panel_width).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyFontSize", () => {
|
||||
beforeEach(() => {
|
||||
// Reset document state
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.style.removeProperty("--terminal-font-size");
|
||||
}
|
||||
});
|
||||
|
||||
it("sets CSS variable for valid font size", () => {
|
||||
applyFontSize(16);
|
||||
const value = document.documentElement.style.getPropertyValue("--terminal-font-size");
|
||||
expect(value).toBe("16px");
|
||||
});
|
||||
|
||||
it("clamps font size below minimum", () => {
|
||||
applyFontSize(5);
|
||||
const value = document.documentElement.style.getPropertyValue("--terminal-font-size");
|
||||
expect(value).toBe(`${MIN_FONT_SIZE}px`);
|
||||
});
|
||||
|
||||
it("clamps font size above maximum", () => {
|
||||
applyFontSize(50);
|
||||
const value = document.documentElement.style.getPropertyValue("--terminal-font-size");
|
||||
expect(value).toBe(`${MAX_FONT_SIZE}px`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyTheme", () => {
|
||||
beforeEach(() => {
|
||||
// Reset document state
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
clearCustomThemeColors();
|
||||
}
|
||||
});
|
||||
|
||||
it("sets data-theme attribute for dark theme", () => {
|
||||
applyTheme("dark");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||
});
|
||||
|
||||
it("sets data-theme attribute for light theme", () => {
|
||||
applyTheme("light");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||
});
|
||||
|
||||
it("sets data-theme attribute for high-contrast theme", () => {
|
||||
applyTheme("high-contrast");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("high-contrast");
|
||||
});
|
||||
|
||||
it("uses dark as base for custom theme", () => {
|
||||
applyTheme("custom");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||
});
|
||||
|
||||
it("applies custom colors when theme is custom", () => {
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: "#1a1a2e",
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: "#e94560",
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
};
|
||||
|
||||
applyTheme("custom", colors);
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#1a1a2e");
|
||||
expect(document.documentElement.style.getPropertyValue("--accent-primary")).toBe("#e94560");
|
||||
});
|
||||
|
||||
it("does not apply custom colors for non-custom themes", () => {
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: "#1a1a2e",
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
};
|
||||
|
||||
applyTheme("dark", colors);
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCustomThemeColors", () => {
|
||||
beforeEach(() => {
|
||||
clearCustomThemeColors();
|
||||
});
|
||||
|
||||
it("applies all provided colors", () => {
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: "#111111",
|
||||
bg_secondary: "#222222",
|
||||
bg_terminal: "#333333",
|
||||
accent_primary: "#444444",
|
||||
accent_secondary: "#555555",
|
||||
text_primary: "#666666",
|
||||
text_secondary: "#777777",
|
||||
border_color: "#888888",
|
||||
};
|
||||
|
||||
applyCustomThemeColors(colors);
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#111111");
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-secondary")).toBe("#222222");
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-terminal")).toBe("#333333");
|
||||
expect(document.documentElement.style.getPropertyValue("--accent-primary")).toBe("#444444");
|
||||
expect(document.documentElement.style.getPropertyValue("--accent-secondary")).toBe("#555555");
|
||||
expect(document.documentElement.style.getPropertyValue("--text-primary")).toBe("#666666");
|
||||
expect(document.documentElement.style.getPropertyValue("--text-secondary")).toBe("#777777");
|
||||
expect(document.documentElement.style.getPropertyValue("--border-color")).toBe("#888888");
|
||||
});
|
||||
|
||||
it("skips null values", () => {
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: "#111111",
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
};
|
||||
|
||||
applyCustomThemeColors(colors);
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#111111");
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-secondary")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearCustomThemeColors", () => {
|
||||
it("removes all custom theme CSS properties", () => {
|
||||
// First apply some colors
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: "#111111",
|
||||
bg_secondary: "#222222",
|
||||
bg_terminal: "#333333",
|
||||
accent_primary: "#444444",
|
||||
accent_secondary: "#555555",
|
||||
text_primary: "#666666",
|
||||
text_secondary: "#777777",
|
||||
border_color: "#888888",
|
||||
};
|
||||
applyCustomThemeColors(colors);
|
||||
|
||||
// Then clear them
|
||||
clearCustomThemeColors();
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("");
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-secondary")).toBe("");
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-terminal")).toBe("");
|
||||
expect(document.documentElement.style.getPropertyValue("--accent-primary")).toBe("");
|
||||
expect(document.documentElement.style.getPropertyValue("--accent-secondary")).toBe("");
|
||||
expect(document.documentElement.style.getPropertyValue("--text-primary")).toBe("");
|
||||
expect(document.documentElement.style.getPropertyValue("--text-secondary")).toBe("");
|
||||
expect(document.documentElement.style.getPropertyValue("--border-color")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("derived stores", () => {
|
||||
// Note: These tests verify the derived store logic by testing the derivation functions
|
||||
// The actual stores depend on configStore which requires Tauri invoke mocking
|
||||
|
||||
it("isDarkTheme returns true for dark theme config", () => {
|
||||
// Test the derivation logic
|
||||
const darkConfig = { theme: "dark" as Theme };
|
||||
expect(darkConfig.theme === "dark").toBe(true);
|
||||
});
|
||||
|
||||
it("isDarkTheme returns false for light theme config", () => {
|
||||
const lightConfig = { theme: "light" as Theme };
|
||||
expect(lightConfig.theme === "dark").toBe(false);
|
||||
});
|
||||
|
||||
it("isStreamerMode derives from streamer_mode config", () => {
|
||||
const configWithStreamerMode = { streamer_mode: true };
|
||||
const configWithoutStreamerMode = { streamer_mode: false };
|
||||
|
||||
expect(configWithStreamerMode.streamer_mode).toBe(true);
|
||||
expect(configWithoutStreamerMode.streamer_mode).toBe(false);
|
||||
});
|
||||
|
||||
it("isCompactMode derives from compact_mode config", () => {
|
||||
const configWithCompactMode = { compact_mode: true };
|
||||
const configWithoutCompactMode = { compact_mode: false };
|
||||
|
||||
expect(configWithCompactMode.compact_mode).toBe(true);
|
||||
expect(configWithoutCompactMode.compact_mode).toBe(false);
|
||||
});
|
||||
|
||||
it("shouldHidePaths requires both streamer_mode and streamer_hide_paths", () => {
|
||||
const config1 = { streamer_mode: true, streamer_hide_paths: true };
|
||||
const config2 = { streamer_mode: true, streamer_hide_paths: false };
|
||||
const config3 = { streamer_mode: false, streamer_hide_paths: true };
|
||||
const config4 = { streamer_mode: false, streamer_hide_paths: false };
|
||||
|
||||
expect(config1.streamer_mode && config1.streamer_hide_paths).toBe(true);
|
||||
expect(config2.streamer_mode && config2.streamer_hide_paths).toBe(false);
|
||||
expect(config3.streamer_mode && config3.streamer_hide_paths).toBe(false);
|
||||
expect(config4.streamer_mode && config4.streamer_hide_paths).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configStore methods", () => {
|
||||
it("has all expected methods", () => {
|
||||
expect(typeof configStore.loadConfig).toBe("function");
|
||||
expect(typeof configStore.saveConfig).toBe("function");
|
||||
expect(typeof configStore.updateConfig).toBe("function");
|
||||
expect(typeof configStore.openSidebar).toBe("function");
|
||||
expect(typeof configStore.closeSidebar).toBe("function");
|
||||
expect(typeof configStore.toggleSidebar).toBe("function");
|
||||
expect(typeof configStore.setTheme).toBe("function");
|
||||
expect(typeof configStore.setCustomThemeColors).toBe("function");
|
||||
expect(typeof configStore.setFontSize).toBe("function");
|
||||
expect(typeof configStore.increaseFontSize).toBe("function");
|
||||
expect(typeof configStore.decreaseFontSize).toBe("function");
|
||||
expect(typeof configStore.resetFontSize).toBe("function");
|
||||
expect(typeof configStore.addAutoGrantedTool).toBe("function");
|
||||
expect(typeof configStore.removeAutoGrantedTool).toBe("function");
|
||||
expect(typeof configStore.getConfig).toBe("function");
|
||||
expect(typeof configStore.toggleStreamerMode).toBe("function");
|
||||
expect(typeof configStore.toggleCompactMode).toBe("function");
|
||||
expect(typeof configStore.setCompactMode).toBe("function");
|
||||
});
|
||||
|
||||
it("has subscribable stores", () => {
|
||||
expect(typeof configStore.config.subscribe).toBe("function");
|
||||
expect(typeof configStore.isLoading.subscribe).toBe("function");
|
||||
expect(typeof configStore.isSidebarOpen.subscribe).toBe("function");
|
||||
expect(typeof configStore.saveError.subscribe).toBe("function");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,18 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export type Theme = "dark" | "light";
|
||||
export type Theme = "dark" | "light" | "high-contrast" | "custom";
|
||||
|
||||
export interface CustomThemeColors {
|
||||
bg_primary: string | null;
|
||||
bg_secondary: string | null;
|
||||
bg_terminal: string | null;
|
||||
accent_primary: string | null;
|
||||
accent_secondary: string | null;
|
||||
text_primary: string | null;
|
||||
text_secondary: string | null;
|
||||
border_color: string | null;
|
||||
}
|
||||
|
||||
export interface HikariConfig {
|
||||
model: string | null;
|
||||
@@ -14,6 +25,18 @@ export interface HikariConfig {
|
||||
greeting_custom_prompt: string | null;
|
||||
notifications_enabled: boolean;
|
||||
notification_volume: number;
|
||||
always_on_top: boolean;
|
||||
minimize_to_tray: boolean;
|
||||
update_checks_enabled: boolean;
|
||||
character_panel_width: number | null;
|
||||
font_size: number;
|
||||
streamer_mode: boolean;
|
||||
streamer_hide_paths: boolean;
|
||||
compact_mode: boolean;
|
||||
profile_name: string | null;
|
||||
profile_avatar_path: string | null;
|
||||
profile_bio: string | null;
|
||||
custom_theme_colors: CustomThemeColors;
|
||||
}
|
||||
|
||||
const defaultConfig: HikariConfig = {
|
||||
@@ -27,6 +50,27 @@ const defaultConfig: HikariConfig = {
|
||||
greeting_custom_prompt: null,
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: false,
|
||||
minimize_to_tray: false,
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: null,
|
||||
font_size: 14,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
profile_name: null,
|
||||
profile_avatar_path: null,
|
||||
profile_bio: null,
|
||||
custom_theme_colors: {
|
||||
bg_primary: null,
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
};
|
||||
|
||||
function createConfigStore() {
|
||||
@@ -82,9 +126,51 @@ function createConfigStore() {
|
||||
closeSidebar: () => isSidebarOpen.set(false),
|
||||
toggleSidebar: () => isSidebarOpen.update((open) => !open),
|
||||
|
||||
setTheme: async (theme: Theme) => {
|
||||
await updateConfig({ theme });
|
||||
applyTheme(theme);
|
||||
setTheme: async (theme: Theme, customColors?: CustomThemeColors) => {
|
||||
const updates: Partial<HikariConfig> = { theme };
|
||||
if (customColors) {
|
||||
updates.custom_theme_colors = customColors;
|
||||
}
|
||||
await updateConfig(updates);
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
applyTheme(theme, currentConfig.custom_theme_colors);
|
||||
},
|
||||
|
||||
setCustomThemeColors: async (colors: CustomThemeColors) => {
|
||||
await updateConfig({ custom_theme_colors: colors });
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
if (currentConfig.theme === "custom") {
|
||||
applyCustomThemeColors(colors);
|
||||
}
|
||||
},
|
||||
|
||||
setFontSize: async (size: number) => {
|
||||
const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
|
||||
await updateConfig({ font_size: clampedSize });
|
||||
applyFontSize(clampedSize);
|
||||
},
|
||||
|
||||
increaseFontSize: async () => {
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2);
|
||||
await updateConfig({ font_size: newSize });
|
||||
applyFontSize(newSize);
|
||||
},
|
||||
|
||||
decreaseFontSize: async () => {
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2);
|
||||
await updateConfig({ font_size: newSize });
|
||||
applyFontSize(newSize);
|
||||
},
|
||||
|
||||
resetFontSize: async () => {
|
||||
await updateConfig({ font_size: DEFAULT_FONT_SIZE });
|
||||
applyFontSize(DEFAULT_FONT_SIZE);
|
||||
},
|
||||
|
||||
addAutoGrantedTool: async (tool: string) => {
|
||||
@@ -108,15 +194,112 @@ function createConfigStore() {
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
return currentConfig;
|
||||
},
|
||||
|
||||
toggleStreamerMode: async () => {
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
await updateConfig({ streamer_mode: !currentConfig.streamer_mode });
|
||||
},
|
||||
|
||||
toggleCompactMode: async () => {
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
await updateConfig({ compact_mode: !currentConfig.compact_mode });
|
||||
},
|
||||
|
||||
setCompactMode: async (enabled: boolean) => {
|
||||
await updateConfig({ compact_mode: enabled });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyTheme(theme: Theme) {
|
||||
export function applyTheme(theme: Theme, customColors?: CustomThemeColors) {
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
// For custom theme, we use dark as the base and override with custom colors
|
||||
document.documentElement.setAttribute("data-theme", theme === "custom" ? "dark" : theme);
|
||||
|
||||
// Clear any previously applied custom colors
|
||||
clearCustomThemeColors();
|
||||
|
||||
// Apply custom colors if theme is custom
|
||||
if (theme === "custom" && customColors) {
|
||||
applyCustomThemeColors(customColors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyCustomThemeColors(colors: CustomThemeColors) {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const root = document.documentElement;
|
||||
if (colors.bg_primary) root.style.setProperty("--bg-primary", colors.bg_primary);
|
||||
if (colors.bg_secondary) root.style.setProperty("--bg-secondary", colors.bg_secondary);
|
||||
if (colors.bg_terminal) root.style.setProperty("--bg-terminal", colors.bg_terminal);
|
||||
if (colors.accent_primary) root.style.setProperty("--accent-primary", colors.accent_primary);
|
||||
if (colors.accent_secondary)
|
||||
root.style.setProperty("--accent-secondary", colors.accent_secondary);
|
||||
if (colors.text_primary) root.style.setProperty("--text-primary", colors.text_primary);
|
||||
if (colors.text_secondary) root.style.setProperty("--text-secondary", colors.text_secondary);
|
||||
if (colors.border_color) root.style.setProperty("--border-color", colors.border_color);
|
||||
}
|
||||
|
||||
export function clearCustomThemeColors() {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const root = document.documentElement;
|
||||
const customProperties = [
|
||||
"--bg-primary",
|
||||
"--bg-secondary",
|
||||
"--bg-terminal",
|
||||
"--accent-primary",
|
||||
"--accent-secondary",
|
||||
"--text-primary",
|
||||
"--text-secondary",
|
||||
"--border-color",
|
||||
];
|
||||
customProperties.forEach((prop) => root.style.removeProperty(prop));
|
||||
}
|
||||
|
||||
const MIN_FONT_SIZE = 10;
|
||||
const MAX_FONT_SIZE = 24;
|
||||
const DEFAULT_FONT_SIZE = 14;
|
||||
|
||||
export function applyFontSize(size: number) {
|
||||
if (typeof document !== "undefined") {
|
||||
const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
|
||||
document.documentElement.style.setProperty("--terminal-font-size", `${clampedSize}px`);
|
||||
}
|
||||
}
|
||||
|
||||
export function clampFontSize(size: number): number {
|
||||
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
|
||||
}
|
||||
|
||||
export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
|
||||
|
||||
export const configStore = createConfigStore();
|
||||
|
||||
export const isDarkTheme = derived(configStore.config, ($config) => $config.theme === "dark");
|
||||
|
||||
export const isStreamerMode = derived(configStore.config, ($config) => $config.streamer_mode);
|
||||
export const isCompactMode = derived(configStore.config, ($config) => $config.compact_mode);
|
||||
export const shouldHidePaths = derived(
|
||||
configStore.config,
|
||||
($config) => $config.streamer_mode && $config.streamer_hide_paths
|
||||
);
|
||||
|
||||
/**
|
||||
* Masks file paths in text when streamer mode with hide paths is enabled.
|
||||
* Replaces username portion of paths with asterisks.
|
||||
*/
|
||||
export function maskPaths(text: string, hidePaths: boolean): string {
|
||||
if (!hidePaths) return text;
|
||||
|
||||
// Match Unix paths like /home/username/... or /Users/username/...
|
||||
// and Windows paths like C:\Users\username\...
|
||||
return text
|
||||
.replace(/\/home\/([^/\s]+)/g, "/home/****")
|
||||
.replace(/\/Users\/([^/\s]+)/g, "/Users/****")
|
||||
.replace(/C:\\Users\\([^\\\s]+)/gi, "C:\\Users\\****")
|
||||
.replace(/~\//g, "****/");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// Test the Conversation interface and store behavior
|
||||
describe("Conversation interface", () => {
|
||||
it("defines all required fields", () => {
|
||||
const conversation = {
|
||||
id: "conv-123",
|
||||
name: "Test Conversation",
|
||||
terminalLines: [],
|
||||
sessionId: null,
|
||||
connectionStatus: "disconnected" as const,
|
||||
workingDirectory: "",
|
||||
characterState: "idle" as const,
|
||||
isProcessing: false,
|
||||
grantedTools: new Set<string>(),
|
||||
pendingPermission: null,
|
||||
pendingQuestion: null,
|
||||
scrollPosition: -1,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
expect(conversation.id).toBe("conv-123");
|
||||
expect(conversation.name).toBe("Test Conversation");
|
||||
expect(conversation.terminalLines).toEqual([]);
|
||||
expect(conversation.sessionId).toBeNull();
|
||||
expect(conversation.connectionStatus).toBe("disconnected");
|
||||
expect(conversation.workingDirectory).toBe("");
|
||||
expect(conversation.characterState).toBe("idle");
|
||||
expect(conversation.isProcessing).toBe(false);
|
||||
expect(conversation.grantedTools.size).toBe(0);
|
||||
expect(conversation.pendingPermission).toBeNull();
|
||||
expect(conversation.pendingQuestion).toBeNull();
|
||||
expect(conversation.scrollPosition).toBe(-1);
|
||||
expect(conversation.attachments).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles terminal lines array", () => {
|
||||
const lines = [
|
||||
{
|
||||
id: "line-1",
|
||||
type: "user" as const,
|
||||
content: "Hello",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
{
|
||||
id: "line-2",
|
||||
type: "assistant" as const,
|
||||
content: "Hi there!",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[0].type).toBe("user");
|
||||
expect(lines[1].type).toBe("assistant");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation ID generation", () => {
|
||||
it("generates unique IDs with timestamp prefix", () => {
|
||||
let counter = 0;
|
||||
const generateId = () => `conv-${Date.now()}-${counter++}`;
|
||||
|
||||
const id1 = generateId();
|
||||
const id2 = generateId();
|
||||
|
||||
expect(id1).toMatch(/^conv-\d+-\d+$/);
|
||||
expect(id2).toMatch(/^conv-\d+-\d+$/);
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("line ID generation", () => {
|
||||
it("generates unique line IDs", () => {
|
||||
let counter = 0;
|
||||
const generateLineId = () => `line-${Date.now()}-${counter++}`;
|
||||
|
||||
const id1 = generateLineId();
|
||||
const id2 = generateLineId();
|
||||
|
||||
expect(id1).toMatch(/^line-\d+-\d+$/);
|
||||
expect(id2).toMatch(/^line-\d+-\d+$/);
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connection status types", () => {
|
||||
it("supports all connection status values", () => {
|
||||
const statuses = ["connected", "disconnected", "connecting", "error"] as const;
|
||||
|
||||
statuses.forEach((status) => {
|
||||
expect(typeof status).toBe("string");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("character state types", () => {
|
||||
it("supports all character state values", () => {
|
||||
const states = [
|
||||
"idle",
|
||||
"thinking",
|
||||
"typing",
|
||||
"searching",
|
||||
"coding",
|
||||
"mcp",
|
||||
"permission",
|
||||
"success",
|
||||
"error",
|
||||
] as const;
|
||||
|
||||
states.forEach((state) => {
|
||||
expect(typeof state).toBe("string");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("terminal line types", () => {
|
||||
it("supports all terminal line types", () => {
|
||||
const types = ["user", "assistant", "system", "tool", "error"] as const;
|
||||
|
||||
types.forEach((type) => {
|
||||
expect(typeof type).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
it("creates terminal line with required fields", () => {
|
||||
const line = {
|
||||
id: "line-123",
|
||||
type: "user" as const,
|
||||
content: "Test message",
|
||||
timestamp: new Date(),
|
||||
toolName: undefined,
|
||||
};
|
||||
|
||||
expect(line.id).toBe("line-123");
|
||||
expect(line.type).toBe("user");
|
||||
expect(line.content).toBe("Test message");
|
||||
expect(line.timestamp).toBeInstanceOf(Date);
|
||||
expect(line.toolName).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates terminal line with tool name", () => {
|
||||
const line = {
|
||||
id: "line-456",
|
||||
type: "tool" as const,
|
||||
content: "Tool output",
|
||||
timestamp: new Date(),
|
||||
toolName: "Read",
|
||||
};
|
||||
|
||||
expect(line.toolName).toBe("Read");
|
||||
});
|
||||
});
|
||||
|
||||
describe("permission request structure", () => {
|
||||
it("creates valid permission request", () => {
|
||||
const request = {
|
||||
id: "perm-123",
|
||||
tool: "Bash",
|
||||
description: "Run shell command",
|
||||
input: '{"command": "ls"}',
|
||||
};
|
||||
|
||||
expect(request.id).toBe("perm-123");
|
||||
expect(request.tool).toBe("Bash");
|
||||
expect(request.description).toBe("Run shell command");
|
||||
expect(request.input).toContain("command");
|
||||
});
|
||||
});
|
||||
|
||||
describe("user question structure", () => {
|
||||
it("creates valid question event", () => {
|
||||
const question = {
|
||||
id: "q-123",
|
||||
question: "Which option?",
|
||||
options: [
|
||||
{ label: "A", description: "Option A" },
|
||||
{ label: "B", description: "Option B" },
|
||||
],
|
||||
conversation_id: "conv-123",
|
||||
};
|
||||
|
||||
expect(question.id).toBe("q-123");
|
||||
expect(question.question).toBe("Which option?");
|
||||
expect(question.options).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachment structure", () => {
|
||||
it("creates valid file attachment", () => {
|
||||
const attachment = {
|
||||
id: "att-123",
|
||||
type: "file" as const,
|
||||
name: "test.txt",
|
||||
path: "/tmp/test.txt",
|
||||
size: 1024,
|
||||
mimeType: "text/plain",
|
||||
};
|
||||
|
||||
expect(attachment.id).toBe("att-123");
|
||||
expect(attachment.type).toBe("file");
|
||||
expect(attachment.name).toBe("test.txt");
|
||||
});
|
||||
|
||||
it("creates valid image attachment", () => {
|
||||
const attachment = {
|
||||
id: "att-456",
|
||||
type: "image" as const,
|
||||
name: "screenshot.png",
|
||||
path: "/tmp/screenshot.png",
|
||||
size: 50000,
|
||||
mimeType: "image/png",
|
||||
previewUrl: "data:image/png;base64,...",
|
||||
};
|
||||
|
||||
expect(attachment.type).toBe("image");
|
||||
expect(attachment.previewUrl).toContain("data:image");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation management operations", () => {
|
||||
it("creates new conversation with default values", () => {
|
||||
let counter = 1;
|
||||
const createNewConversation = (name?: string) => {
|
||||
const id = `conv-${Date.now()}-${counter++}`;
|
||||
return {
|
||||
id,
|
||||
name: name || `Conversation ${counter}`,
|
||||
terminalLines: [],
|
||||
sessionId: null,
|
||||
connectionStatus: "disconnected" as const,
|
||||
workingDirectory: "",
|
||||
characterState: "idle" as const,
|
||||
isProcessing: false,
|
||||
grantedTools: new Set<string>(),
|
||||
pendingPermission: null,
|
||||
pendingQuestion: null,
|
||||
scrollPosition: -1,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
attachments: [],
|
||||
};
|
||||
};
|
||||
|
||||
const conv = createNewConversation("My Chat");
|
||||
expect(conv.name).toBe("My Chat");
|
||||
expect(conv.connectionStatus).toBe("disconnected");
|
||||
expect(conv.characterState).toBe("idle");
|
||||
});
|
||||
|
||||
it("uses default name when not provided", () => {
|
||||
const counter = 5;
|
||||
const createNewConversation = (name?: string) => ({
|
||||
name: name || `Conversation ${counter}`,
|
||||
});
|
||||
|
||||
const conv = createNewConversation();
|
||||
expect(conv.name).toBe("Conversation 5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation history formatting", () => {
|
||||
it("formats user and assistant messages for history", () => {
|
||||
const lines = [
|
||||
{ type: "user" as const, content: "Hello" },
|
||||
{ type: "assistant" as const, content: "Hi there!" },
|
||||
{ type: "system" as const, content: "Connected" },
|
||||
{ type: "user" as const, content: "How are you?" },
|
||||
];
|
||||
|
||||
const relevantLines = lines.filter((line) => line.type === "user" || line.type === "assistant");
|
||||
|
||||
const history = relevantLines
|
||||
.map((line) => {
|
||||
const role = line.type === "user" ? "User" : "Assistant";
|
||||
return `${role}: ${line.content}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
expect(history).toContain("User: Hello");
|
||||
expect(history).toContain("Assistant: Hi there!");
|
||||
expect(history).toContain("User: How are you?");
|
||||
expect(history).not.toContain("Connected");
|
||||
});
|
||||
|
||||
it("returns empty string for no messages", () => {
|
||||
const lines: Array<{ type: string; content: string }> = [];
|
||||
const relevantLines = lines.filter((line) => line.type === "user" || line.type === "assistant");
|
||||
|
||||
expect(relevantLines.length).toBe(0);
|
||||
const history = relevantLines.length === 0 ? "" : "has content";
|
||||
expect(history).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool granting", () => {
|
||||
it("tracks granted tools with Set", () => {
|
||||
const grantedTools = new Set<string>();
|
||||
|
||||
grantedTools.add("Read");
|
||||
grantedTools.add("Write");
|
||||
grantedTools.add("Bash");
|
||||
|
||||
expect(grantedTools.has("Read")).toBe(true);
|
||||
expect(grantedTools.has("Write")).toBe(true);
|
||||
expect(grantedTools.has("Bash")).toBe(true);
|
||||
expect(grantedTools.has("Edit")).toBe(false);
|
||||
expect(grantedTools.size).toBe(3);
|
||||
});
|
||||
|
||||
it("clears all granted tools", () => {
|
||||
const grantedTools = new Set<string>(["Read", "Write"]);
|
||||
expect(grantedTools.size).toBe(2);
|
||||
|
||||
grantedTools.clear();
|
||||
expect(grantedTools.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scroll position handling", () => {
|
||||
it("uses -1 for auto-scroll (scroll to bottom)", () => {
|
||||
const scrollPosition = -1;
|
||||
const isAutoScroll = scrollPosition === -1;
|
||||
|
||||
expect(isAutoScroll).toBe(true);
|
||||
});
|
||||
|
||||
it("uses positive values for manual scroll position", () => {
|
||||
const scrollPosition: number = 500;
|
||||
const isAutoScroll = scrollPosition === -1;
|
||||
|
||||
expect(isAutoScroll).toBe(false);
|
||||
expect(scrollPosition).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation deletion rules", () => {
|
||||
it("prevents deletion of last conversation", () => {
|
||||
const conversations = new Map([["conv-1", { id: "conv-1", name: "Main" }]]);
|
||||
|
||||
const canDelete = conversations.size > 1;
|
||||
expect(canDelete).toBe(false);
|
||||
});
|
||||
|
||||
it("allows deletion when multiple conversations exist", () => {
|
||||
const conversations = new Map([
|
||||
["conv-1", { id: "conv-1", name: "Main" }],
|
||||
["conv-2", { id: "conv-2", name: "Second" }],
|
||||
]);
|
||||
|
||||
const canDelete = conversations.size > 1;
|
||||
expect(canDelete).toBe(true);
|
||||
});
|
||||
|
||||
it("switches to remaining conversation after deletion", () => {
|
||||
const conversations = new Map([
|
||||
["conv-1", { id: "conv-1", name: "Main" }],
|
||||
["conv-2", { id: "conv-2", name: "Second" }],
|
||||
]);
|
||||
|
||||
const activeId = "conv-1";
|
||||
conversations.delete(activeId);
|
||||
|
||||
const remaining = Array.from(conversations.keys());
|
||||
expect(remaining).toEqual(["conv-2"]);
|
||||
expect(remaining[0]).toBe("conv-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("activity timestamp tracking", () => {
|
||||
it("updates lastActivityAt on changes", () => {
|
||||
const before = new Date();
|
||||
|
||||
// Simulate a small delay
|
||||
const after = new Date(before.getTime() + 100);
|
||||
|
||||
expect(after.getTime()).toBeGreaterThan(before.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Map operations for conversations", () => {
|
||||
it("stores and retrieves conversations by ID", () => {
|
||||
const conversations = new Map<string, { id: string; name: string }>();
|
||||
|
||||
conversations.set("conv-1", { id: "conv-1", name: "First" });
|
||||
conversations.set("conv-2", { id: "conv-2", name: "Second" });
|
||||
|
||||
expect(conversations.get("conv-1")?.name).toBe("First");
|
||||
expect(conversations.get("conv-2")?.name).toBe("Second");
|
||||
expect(conversations.get("conv-3")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("checks if conversation exists", () => {
|
||||
const conversations = new Map([["conv-1", { id: "conv-1" }]]);
|
||||
|
||||
expect(conversations.has("conv-1")).toBe(true);
|
||||
expect(conversations.has("conv-2")).toBe(false);
|
||||
});
|
||||
|
||||
it("iterates over all conversations", () => {
|
||||
const conversations = new Map([
|
||||
["conv-1", { id: "conv-1", name: "First" }],
|
||||
["conv-2", { id: "conv-2", name: "Second" }],
|
||||
]);
|
||||
|
||||
const names: string[] = [];
|
||||
conversations.forEach((conv) => names.push(conv.name));
|
||||
|
||||
expect(names).toContain("First");
|
||||
expect(names).toContain("Second");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation rename", () => {
|
||||
it("updates conversation name", () => {
|
||||
const conversation = { id: "conv-1", name: "Old Name", lastActivityAt: new Date() };
|
||||
|
||||
conversation.name = "New Name";
|
||||
conversation.lastActivityAt = new Date();
|
||||
|
||||
expect(conversation.name).toBe("New Name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachment management", () => {
|
||||
it("adds attachment to array", () => {
|
||||
const attachments: Array<{ id: string; name: string }> = [];
|
||||
|
||||
attachments.push({ id: "att-1", name: "file1.txt" });
|
||||
expect(attachments).toHaveLength(1);
|
||||
|
||||
attachments.push({ id: "att-2", name: "file2.txt" });
|
||||
expect(attachments).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("removes attachment by ID", () => {
|
||||
const attachments = [
|
||||
{ id: "att-1", name: "file1.txt" },
|
||||
{ id: "att-2", name: "file2.txt" },
|
||||
];
|
||||
|
||||
const filtered = attachments.filter((a) => a.id !== "att-1");
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].id).toBe("att-2");
|
||||
});
|
||||
|
||||
it("clears all attachments", () => {
|
||||
const cleared: Array<{ id: string }> = [];
|
||||
expect(cleared).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("derived store behavior", () => {
|
||||
it("derives connection status from active conversation", () => {
|
||||
const activeConv = { connectionStatus: "connected" as const };
|
||||
const derivedStatus = activeConv?.connectionStatus || "disconnected";
|
||||
|
||||
expect(derivedStatus).toBe("connected");
|
||||
});
|
||||
|
||||
it("defaults to disconnected when no active conversation", () => {
|
||||
const activeConv = null as { connectionStatus?: string } | null;
|
||||
const derivedStatus = activeConv?.connectionStatus || "disconnected";
|
||||
|
||||
expect(derivedStatus).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("derives terminal lines from active conversation", () => {
|
||||
const activeConv = {
|
||||
terminalLines: [
|
||||
{ id: "1", content: "Hello" },
|
||||
{ id: "2", content: "World" },
|
||||
],
|
||||
};
|
||||
const derivedLines = activeConv?.terminalLines || [];
|
||||
|
||||
expect(derivedLines).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("defaults to empty array when no active conversation", () => {
|
||||
const activeConv = null as { terminalLines?: Array<{ id: string; content: string }> } | null;
|
||||
const derivedLines = activeConv?.terminalLines || [];
|
||||
|
||||
expect(derivedLines).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("line update operations", () => {
|
||||
it("updates line content by ID", () => {
|
||||
const lines = [
|
||||
{ id: "line-1", content: "Original" },
|
||||
{ id: "line-2", content: "Other" },
|
||||
];
|
||||
|
||||
const line = lines.find((l) => l.id === "line-1");
|
||||
if (line) {
|
||||
line.content = "Updated";
|
||||
}
|
||||
|
||||
expect(lines[0].content).toBe("Updated");
|
||||
expect(lines[1].content).toBe("Other");
|
||||
});
|
||||
|
||||
it("appends to line content", () => {
|
||||
const line = { id: "line-1", content: "Hello" };
|
||||
|
||||
line.content += " World";
|
||||
|
||||
expect(line.content).toBe("Hello World");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pending retry message", () => {
|
||||
it("stores and clears retry message", () => {
|
||||
let pendingRetryMessage: string | null = null;
|
||||
|
||||
pendingRetryMessage = "Retry this message";
|
||||
expect(pendingRetryMessage).toBe("Retry this message");
|
||||
|
||||
pendingRetryMessage = null;
|
||||
expect(pendingRetryMessage).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,15 @@
|
||||
import { writable, derived, get } from "svelte/store";
|
||||
import type { TerminalLine, ConnectionStatus, PermissionRequest } from "$lib/types/messages";
|
||||
import type {
|
||||
TerminalLine,
|
||||
ConnectionStatus,
|
||||
PermissionRequest,
|
||||
UserQuestionEvent,
|
||||
Attachment,
|
||||
} from "$lib/types/messages";
|
||||
import type { CharacterState } from "$lib/types/states";
|
||||
import { cleanupConversationTracking } from "$lib/tauri";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { sessionsStore } from "$lib/stores/sessions";
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
@@ -14,14 +21,17 @@ export interface Conversation {
|
||||
characterState: CharacterState;
|
||||
isProcessing: boolean;
|
||||
grantedTools: Set<string>;
|
||||
pendingPermission: PermissionRequest | null;
|
||||
pendingQuestion: UserQuestionEvent | null;
|
||||
scrollPosition: number;
|
||||
createdAt: Date;
|
||||
lastActivityAt: Date;
|
||||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
function createConversationsStore() {
|
||||
const conversations = writable<Map<string, Conversation>>(new Map());
|
||||
const activeConversationId = writable<string | null>(null);
|
||||
const pendingPermission = writable<PermissionRequest | null>(null);
|
||||
const pendingRetryMessage = writable<string | null>(null);
|
||||
|
||||
let conversationCounter = 0;
|
||||
@@ -47,8 +57,12 @@ function createConversationsStore() {
|
||||
characterState: "idle",
|
||||
isProcessing: false,
|
||||
grantedTools: new Set(),
|
||||
pendingPermission: null,
|
||||
pendingQuestion: null,
|
||||
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
attachments: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,6 +107,13 @@ function createConversationsStore() {
|
||||
activeConversation,
|
||||
($conv) => $conv?.grantedTools || new Set<string>()
|
||||
);
|
||||
const pendingPermission = derived(
|
||||
activeConversation,
|
||||
($conv) => $conv?.pendingPermission || null
|
||||
);
|
||||
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
|
||||
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
|
||||
const attachments = derived(activeConversation, ($conv) => $conv?.attachments || []);
|
||||
|
||||
return {
|
||||
// Expose derived stores for compatibility
|
||||
@@ -101,9 +122,12 @@ function createConversationsStore() {
|
||||
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
|
||||
terminalLines: { subscribe: terminalLines.subscribe },
|
||||
pendingPermission: { subscribe: pendingPermission.subscribe },
|
||||
pendingQuestion: { subscribe: pendingQuestion.subscribe },
|
||||
isProcessing: { subscribe: isProcessing.subscribe },
|
||||
grantedTools: { subscribe: grantedTools.subscribe },
|
||||
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
|
||||
scrollPosition: { subscribe: scrollPosition.subscribe },
|
||||
attachments: { subscribe: attachments.subscribe },
|
||||
|
||||
// New conversation-specific stores
|
||||
conversations: { subscribe: conversations.subscribe },
|
||||
@@ -148,8 +172,98 @@ function createConversationsStore() {
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
requestPermission: (request: PermissionRequest) => pendingPermission.set(request),
|
||||
clearPermission: () => pendingPermission.set(null),
|
||||
requestPermission: (request: PermissionRequest) => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.pendingPermission = request;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
clearPermission: () => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.pendingPermission = null;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
requestPermissionForConversation: (conversationId: string, request: PermissionRequest) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.pendingPermission = request;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
clearPermissionForConversation: (conversationId: string) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.pendingPermission = null;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
requestQuestion: (question: UserQuestionEvent) => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.pendingQuestion = question;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
clearQuestion: () => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.pendingQuestion = null;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
requestQuestionForConversation: (conversationId: string, question: UserQuestionEvent) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.pendingQuestion = question;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
clearQuestionForConversation: (conversationId: string) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.pendingQuestion = null;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
|
||||
|
||||
// Conversation management
|
||||
@@ -164,7 +278,7 @@ function createConversationsStore() {
|
||||
return newConv.id;
|
||||
},
|
||||
|
||||
deleteConversation: (id: string) => {
|
||||
deleteConversation: async (id: string) => {
|
||||
ensureInitialized();
|
||||
const convs = get(conversations);
|
||||
const activeId = get(activeConversationId);
|
||||
@@ -174,8 +288,11 @@ function createConversationsStore() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clean up tracking for this conversation
|
||||
cleanupConversationTracking(id);
|
||||
// Cancel any pending auto-save for this conversation
|
||||
sessionsStore.cancelAutoSave(id);
|
||||
|
||||
// Clean up tracking for this conversation (including temp files)
|
||||
await cleanupConversationTracking(id);
|
||||
|
||||
conversations.update((c) => {
|
||||
c.delete(id);
|
||||
@@ -221,6 +338,22 @@ function createConversationsStore() {
|
||||
});
|
||||
},
|
||||
|
||||
saveScrollPosition: (id: string, position: number) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(id);
|
||||
if (conv) {
|
||||
conv.scrollPosition = position;
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
getScrollPosition: (id: string): number => {
|
||||
const convs = get(conversations);
|
||||
const conv = convs.get(id);
|
||||
return conv?.scrollPosition ?? -1;
|
||||
},
|
||||
|
||||
// Methods that operate on the active conversation
|
||||
setSessionId: (id: string | null) => {
|
||||
ensureInitialized();
|
||||
@@ -305,6 +438,8 @@ function createConversationsStore() {
|
||||
if (conv) {
|
||||
conv.terminalLines.push(line);
|
||||
conv.lastActivityAt = new Date();
|
||||
// Schedule auto-save for this conversation
|
||||
sessionsStore.scheduleAutoSave(conv);
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
@@ -333,6 +468,8 @@ function createConversationsStore() {
|
||||
if (conv) {
|
||||
conv.terminalLines.push(line);
|
||||
conv.lastActivityAt = new Date();
|
||||
// Schedule auto-save for this conversation
|
||||
sessionsStore.scheduleAutoSave(conv);
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
@@ -447,6 +584,58 @@ function createConversationsStore() {
|
||||
return conv?.grantedTools.has(toolName) || false;
|
||||
},
|
||||
|
||||
// Attachment management
|
||||
addAttachment: (attachment: Attachment) => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.attachments.push(attachment);
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
removeAttachment: (id: string) => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.attachments = conv.attachments.filter((a) => a.id !== id);
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
clearAttachments: () => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.attachments = [];
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
getAttachments: (): Attachment[] => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return [];
|
||||
|
||||
const convs = get(conversations);
|
||||
const conv = convs.get(activeId);
|
||||
return conv?.attachments || [];
|
||||
},
|
||||
|
||||
// Add initialization helper
|
||||
initialize: () => {
|
||||
ensureInitialized();
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||
import { quickActionsStore, type QuickAction } from "./quickActions";
|
||||
|
||||
describe("QuickAction interface", () => {
|
||||
it("defines all required fields", () => {
|
||||
const action: QuickAction = {
|
||||
id: "action-123",
|
||||
name: "Run Tests",
|
||||
prompt: "Please run the tests",
|
||||
icon: "play",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
expect(action.id).toBe("action-123");
|
||||
expect(action.name).toBe("Run Tests");
|
||||
expect(action.prompt).toBe("Please run the tests");
|
||||
expect(action.icon).toBe("play");
|
||||
expect(action.is_default).toBe(false);
|
||||
expect(action.created_at).toBe("2024-01-01T00:00:00Z");
|
||||
expect(action.updated_at).toBe("2024-01-01T00:00:00Z");
|
||||
});
|
||||
|
||||
it("supports default actions", () => {
|
||||
const defaultAction: QuickAction = {
|
||||
id: "default-review-pr",
|
||||
name: "Review PR",
|
||||
prompt: "Please review this pull request",
|
||||
icon: "git-pull-request",
|
||||
is_default: true,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
expect(defaultAction.is_default).toBe(true);
|
||||
expect(defaultAction.id).toContain("default-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("quickActionsStore", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("store structure", () => {
|
||||
it("has all expected methods", () => {
|
||||
expect(typeof quickActionsStore.loadQuickActions).toBe("function");
|
||||
expect(typeof quickActionsStore.saveQuickAction).toBe("function");
|
||||
expect(typeof quickActionsStore.createQuickAction).toBe("function");
|
||||
expect(typeof quickActionsStore.updateQuickAction).toBe("function");
|
||||
expect(typeof quickActionsStore.deleteQuickAction).toBe("function");
|
||||
expect(typeof quickActionsStore.resetDefaults).toBe("function");
|
||||
});
|
||||
|
||||
it("has subscribable stores", () => {
|
||||
expect(typeof quickActionsStore.actions.subscribe).toBe("function");
|
||||
expect(typeof quickActionsStore.isLoading.subscribe).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadQuickActions", () => {
|
||||
it("loads quick actions from backend", async () => {
|
||||
const mockActions: QuickAction[] = [
|
||||
{
|
||||
id: "action-1",
|
||||
name: "Action 1",
|
||||
prompt: "prompt 1",
|
||||
icon: "star",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
setMockInvokeResult("list_quick_actions", mockActions);
|
||||
|
||||
await quickActionsStore.loadQuickActions();
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("list_quick_actions");
|
||||
});
|
||||
|
||||
it("handles load errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("list_quick_actions", new Error("Failed to load"));
|
||||
|
||||
await quickActionsStore.loadQuickActions();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to load quick actions:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveQuickAction", () => {
|
||||
it("saves action and reloads list", async () => {
|
||||
const action: QuickAction = {
|
||||
id: "action-123",
|
||||
name: "Test",
|
||||
prompt: "test prompt",
|
||||
icon: "star",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
setMockInvokeResult("save_quick_action", undefined);
|
||||
setMockInvokeResult("list_quick_actions", [action]);
|
||||
|
||||
const result = await quickActionsStore.saveQuickAction(action);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith("save_quick_action", { action });
|
||||
});
|
||||
|
||||
it("handles save errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("save_quick_action", new Error("Failed to save"));
|
||||
|
||||
const action: QuickAction = {
|
||||
id: "action-123",
|
||||
name: "Test",
|
||||
prompt: "test prompt",
|
||||
icon: "star",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const result = await quickActionsStore.saveQuickAction(action);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to save quick action:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createQuickAction", () => {
|
||||
it("creates new action with generated ID and timestamps", async () => {
|
||||
setMockInvokeResult("save_quick_action", undefined);
|
||||
setMockInvokeResult("list_quick_actions", []);
|
||||
|
||||
const result = await quickActionsStore.createQuickAction("My Action", "Do something", "star");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"save_quick_action",
|
||||
expect.objectContaining({
|
||||
action: expect.objectContaining({
|
||||
name: "My Action",
|
||||
prompt: "Do something",
|
||||
icon: "star",
|
||||
is_default: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateQuickAction", () => {
|
||||
it("updates existing action preserving created_at", async () => {
|
||||
const existingAction: QuickAction = {
|
||||
id: "action-123",
|
||||
name: "Old Name",
|
||||
prompt: "old prompt",
|
||||
icon: "old-icon",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
setMockInvokeResult("list_quick_actions", [existingAction]);
|
||||
setMockInvokeResult("save_quick_action", undefined);
|
||||
|
||||
const result = await quickActionsStore.updateQuickAction(
|
||||
"action-123",
|
||||
"New Name",
|
||||
"new prompt",
|
||||
"new-icon"
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"save_quick_action",
|
||||
expect.objectContaining({
|
||||
action: expect.objectContaining({
|
||||
id: "action-123",
|
||||
name: "New Name",
|
||||
prompt: "new prompt",
|
||||
icon: "new-icon",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for non-existent action", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("list_quick_actions", []);
|
||||
|
||||
const result = await quickActionsStore.updateQuickAction(
|
||||
"non-existent",
|
||||
"Name",
|
||||
"prompt",
|
||||
"icon"
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Quick action not found for update");
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteQuickAction", () => {
|
||||
it("deletes action by ID", async () => {
|
||||
setMockInvokeResult("delete_quick_action", undefined);
|
||||
setMockInvokeResult("list_quick_actions", []);
|
||||
|
||||
const result = await quickActionsStore.deleteQuickAction("action-123");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith("delete_quick_action", { actionId: "action-123" });
|
||||
});
|
||||
|
||||
it("handles delete errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("delete_quick_action", new Error("Cannot delete default action"));
|
||||
|
||||
const result = await quickActionsStore.deleteQuickAction("default-1");
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to delete quick action:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetDefaults", () => {
|
||||
it("resets default actions", async () => {
|
||||
setMockInvokeResult("reset_default_quick_actions", undefined);
|
||||
setMockInvokeResult("list_quick_actions", []);
|
||||
|
||||
const result = await quickActionsStore.resetDefaults();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith("reset_default_quick_actions");
|
||||
});
|
||||
|
||||
it("handles reset errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("reset_default_quick_actions", new Error("Reset failed"));
|
||||
|
||||
const result = await quickActionsStore.resetDefaults();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Failed to reset default quick actions:",
|
||||
expect.any(Error)
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("quick action ID generation", () => {
|
||||
it("generates unique custom action IDs", () => {
|
||||
const generateId = () => `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
const id1 = generateId();
|
||||
const id2 = generateId();
|
||||
|
||||
expect(id1).toMatch(/^custom-\d+-[a-z0-9]+$/);
|
||||
expect(id2).toMatch(/^custom-\d+-[a-z0-9]+$/);
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("action validation", () => {
|
||||
it("requires non-empty name", () => {
|
||||
const action = { name: "" };
|
||||
const isValid = action.name.trim().length > 0;
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("requires non-empty prompt", () => {
|
||||
const action = { prompt: " " };
|
||||
const isValid = action.prompt.trim().length > 0;
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("requires non-empty icon", () => {
|
||||
const action = { icon: "star" };
|
||||
const isValid = action.icon.trim().length > 0;
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("action icons", () => {
|
||||
it("supports various icon names", () => {
|
||||
const validIcons = [
|
||||
"git-pull-request",
|
||||
"play",
|
||||
"file-text",
|
||||
"alert-circle",
|
||||
"check-square",
|
||||
"refresh-cw",
|
||||
"star",
|
||||
"code",
|
||||
"terminal",
|
||||
];
|
||||
|
||||
validIcons.forEach((icon) => {
|
||||
expect(typeof icon).toBe("string");
|
||||
expect(icon.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("action sorting", () => {
|
||||
it("sorts default actions before custom actions", () => {
|
||||
const actions = [
|
||||
{ id: "custom-1", name: "Custom", is_default: false },
|
||||
{ id: "default-1", name: "Default", is_default: true },
|
||||
];
|
||||
|
||||
const sorted = [...actions].sort((a, b) => {
|
||||
const defaultCmp = (b.is_default ? 1 : 0) - (a.is_default ? 1 : 0);
|
||||
if (defaultCmp !== 0) return defaultCmp;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
expect(sorted[0].is_default).toBe(true);
|
||||
expect(sorted[1].is_default).toBe(false);
|
||||
});
|
||||
|
||||
it("sorts alphabetically within same default status", () => {
|
||||
const actions = [
|
||||
{ id: "default-2", name: "Zebra", is_default: true },
|
||||
{ id: "default-1", name: "Apple", is_default: true },
|
||||
];
|
||||
|
||||
const sorted = [...actions].sort((a, b) => {
|
||||
const defaultCmp = (b.is_default ? 1 : 0) - (a.is_default ? 1 : 0);
|
||||
if (defaultCmp !== 0) return defaultCmp;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
expect(sorted[0].name).toBe("Apple");
|
||||
expect(sorted[1].name).toBe("Zebra");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface QuickAction {
|
||||
id: string;
|
||||
name: string;
|
||||
prompt: string;
|
||||
icon: string;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function createQuickActionsStore() {
|
||||
const actions = writable<QuickAction[]>([]);
|
||||
const isLoading = writable(false);
|
||||
|
||||
async function loadQuickActions(): Promise<void> {
|
||||
isLoading.set(true);
|
||||
try {
|
||||
const actionList = await invoke<QuickAction[]>("list_quick_actions");
|
||||
actions.set(actionList);
|
||||
} catch (error) {
|
||||
console.error("Failed to load quick actions:", error);
|
||||
} finally {
|
||||
isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveQuickAction(action: QuickAction): Promise<boolean> {
|
||||
try {
|
||||
await invoke("save_quick_action", { action });
|
||||
await loadQuickActions();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to save quick action:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createQuickAction(name: string, prompt: string, icon: string): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const action: QuickAction = {
|
||||
id: `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
name,
|
||||
prompt,
|
||||
icon,
|
||||
is_default: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
return saveQuickAction(action);
|
||||
}
|
||||
|
||||
async function updateQuickAction(
|
||||
id: string,
|
||||
name: string,
|
||||
prompt: string,
|
||||
icon: string
|
||||
): Promise<boolean> {
|
||||
const currentActions = await invoke<QuickAction[]>("list_quick_actions");
|
||||
const existing = currentActions.find((a) => a.id === id);
|
||||
|
||||
if (!existing) {
|
||||
console.error("Quick action not found for update");
|
||||
return false;
|
||||
}
|
||||
|
||||
const updated: QuickAction = {
|
||||
...existing,
|
||||
name,
|
||||
prompt,
|
||||
icon,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return saveQuickAction(updated);
|
||||
}
|
||||
|
||||
async function deleteQuickAction(actionId: string): Promise<boolean> {
|
||||
try {
|
||||
await invoke("delete_quick_action", { actionId });
|
||||
await loadQuickActions();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete quick action:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDefaults(): Promise<boolean> {
|
||||
try {
|
||||
await invoke("reset_default_quick_actions");
|
||||
await loadQuickActions();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to reset default quick actions:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
actions: { subscribe: actions.subscribe },
|
||||
isLoading: { subscribe: isLoading.subscribe },
|
||||
loadQuickActions,
|
||||
saveQuickAction,
|
||||
createQuickAction,
|
||||
updateQuickAction,
|
||||
deleteQuickAction,
|
||||
resetDefaults,
|
||||
};
|
||||
}
|
||||
|
||||
export const quickActionsStore = createQuickActionsStore();
|
||||
@@ -0,0 +1,63 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
|
||||
interface SearchState {
|
||||
query: string;
|
||||
isActive: boolean;
|
||||
matchCount: number;
|
||||
currentMatchIndex: number;
|
||||
}
|
||||
|
||||
const initialState: SearchState = {
|
||||
query: "",
|
||||
isActive: false,
|
||||
matchCount: 0,
|
||||
currentMatchIndex: 0,
|
||||
};
|
||||
|
||||
const searchStore = writable<SearchState>(initialState);
|
||||
|
||||
export const searchState = {
|
||||
subscribe: searchStore.subscribe,
|
||||
|
||||
setQuery: (query: string) => {
|
||||
searchStore.update((state) => ({
|
||||
...state,
|
||||
query,
|
||||
isActive: query.length > 0,
|
||||
currentMatchIndex: 0,
|
||||
}));
|
||||
},
|
||||
|
||||
setMatchCount: (count: number) => {
|
||||
searchStore.update((state) => ({
|
||||
...state,
|
||||
matchCount: count,
|
||||
}));
|
||||
},
|
||||
|
||||
nextMatch: () => {
|
||||
searchStore.update((state) => ({
|
||||
...state,
|
||||
currentMatchIndex:
|
||||
state.matchCount > 0 ? (state.currentMatchIndex + 1) % state.matchCount : 0,
|
||||
}));
|
||||
},
|
||||
|
||||
previousMatch: () => {
|
||||
searchStore.update((state) => ({
|
||||
...state,
|
||||
currentMatchIndex:
|
||||
state.matchCount > 0
|
||||
? (state.currentMatchIndex - 1 + state.matchCount) % state.matchCount
|
||||
: 0,
|
||||
}));
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
searchStore.set(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
export const isSearchActive = derived(searchStore, ($search) => $search.isActive);
|
||||
|
||||
export const searchQuery = derived(searchStore, ($search) => $search.query);
|
||||
@@ -0,0 +1,708 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { save, open } from "@tauri-apps/plugin-dialog";
|
||||
import type { Conversation } from "./conversations";
|
||||
import { writeTextFile, readTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { openPath } from "@tauri-apps/plugin-opener";
|
||||
|
||||
// Debounce delay for auto-save (in milliseconds)
|
||||
const AUTO_SAVE_DEBOUNCE_MS = 2000;
|
||||
|
||||
export interface SavedMessage {
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
tool_name?: string;
|
||||
}
|
||||
|
||||
export interface SavedSession {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
last_activity_at: string;
|
||||
working_directory: string;
|
||||
message_count: number;
|
||||
preview: string;
|
||||
messages: SavedMessage[];
|
||||
}
|
||||
|
||||
export interface SessionListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
last_activity_at: string;
|
||||
working_directory: string;
|
||||
message_count: number;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function generateHtmlExport(session: SavedSession, includeMetadata: boolean): string {
|
||||
const styles = `
|
||||
:root {
|
||||
--bg-primary: #1a1a2e;
|
||||
--bg-secondary: #16213e;
|
||||
--bg-code: #1e1e2e;
|
||||
--accent-primary: #e94560;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a0a0a0;
|
||||
--border-color: #2a2a4a;
|
||||
--terminal-user: #22d3ee;
|
||||
--terminal-tool: #c084fc;
|
||||
--terminal-error: #f87171;
|
||||
--hljs-keyword: #f472b6;
|
||||
--hljs-string: #a3e635;
|
||||
--hljs-number: #fbbf24;
|
||||
--hljs-comment: #6b7280;
|
||||
--hljs-function: #c084fc;
|
||||
--hljs-type: #22d3ee;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
padding: 2rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 { color: var(--accent-primary); margin-bottom: 1rem; }
|
||||
.metadata {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.metadata p { color: var(--text-secondary); margin: 0.25rem 0; }
|
||||
.metadata strong { color: var(--text-primary); }
|
||||
hr { border: none; border-top: 1px solid var(--border-color); margin: 1.5rem 0; }
|
||||
.message {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-left: 4px solid var(--border-color);
|
||||
}
|
||||
.message.user { border-left-color: var(--terminal-user); }
|
||||
.message.assistant { border-left-color: var(--accent-primary); }
|
||||
.message.tool_use { border-left-color: var(--terminal-tool); }
|
||||
.message.tool_result { border-left-color: var(--hljs-string); }
|
||||
.message.error { border-left-color: var(--terminal-error); }
|
||||
.message.system { border-left-color: var(--text-secondary); }
|
||||
.message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.message-type {
|
||||
font-weight: bold;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.message-type.user { color: var(--terminal-user); }
|
||||
.message-type.assistant { color: var(--accent-primary); }
|
||||
.message-type.tool_use { color: var(--terminal-tool); }
|
||||
.message-type.tool_result { color: var(--hljs-string); }
|
||||
.message-type.error { color: var(--terminal-error); }
|
||||
.message-type.system { color: var(--text-secondary); }
|
||||
.timestamp { color: var(--text-secondary); font-size: 0.85rem; }
|
||||
.tool-name { color: var(--terminal-tool); font-size: 0.9rem; }
|
||||
.message-content {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
pre {
|
||||
background: var(--bg-code);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
code {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg-primary: #f8f9fa;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-code: #f5f5f5;
|
||||
--text-primary: #1a1a2e;
|
||||
--text-secondary: #5a5a7a;
|
||||
--border-color: #d0d0e0;
|
||||
--terminal-user: #0891b2;
|
||||
--terminal-tool: #7c3aed;
|
||||
--terminal-error: #dc2626;
|
||||
--hljs-keyword: #d946ef;
|
||||
--hljs-string: #16a34a;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
let messagesHtml = "";
|
||||
for (const message of session.messages) {
|
||||
const timestamp = new Date(message.timestamp).toLocaleString();
|
||||
const typeLabel =
|
||||
message.type === "tool_use"
|
||||
? "Tool Use"
|
||||
: message.type === "tool_result"
|
||||
? "Tool Result"
|
||||
: message.type.charAt(0).toUpperCase() + message.type.slice(1);
|
||||
|
||||
messagesHtml += `
|
||||
<div class="message ${message.type}">
|
||||
<div class="message-header">
|
||||
<span class="message-type ${message.type}">${typeLabel}</span>
|
||||
<span class="timestamp">${timestamp}</span>
|
||||
</div>
|
||||
${message.tool_name ? `<div class="tool-name">🔧 ${escapeHtml(message.tool_name)}</div>` : ""}
|
||||
<div class="message-content">${escapeHtml(message.content)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const metadataHtml = includeMetadata
|
||||
? `
|
||||
<div class="metadata">
|
||||
<p><strong>Created:</strong> ${new Date(session.created_at).toLocaleString()}</p>
|
||||
<p><strong>Last Activity:</strong> ${new Date(session.last_activity_at).toLocaleString()}</p>
|
||||
<p><strong>Working Directory:</strong> ${session.working_directory || "Not set"}</p>
|
||||
<p><strong>Messages:</strong> ${session.message_count}</p>
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapeHtml(session.name)} - Hikari Desktop Export</title>
|
||||
<style>${styles}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>💬 ${escapeHtml(session.name)}</h1>
|
||||
${metadataHtml}
|
||||
<hr>
|
||||
${messagesHtml}
|
||||
<div class="footer">
|
||||
<p>✨ Exported from Hikari Desktop</p>
|
||||
<p>Export date: ${new Date().toLocaleString()}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function generatePrintableHtml(session: SavedSession, includeMetadata: boolean): string {
|
||||
const printStyles = `
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 11pt;
|
||||
line-height: 1.5;
|
||||
padding: 0.5in;
|
||||
max-width: 8.5in;
|
||||
margin: 0 auto;
|
||||
color: #000;
|
||||
background: #fff;
|
||||
}
|
||||
h1 {
|
||||
font-size: 18pt;
|
||||
margin-bottom: 0.25in;
|
||||
border-bottom: 2px solid #333;
|
||||
padding-bottom: 0.1in;
|
||||
}
|
||||
.metadata {
|
||||
background: #f5f5f5;
|
||||
padding: 0.15in;
|
||||
margin-bottom: 0.25in;
|
||||
border: 1px solid #ddd;
|
||||
font-size: 9pt;
|
||||
}
|
||||
.metadata p { margin: 2pt 0; }
|
||||
hr { border: none; border-top: 1px solid #ccc; margin: 0.2in 0; }
|
||||
.message {
|
||||
margin-bottom: 0.15in;
|
||||
padding: 0.1in;
|
||||
border-left: 3px solid #ccc;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.message.user { border-left-color: #0088cc; }
|
||||
.message.assistant { border-left-color: #cc0044; }
|
||||
.message.tool_use { border-left-color: #8844cc; }
|
||||
.message.tool_result { border-left-color: #44cc44; }
|
||||
.message.error { border-left-color: #cc4444; }
|
||||
.message.system { border-left-color: #888; }
|
||||
.message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
margin-bottom: 0.05in;
|
||||
border-bottom: 1px dotted #ddd;
|
||||
padding-bottom: 0.03in;
|
||||
}
|
||||
.message-type { font-weight: bold; text-transform: uppercase; }
|
||||
.message-type.user { color: #0066aa; }
|
||||
.message-type.assistant { color: #aa0033; }
|
||||
.message-type.tool_use { color: #6633aa; }
|
||||
.message-type.tool_result { color: #33aa33; }
|
||||
.message-type.error { color: #aa3333; }
|
||||
.tool-name { color: #666; font-size: 9pt; font-style: italic; }
|
||||
.message-content {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: 'Consolas', 'Courier New', monospace;
|
||||
font-size: 9pt;
|
||||
background: #fafafa;
|
||||
padding: 0.05in;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 8pt;
|
||||
color: #999;
|
||||
margin-top: 0.25in;
|
||||
padding-top: 0.1in;
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
.print-instructions {
|
||||
background: #ffffcc;
|
||||
border: 1px solid #cccc00;
|
||||
padding: 0.15in;
|
||||
margin-bottom: 0.25in;
|
||||
font-size: 10pt;
|
||||
}
|
||||
.print-instructions h2 { font-size: 12pt; margin-bottom: 0.1in; }
|
||||
.print-instructions ol { margin-left: 0.2in; }
|
||||
.print-instructions li { margin: 0.05in 0; }
|
||||
@media print {
|
||||
.print-instructions { display: none; }
|
||||
body { padding: 0; }
|
||||
}
|
||||
@page { margin: 0.5in; }
|
||||
`;
|
||||
|
||||
let messagesHtml = "";
|
||||
for (const message of session.messages) {
|
||||
const timestamp = new Date(message.timestamp).toLocaleString();
|
||||
const typeLabel =
|
||||
message.type === "tool_use"
|
||||
? "Tool"
|
||||
: message.type === "tool_result"
|
||||
? "Result"
|
||||
: message.type.charAt(0).toUpperCase() + message.type.slice(1);
|
||||
|
||||
messagesHtml += `
|
||||
<div class="message ${message.type}">
|
||||
<div class="message-header">
|
||||
<span class="message-type ${message.type}">${typeLabel}</span>
|
||||
<span class="timestamp">${timestamp}</span>
|
||||
</div>
|
||||
${message.tool_name ? `<div class="tool-name">${escapeHtml(message.tool_name)}</div>` : ""}
|
||||
<div class="message-content">${escapeHtml(message.content)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const metadataHtml = includeMetadata
|
||||
? `
|
||||
<div class="metadata">
|
||||
<p><strong>Created:</strong> ${new Date(session.created_at).toLocaleString()}</p>
|
||||
<p><strong>Last Activity:</strong> ${new Date(session.last_activity_at).toLocaleString()}</p>
|
||||
<p><strong>Working Directory:</strong> ${session.working_directory || "Not set"}</p>
|
||||
<p><strong>Messages:</strong> ${session.message_count}</p>
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapeHtml(session.name)} - Print to PDF</title>
|
||||
<style>${printStyles}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="print-instructions">
|
||||
<h2>Print to PDF Instructions</h2>
|
||||
<ol>
|
||||
<li>Press <strong>Ctrl+P</strong> (or <strong>Cmd+P</strong> on Mac) to open the print dialog</li>
|
||||
<li>Select <strong>"Save as PDF"</strong> or <strong>"Microsoft Print to PDF"</strong> as the printer</li>
|
||||
<li>Click <strong>Print</strong> to save your PDF</li>
|
||||
</ol>
|
||||
<p><em>This instruction box will not appear in your PDF.</em></p>
|
||||
</div>
|
||||
<h1>${escapeHtml(session.name)}</h1>
|
||||
${metadataHtml}
|
||||
<hr>
|
||||
${messagesHtml}
|
||||
<div class="footer">
|
||||
<p>Exported from Hikari Desktop | ${new Date().toLocaleString()}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function createSessionsStore() {
|
||||
const sessions = writable<SessionListItem[]>([]);
|
||||
const isLoading = writable(false);
|
||||
const searchQuery = writable("");
|
||||
|
||||
// Track pending auto-save timeouts per conversation
|
||||
const pendingAutoSaves = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
async function loadSessions(): Promise<void> {
|
||||
isLoading.set(true);
|
||||
try {
|
||||
const result = await invoke<SessionListItem[]>("list_sessions");
|
||||
sessions.set(result);
|
||||
} catch (error) {
|
||||
console.error("Failed to load sessions:", error);
|
||||
} finally {
|
||||
isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConversation(conversation: Conversation): Promise<void> {
|
||||
const messages: SavedMessage[] = conversation.terminalLines.map((line) => ({
|
||||
id: line.id,
|
||||
type: line.type,
|
||||
content: line.content,
|
||||
timestamp: line.timestamp.toISOString(),
|
||||
tool_name: line.toolName,
|
||||
}));
|
||||
|
||||
const userAndAssistantMessages = conversation.terminalLines.filter(
|
||||
(line) => line.type === "user" || line.type === "assistant"
|
||||
);
|
||||
const previewContent =
|
||||
userAndAssistantMessages
|
||||
.slice(0, 3)
|
||||
.map((m) => m.content)
|
||||
.join(" ")
|
||||
.slice(0, 150) + (userAndAssistantMessages.length > 3 ? "..." : "");
|
||||
|
||||
const session: SavedSession = {
|
||||
id: conversation.id,
|
||||
name: conversation.name,
|
||||
created_at: conversation.createdAt.toISOString(),
|
||||
last_activity_at: conversation.lastActivityAt.toISOString(),
|
||||
working_directory: conversation.workingDirectory,
|
||||
message_count: conversation.terminalLines.length,
|
||||
preview: previewContent || "Empty conversation",
|
||||
messages,
|
||||
};
|
||||
|
||||
try {
|
||||
await invoke("save_session", { session });
|
||||
await loadSessions();
|
||||
} catch (error) {
|
||||
console.error("Failed to save session:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSession(sessionId: string): Promise<SavedSession | null> {
|
||||
try {
|
||||
const session = await invoke<SavedSession | null>("load_session", {
|
||||
sessionId,
|
||||
});
|
||||
return session;
|
||||
} catch (error) {
|
||||
console.error("Failed to load session:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(sessionId: string): Promise<void> {
|
||||
try {
|
||||
await invoke("delete_session", { sessionId });
|
||||
await loadSessions();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete session:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function searchSessions(query: string): Promise<void> {
|
||||
searchQuery.set(query);
|
||||
|
||||
if (!query.trim()) {
|
||||
await loadSessions();
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.set(true);
|
||||
try {
|
||||
const result = await invoke<SessionListItem[]>("search_sessions", {
|
||||
query,
|
||||
});
|
||||
sessions.set(result);
|
||||
} catch (error) {
|
||||
console.error("Failed to search sessions:", error);
|
||||
} finally {
|
||||
isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAllSessions(): Promise<void> {
|
||||
try {
|
||||
await invoke("clear_all_sessions");
|
||||
sessions.set([]);
|
||||
} catch (error) {
|
||||
console.error("Failed to clear sessions:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAutoSave(conversation: Conversation): void {
|
||||
// Don't auto-save empty conversations
|
||||
if (conversation.terminalLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any pending auto-save for this conversation
|
||||
const existingTimeout = pendingAutoSaves.get(conversation.id);
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout);
|
||||
}
|
||||
|
||||
// Schedule a new auto-save
|
||||
const timeout = setTimeout(async () => {
|
||||
pendingAutoSaves.delete(conversation.id);
|
||||
try {
|
||||
await saveConversation(conversation);
|
||||
} catch (error) {
|
||||
console.error("Auto-save failed:", error);
|
||||
}
|
||||
}, AUTO_SAVE_DEBOUNCE_MS);
|
||||
|
||||
pendingAutoSaves.set(conversation.id, timeout);
|
||||
}
|
||||
|
||||
function cancelAutoSave(conversationId: string): void {
|
||||
const existingTimeout = pendingAutoSaves.get(conversationId);
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout);
|
||||
pendingAutoSaves.delete(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSessionAsJson(sessionId: string): Promise<boolean> {
|
||||
try {
|
||||
const session = await loadSession(sessionId);
|
||||
if (!session) {
|
||||
console.error("Session not found for export");
|
||||
return false;
|
||||
}
|
||||
|
||||
const filePath = await save({
|
||||
defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}.json`,
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const exportData = {
|
||||
version: 1,
|
||||
exported_at: new Date().toISOString(),
|
||||
session,
|
||||
};
|
||||
|
||||
await writeTextFile(filePath, JSON.stringify(exportData, null, 2));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to export session as JSON:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSessionAsMarkdown(sessionId: string): Promise<boolean> {
|
||||
try {
|
||||
const session = await loadSession(sessionId);
|
||||
if (!session) {
|
||||
console.error("Session not found for export");
|
||||
return false;
|
||||
}
|
||||
|
||||
const filePath = await save({
|
||||
defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}.md`,
|
||||
filters: [{ name: "Markdown", extensions: ["md"] }],
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let markdown = `# ${session.name}\n\n`;
|
||||
markdown += `**Created:** ${new Date(session.created_at).toLocaleString()}\n`;
|
||||
markdown += `**Last Activity:** ${new Date(session.last_activity_at).toLocaleString()}\n`;
|
||||
markdown += `**Working Directory:** ${session.working_directory || "Not set"}\n`;
|
||||
markdown += `**Messages:** ${session.message_count}\n\n`;
|
||||
markdown += `---\n\n`;
|
||||
|
||||
for (const message of session.messages) {
|
||||
const timestamp = new Date(message.timestamp).toLocaleTimeString();
|
||||
if (message.type === "user") {
|
||||
markdown += `## 👤 User (${timestamp})\n\n${message.content}\n\n`;
|
||||
} else if (message.type === "assistant") {
|
||||
markdown += `## 🤖 Assistant (${timestamp})\n\n${message.content}\n\n`;
|
||||
} else if (message.type === "tool_use") {
|
||||
markdown += `### 🔧 Tool: ${message.tool_name || "Unknown"} (${timestamp})\n\n\`\`\`\n${message.content}\n\`\`\`\n\n`;
|
||||
} else if (message.type === "tool_result") {
|
||||
markdown += `### 📋 Result (${timestamp})\n\n\`\`\`\n${message.content}\n\`\`\`\n\n`;
|
||||
} else if (message.type === "system") {
|
||||
markdown += `> **System:** ${message.content}\n\n`;
|
||||
} else if (message.type === "error") {
|
||||
markdown += `> ⚠️ **Error:** ${message.content}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
markdown += `---\n\n*Exported from Hikari Desktop*\n`;
|
||||
|
||||
await writeTextFile(filePath, markdown);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to export session as Markdown:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSessionAsHtml(
|
||||
sessionId: string,
|
||||
includeMetadata: boolean = true
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const session = await loadSession(sessionId);
|
||||
if (!session) {
|
||||
console.error("Session not found for export");
|
||||
return false;
|
||||
}
|
||||
|
||||
const filePath = await save({
|
||||
defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}.html`,
|
||||
filters: [{ name: "HTML", extensions: ["html"] }],
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const html = generateHtmlExport(session, includeMetadata);
|
||||
await writeTextFile(filePath, html);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to export session as HTML:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSessionAsPdf(
|
||||
sessionId: string,
|
||||
includeMetadata: boolean = true
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const session = await loadSession(sessionId);
|
||||
if (!session) {
|
||||
console.error("Session not found for export");
|
||||
return false;
|
||||
}
|
||||
|
||||
const filePath = await save({
|
||||
defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}-print.html`,
|
||||
filters: [{ name: "HTML (for PDF printing)", extensions: ["html"] }],
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const html = generatePrintableHtml(session, includeMetadata);
|
||||
await writeTextFile(filePath, html);
|
||||
|
||||
// Open the file in the default browser for print-to-PDF
|
||||
await openPath(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to export session for PDF:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function importSession(): Promise<boolean> {
|
||||
try {
|
||||
const filePath = await open({
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = await readTextFile(filePath as string);
|
||||
const importData = JSON.parse(content);
|
||||
|
||||
if (!importData.session || !importData.session.id) {
|
||||
console.error("Invalid session file format");
|
||||
return false;
|
||||
}
|
||||
|
||||
const session: SavedSession = importData.session;
|
||||
|
||||
// Generate a new ID to avoid conflicts with existing sessions
|
||||
session.id = `imported-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
session.name = `${session.name} (Imported)`;
|
||||
|
||||
await invoke("save_session", { session });
|
||||
await loadSessions();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to import session:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: { subscribe: sessions.subscribe },
|
||||
isLoading: { subscribe: isLoading.subscribe },
|
||||
searchQuery: { subscribe: searchQuery.subscribe },
|
||||
loadSessions,
|
||||
saveConversation,
|
||||
loadSession,
|
||||
deleteSession,
|
||||
searchSessions,
|
||||
clearAllSessions,
|
||||
scheduleAutoSave,
|
||||
cancelAutoSave,
|
||||
exportSessionAsJson,
|
||||
exportSessionAsMarkdown,
|
||||
exportSessionAsHtml,
|
||||
exportSessionAsPdf,
|
||||
importSession,
|
||||
};
|
||||
}
|
||||
|
||||
export const sessionsStore = createSessionsStore();
|
||||
@@ -0,0 +1,353 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||
import { snippetsStore, type Snippet } from "./snippets";
|
||||
|
||||
describe("Snippet interface", () => {
|
||||
it("defines all required fields", () => {
|
||||
const snippet: Snippet = {
|
||||
id: "snippet-123",
|
||||
name: "Git Status",
|
||||
content: "git status",
|
||||
category: "Git",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
expect(snippet.id).toBe("snippet-123");
|
||||
expect(snippet.name).toBe("Git Status");
|
||||
expect(snippet.content).toBe("git status");
|
||||
expect(snippet.category).toBe("Git");
|
||||
expect(snippet.is_default).toBe(false);
|
||||
expect(snippet.created_at).toBe("2024-01-01T00:00:00Z");
|
||||
expect(snippet.updated_at).toBe("2024-01-01T00:00:00Z");
|
||||
});
|
||||
|
||||
it("supports default snippets", () => {
|
||||
const defaultSnippet: Snippet = {
|
||||
id: "default-git-status",
|
||||
name: "Git Status",
|
||||
content: "git status --short",
|
||||
category: "Git",
|
||||
is_default: true,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
expect(defaultSnippet.is_default).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snippetsStore", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("store structure", () => {
|
||||
it("has all expected methods", () => {
|
||||
expect(typeof snippetsStore.loadSnippets).toBe("function");
|
||||
expect(typeof snippetsStore.saveSnippet).toBe("function");
|
||||
expect(typeof snippetsStore.createSnippet).toBe("function");
|
||||
expect(typeof snippetsStore.updateSnippet).toBe("function");
|
||||
expect(typeof snippetsStore.deleteSnippet).toBe("function");
|
||||
expect(typeof snippetsStore.resetDefaults).toBe("function");
|
||||
expect(typeof snippetsStore.setSelectedCategory).toBe("function");
|
||||
});
|
||||
|
||||
it("has subscribable stores", () => {
|
||||
expect(typeof snippetsStore.snippets.subscribe).toBe("function");
|
||||
expect(typeof snippetsStore.categories.subscribe).toBe("function");
|
||||
expect(typeof snippetsStore.filteredSnippets.subscribe).toBe("function");
|
||||
expect(typeof snippetsStore.isLoading.subscribe).toBe("function");
|
||||
expect(typeof snippetsStore.selectedCategory.subscribe).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadSnippets", () => {
|
||||
it("loads snippets and categories from backend", async () => {
|
||||
const mockSnippets: Snippet[] = [
|
||||
{
|
||||
id: "snippet-1",
|
||||
name: "Snippet 1",
|
||||
content: "content 1",
|
||||
category: "Git",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
const mockCategories = ["Git", "Shell", "Docker"];
|
||||
|
||||
setMockInvokeResult("list_snippets", mockSnippets);
|
||||
setMockInvokeResult("get_snippet_categories", mockCategories);
|
||||
|
||||
await snippetsStore.loadSnippets();
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("list_snippets");
|
||||
expect(invoke).toHaveBeenCalledWith("get_snippet_categories");
|
||||
});
|
||||
|
||||
it("handles load errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("list_snippets", new Error("Failed to load"));
|
||||
|
||||
await snippetsStore.loadSnippets();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to load snippets:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveSnippet", () => {
|
||||
it("saves snippet and reloads list", async () => {
|
||||
const snippet: Snippet = {
|
||||
id: "snippet-123",
|
||||
name: "Test",
|
||||
content: "test content",
|
||||
category: "Test",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
setMockInvokeResult("save_snippet", undefined);
|
||||
setMockInvokeResult("list_snippets", [snippet]);
|
||||
setMockInvokeResult("get_snippet_categories", ["Test"]);
|
||||
|
||||
const result = await snippetsStore.saveSnippet(snippet);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith("save_snippet", { snippet });
|
||||
});
|
||||
|
||||
it("handles save errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("save_snippet", new Error("Failed to save"));
|
||||
|
||||
const snippet: Snippet = {
|
||||
id: "snippet-123",
|
||||
name: "Test",
|
||||
content: "test content",
|
||||
category: "Test",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const result = await snippetsStore.saveSnippet(snippet);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to save snippet:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSnippet", () => {
|
||||
it("creates new snippet with generated ID and timestamps", async () => {
|
||||
setMockInvokeResult("save_snippet", undefined);
|
||||
setMockInvokeResult("list_snippets", []);
|
||||
setMockInvokeResult("get_snippet_categories", ["Shell"]);
|
||||
|
||||
const result = await snippetsStore.createSnippet("My Snippet", "echo hello", "Shell");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"save_snippet",
|
||||
expect.objectContaining({
|
||||
snippet: expect.objectContaining({
|
||||
name: "My Snippet",
|
||||
content: "echo hello",
|
||||
category: "Shell",
|
||||
is_default: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSnippet", () => {
|
||||
it("updates existing snippet preserving created_at", async () => {
|
||||
const existingSnippet: Snippet = {
|
||||
id: "snippet-123",
|
||||
name: "Old Name",
|
||||
content: "old content",
|
||||
category: "Old Category",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
setMockInvokeResult("list_snippets", [existingSnippet]);
|
||||
setMockInvokeResult("save_snippet", undefined);
|
||||
setMockInvokeResult("get_snippet_categories", ["New Category"]);
|
||||
|
||||
const result = await snippetsStore.updateSnippet(
|
||||
"snippet-123",
|
||||
"New Name",
|
||||
"new content",
|
||||
"New Category"
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"save_snippet",
|
||||
expect.objectContaining({
|
||||
snippet: expect.objectContaining({
|
||||
id: "snippet-123",
|
||||
name: "New Name",
|
||||
content: "new content",
|
||||
category: "New Category",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for non-existent snippet", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("list_snippets", []);
|
||||
|
||||
const result = await snippetsStore.updateSnippet(
|
||||
"non-existent",
|
||||
"Name",
|
||||
"content",
|
||||
"Category"
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Snippet not found for update");
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteSnippet", () => {
|
||||
it("deletes snippet by ID", async () => {
|
||||
setMockInvokeResult("delete_snippet", undefined);
|
||||
setMockInvokeResult("list_snippets", []);
|
||||
setMockInvokeResult("get_snippet_categories", []);
|
||||
|
||||
const result = await snippetsStore.deleteSnippet("snippet-123");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith("delete_snippet", { snippetId: "snippet-123" });
|
||||
});
|
||||
|
||||
it("handles delete errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("delete_snippet", new Error("Cannot delete default snippet"));
|
||||
|
||||
const result = await snippetsStore.deleteSnippet("default-1");
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to delete snippet:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetDefaults", () => {
|
||||
it("resets default snippets", async () => {
|
||||
setMockInvokeResult("reset_default_snippets", undefined);
|
||||
setMockInvokeResult("list_snippets", []);
|
||||
setMockInvokeResult("get_snippet_categories", []);
|
||||
|
||||
const result = await snippetsStore.resetDefaults();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith("reset_default_snippets");
|
||||
});
|
||||
|
||||
it("handles reset errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("reset_default_snippets", new Error("Reset failed"));
|
||||
|
||||
const result = await snippetsStore.resetDefaults();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Failed to reset default snippets:",
|
||||
expect.any(Error)
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSelectedCategory", () => {
|
||||
it("updates selected category", () => {
|
||||
snippetsStore.setSelectedCategory("Git");
|
||||
expect(get(snippetsStore.selectedCategory)).toBe("Git");
|
||||
});
|
||||
|
||||
it("can be cleared with null", () => {
|
||||
snippetsStore.setSelectedCategory("Git");
|
||||
snippetsStore.setSelectedCategory(null);
|
||||
expect(get(snippetsStore.selectedCategory)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("snippet ID generation", () => {
|
||||
it("generates unique custom snippet IDs", () => {
|
||||
const generateId = () => `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
const id1 = generateId();
|
||||
const id2 = generateId();
|
||||
|
||||
expect(id1).toMatch(/^custom-\d+-[a-z0-9]+$/);
|
||||
expect(id2).toMatch(/^custom-\d+-[a-z0-9]+$/);
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snippet validation", () => {
|
||||
it("requires non-empty name", () => {
|
||||
const snippet = { name: "" };
|
||||
const isValid = snippet.name.trim().length > 0;
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("requires non-empty content", () => {
|
||||
const snippet = { content: " " };
|
||||
const isValid = snippet.content.trim().length > 0;
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("requires non-empty category", () => {
|
||||
const snippet = { category: "Git" };
|
||||
const isValid = snippet.category.trim().length > 0;
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snippet content types", () => {
|
||||
it("supports multiline content", () => {
|
||||
const snippet = {
|
||||
content: `git add .
|
||||
git commit -m "message"
|
||||
git push`,
|
||||
};
|
||||
|
||||
expect(snippet.content.includes("\n")).toBe(true);
|
||||
expect(snippet.content.split("\n")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("supports content with special characters", () => {
|
||||
const snippet = {
|
||||
content: "echo \"Hello, World!\" && echo 'Single quotes'",
|
||||
};
|
||||
|
||||
expect(snippet.content).toContain('"');
|
||||
expect(snippet.content).toContain("'");
|
||||
expect(snippet.content).toContain("&&");
|
||||
});
|
||||
|
||||
it("supports content with variables", () => {
|
||||
const snippet = {
|
||||
content: "docker run -v $PWD:/app ${IMAGE_NAME}:${TAG}",
|
||||
};
|
||||
|
||||
expect(snippet.content).toContain("$PWD");
|
||||
expect(snippet.content).toContain("${IMAGE_NAME}");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface Snippet {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
category: string;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function createSnippetsStore() {
|
||||
const snippets = writable<Snippet[]>([]);
|
||||
const categories = writable<string[]>([]);
|
||||
const isLoading = writable(false);
|
||||
const selectedCategory = writable<string | null>(null);
|
||||
|
||||
const filteredSnippets = derived(
|
||||
[snippets, selectedCategory],
|
||||
([$snippets, $selectedCategory]) => {
|
||||
if (!$selectedCategory) {
|
||||
return $snippets;
|
||||
}
|
||||
return $snippets.filter((s) => s.category === $selectedCategory);
|
||||
}
|
||||
);
|
||||
|
||||
async function loadSnippets(): Promise<void> {
|
||||
isLoading.set(true);
|
||||
try {
|
||||
const [snippetList, categoryList] = await Promise.all([
|
||||
invoke<Snippet[]>("list_snippets"),
|
||||
invoke<string[]>("get_snippet_categories"),
|
||||
]);
|
||||
snippets.set(snippetList);
|
||||
categories.set(categoryList);
|
||||
} catch (error) {
|
||||
console.error("Failed to load snippets:", error);
|
||||
} finally {
|
||||
isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSnippet(snippet: Snippet): Promise<boolean> {
|
||||
try {
|
||||
await invoke("save_snippet", { snippet });
|
||||
await loadSnippets();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to save snippet:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createSnippet(name: string, content: string, category: string): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const snippet: Snippet = {
|
||||
id: `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
name,
|
||||
content,
|
||||
category,
|
||||
is_default: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
return saveSnippet(snippet);
|
||||
}
|
||||
|
||||
async function updateSnippet(
|
||||
id: string,
|
||||
name: string,
|
||||
content: string,
|
||||
category: string
|
||||
): Promise<boolean> {
|
||||
const currentSnippets = await invoke<Snippet[]>("list_snippets");
|
||||
const existing = currentSnippets.find((s) => s.id === id);
|
||||
|
||||
if (!existing) {
|
||||
console.error("Snippet not found for update");
|
||||
return false;
|
||||
}
|
||||
|
||||
const updated: Snippet = {
|
||||
...existing,
|
||||
name,
|
||||
content,
|
||||
category,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return saveSnippet(updated);
|
||||
}
|
||||
|
||||
async function deleteSnippet(snippetId: string): Promise<boolean> {
|
||||
try {
|
||||
await invoke("delete_snippet", { snippetId });
|
||||
await loadSnippets();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete snippet:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDefaults(): Promise<boolean> {
|
||||
try {
|
||||
await invoke("reset_default_snippets");
|
||||
await loadSnippets();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to reset default snippets:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setSelectedCategory(category: string | null): void {
|
||||
selectedCategory.set(category);
|
||||
}
|
||||
|
||||
return {
|
||||
snippets: { subscribe: snippets.subscribe },
|
||||
categories: { subscribe: categories.subscribe },
|
||||
filteredSnippets: { subscribe: filteredSnippets.subscribe },
|
||||
isLoading: { subscribe: isLoading.subscribe },
|
||||
selectedCategory: { subscribe: selectedCategory.subscribe },
|
||||
loadSnippets,
|
||||
saveSnippet,
|
||||
createSnippet,
|
||||
updateSnippet,
|
||||
deleteSnippet,
|
||||
resetDefaults,
|
||||
setSelectedCategory,
|
||||
};
|
||||
}
|
||||
|
||||
export const snippetsStore = createSnippetsStore();
|
||||
@@ -0,0 +1,346 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { stats, formattedStats, resetSessionStats } from "./stats";
|
||||
import type { UsageStats } from "./stats";
|
||||
|
||||
// Mock Tauri APIs
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("stats store", () => {
|
||||
beforeEach(() => {
|
||||
// Reset stats to default before each test
|
||||
stats.set({
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cost_usd: 0,
|
||||
session_input_tokens: 0,
|
||||
session_output_tokens: 0,
|
||||
session_cost_usd: 0,
|
||||
model: null,
|
||||
messages_exchanged: 0,
|
||||
session_messages_exchanged: 0,
|
||||
code_blocks_generated: 0,
|
||||
session_code_blocks_generated: 0,
|
||||
files_edited: 0,
|
||||
session_files_edited: 0,
|
||||
files_created: 0,
|
||||
session_files_created: 0,
|
||||
tools_usage: {},
|
||||
session_tools_usage: {},
|
||||
session_duration_seconds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
describe("stats writable store", () => {
|
||||
it("has correct default values", () => {
|
||||
const currentStats = get(stats);
|
||||
expect(currentStats.total_input_tokens).toBe(0);
|
||||
expect(currentStats.total_output_tokens).toBe(0);
|
||||
expect(currentStats.total_cost_usd).toBe(0);
|
||||
expect(currentStats.model).toBeNull();
|
||||
});
|
||||
|
||||
it("can be updated with set", () => {
|
||||
const newStats: UsageStats = {
|
||||
total_input_tokens: 1000,
|
||||
total_output_tokens: 2000,
|
||||
total_cost_usd: 0.05,
|
||||
session_input_tokens: 500,
|
||||
session_output_tokens: 1000,
|
||||
session_cost_usd: 0.025,
|
||||
model: "claude-sonnet-4",
|
||||
messages_exchanged: 10,
|
||||
session_messages_exchanged: 5,
|
||||
code_blocks_generated: 3,
|
||||
session_code_blocks_generated: 2,
|
||||
files_edited: 5,
|
||||
session_files_edited: 2,
|
||||
files_created: 1,
|
||||
session_files_created: 1,
|
||||
tools_usage: { Read: 5, Edit: 3 },
|
||||
session_tools_usage: { Read: 2, Edit: 1 },
|
||||
session_duration_seconds: 300,
|
||||
};
|
||||
|
||||
stats.set(newStats);
|
||||
const currentStats = get(stats);
|
||||
|
||||
expect(currentStats.total_input_tokens).toBe(1000);
|
||||
expect(currentStats.total_output_tokens).toBe(2000);
|
||||
expect(currentStats.model).toBe("claude-sonnet-4");
|
||||
expect(currentStats.tools_usage).toEqual({ Read: 5, Edit: 3 });
|
||||
});
|
||||
|
||||
it("can be updated with update function", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
total_input_tokens: 500,
|
||||
session_messages_exchanged: 3,
|
||||
}));
|
||||
|
||||
const currentStats = get(stats);
|
||||
expect(currentStats.total_input_tokens).toBe(500);
|
||||
expect(currentStats.session_messages_exchanged).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetSessionStats", () => {
|
||||
it("resets all session fields to zero", () => {
|
||||
// First set some values
|
||||
stats.set({
|
||||
total_input_tokens: 1000,
|
||||
total_output_tokens: 2000,
|
||||
total_cost_usd: 0.05,
|
||||
session_input_tokens: 500,
|
||||
session_output_tokens: 1000,
|
||||
session_cost_usd: 0.025,
|
||||
model: "claude-sonnet-4",
|
||||
messages_exchanged: 10,
|
||||
session_messages_exchanged: 5,
|
||||
code_blocks_generated: 3,
|
||||
session_code_blocks_generated: 2,
|
||||
files_edited: 5,
|
||||
session_files_edited: 2,
|
||||
files_created: 1,
|
||||
session_files_created: 1,
|
||||
tools_usage: { Read: 5, Edit: 3 },
|
||||
session_tools_usage: { Read: 2, Edit: 1 },
|
||||
session_duration_seconds: 300,
|
||||
});
|
||||
|
||||
// Reset session stats
|
||||
resetSessionStats();
|
||||
|
||||
const currentStats = get(stats);
|
||||
|
||||
// Total stats should be preserved
|
||||
expect(currentStats.total_input_tokens).toBe(1000);
|
||||
expect(currentStats.total_output_tokens).toBe(2000);
|
||||
expect(currentStats.total_cost_usd).toBe(0.05);
|
||||
expect(currentStats.messages_exchanged).toBe(10);
|
||||
expect(currentStats.code_blocks_generated).toBe(3);
|
||||
expect(currentStats.files_edited).toBe(5);
|
||||
expect(currentStats.files_created).toBe(1);
|
||||
expect(currentStats.tools_usage).toEqual({ Read: 5, Edit: 3 });
|
||||
expect(currentStats.model).toBe("claude-sonnet-4");
|
||||
|
||||
// Session stats should be reset
|
||||
expect(currentStats.session_input_tokens).toBe(0);
|
||||
expect(currentStats.session_output_tokens).toBe(0);
|
||||
expect(currentStats.session_cost_usd).toBe(0);
|
||||
expect(currentStats.session_messages_exchanged).toBe(0);
|
||||
expect(currentStats.session_code_blocks_generated).toBe(0);
|
||||
expect(currentStats.session_files_edited).toBe(0);
|
||||
expect(currentStats.session_files_created).toBe(0);
|
||||
expect(currentStats.session_tools_usage).toEqual({});
|
||||
expect(currentStats.session_duration_seconds).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formattedStats derived store", () => {
|
||||
it("formats token numbers with locale string", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
total_input_tokens: 1234567,
|
||||
total_output_tokens: 7654321,
|
||||
session_input_tokens: 12345,
|
||||
session_output_tokens: 54321,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
|
||||
expect(formatted.totalTokens).toBe("8,888,888");
|
||||
expect(formatted.totalInputTokens).toBe("1,234,567");
|
||||
expect(formatted.totalOutputTokens).toBe("7,654,321");
|
||||
expect(formatted.sessionTokens).toBe("66,666");
|
||||
expect(formatted.sessionInputTokens).toBe("12,345");
|
||||
expect(formatted.sessionOutputTokens).toBe("54,321");
|
||||
});
|
||||
|
||||
it("formats cost with 4 decimal places", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
total_cost_usd: 1.23456,
|
||||
session_cost_usd: 0.00123,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
|
||||
expect(formatted.totalCost).toBe("$1.2346");
|
||||
expect(formatted.sessionCost).toBe("$0.0012");
|
||||
});
|
||||
|
||||
it("formats duration seconds only", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
session_duration_seconds: 45,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.sessionDuration).toBe("45s");
|
||||
});
|
||||
|
||||
it("formats duration minutes and seconds", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
session_duration_seconds: 125, // 2m 5s
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.sessionDuration).toBe("2m 5s");
|
||||
});
|
||||
|
||||
it("formats duration hours, minutes, and seconds", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
session_duration_seconds: 3725, // 1h 2m 5s
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.sessionDuration).toBe("1h 2m 5s");
|
||||
});
|
||||
|
||||
it("formats duration with zero seconds", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
session_duration_seconds: 3600, // exactly 1h
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.sessionDuration).toBe("1h 0m 0s");
|
||||
});
|
||||
|
||||
it("shows model name when available", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
model: "claude-opus-4-5",
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.model).toBe("claude-opus-4-5");
|
||||
});
|
||||
|
||||
it("shows placeholder when model is null", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
model: null,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.model).toBe("No model selected");
|
||||
});
|
||||
|
||||
it("formats message counts", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
messages_exchanged: 100,
|
||||
session_messages_exchanged: 10,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.messagesTotal).toBe("100");
|
||||
expect(formatted.messagesSession).toBe("10");
|
||||
});
|
||||
|
||||
it("formats code block counts", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
code_blocks_generated: 50,
|
||||
session_code_blocks_generated: 5,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.codeBlocksTotal).toBe("50");
|
||||
expect(formatted.codeBlocksSession).toBe("5");
|
||||
});
|
||||
|
||||
it("formats file counts", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
files_edited: 25,
|
||||
session_files_edited: 3,
|
||||
files_created: 10,
|
||||
session_files_created: 2,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.filesEditedTotal).toBe("25");
|
||||
expect(formatted.filesEditedSession).toBe("3");
|
||||
expect(formatted.filesCreatedTotal).toBe("10");
|
||||
expect(formatted.filesCreatedSession).toBe("2");
|
||||
});
|
||||
|
||||
it("exposes tools usage directly", () => {
|
||||
const toolsUsage = { Read: 10, Edit: 5, Write: 3 };
|
||||
const sessionToolsUsage = { Read: 2, Edit: 1 };
|
||||
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
tools_usage: toolsUsage,
|
||||
session_tools_usage: sessionToolsUsage,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.toolsUsage).toEqual(toolsUsage);
|
||||
expect(formatted.sessionToolsUsage).toEqual(sessionToolsUsage);
|
||||
});
|
||||
|
||||
it("handles zero values correctly", () => {
|
||||
const formatted = get(formattedStats);
|
||||
|
||||
expect(formatted.totalTokens).toBe("0");
|
||||
expect(formatted.totalCost).toBe("$0.0000");
|
||||
expect(formatted.sessionDuration).toBe("0s");
|
||||
expect(formatted.messagesTotal).toBe("0");
|
||||
});
|
||||
|
||||
it("handles large numbers with proper formatting", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
total_input_tokens: 1000000000, // 1 billion
|
||||
messages_exchanged: 999999,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.totalInputTokens).toBe("1,000,000,000");
|
||||
expect(formatted.messagesTotal).toBe("999,999");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageStats interface", () => {
|
||||
it("supports all expected fields", () => {
|
||||
const fullStats: UsageStats = {
|
||||
total_input_tokens: 100,
|
||||
total_output_tokens: 200,
|
||||
total_cost_usd: 0.01,
|
||||
session_input_tokens: 50,
|
||||
session_output_tokens: 100,
|
||||
session_cost_usd: 0.005,
|
||||
model: "test-model",
|
||||
messages_exchanged: 5,
|
||||
session_messages_exchanged: 2,
|
||||
code_blocks_generated: 3,
|
||||
session_code_blocks_generated: 1,
|
||||
files_edited: 2,
|
||||
session_files_edited: 1,
|
||||
files_created: 1,
|
||||
session_files_created: 0,
|
||||
tools_usage: { Read: 3 },
|
||||
session_tools_usage: { Read: 1 },
|
||||
session_duration_seconds: 60,
|
||||
};
|
||||
|
||||
stats.set(fullStats);
|
||||
const currentStats = get(stats);
|
||||
|
||||
// Verify all fields are present and correct
|
||||
expect(currentStats).toEqual(fullStats);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -104,10 +104,11 @@ export async function initStatsListener() {
|
||||
stats.set(newStats);
|
||||
});
|
||||
|
||||
// Load initial stats from backend
|
||||
// Load initial persisted stats from backend (no bridge required)
|
||||
try {
|
||||
const initialStats = await invoke<UsageStats>("get_usage_stats");
|
||||
const initialStats = await invoke<UsageStats>("get_persisted_stats");
|
||||
stats.set(initialStats);
|
||||
console.log("Loaded persisted stats:", initialStats);
|
||||
} catch (error) {
|
||||
console.error("Failed to load initial stats:", error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { emitMockEvent, setMockInvokeResult } from "../../vitest.setup";
|
||||
|
||||
// We need to test the helper functions that are exported
|
||||
// The main listener initialization is tested through integration
|
||||
|
||||
describe("tauri helpers", () => {
|
||||
describe("getTimeOfDay (inferred from greeting behavior)", () => {
|
||||
it("returns morning for hours 5-11", () => {
|
||||
const date = new Date();
|
||||
date.setHours(8, 0, 0, 0);
|
||||
vi.setSystemTime(date);
|
||||
|
||||
// The getTimeOfDay function is private, but we can verify the greeting prompt includes the time
|
||||
// This tests the logic indirectly
|
||||
expect(date.getHours()).toBeGreaterThanOrEqual(5);
|
||||
expect(date.getHours()).toBeLessThan(12);
|
||||
});
|
||||
|
||||
it("returns afternoon for hours 12-16", () => {
|
||||
const date = new Date();
|
||||
date.setHours(14, 0, 0, 0);
|
||||
vi.setSystemTime(date);
|
||||
|
||||
expect(date.getHours()).toBeGreaterThanOrEqual(12);
|
||||
expect(date.getHours()).toBeLessThan(17);
|
||||
});
|
||||
|
||||
it("returns evening for hours 17-20", () => {
|
||||
const date = new Date();
|
||||
date.setHours(19, 0, 0, 0);
|
||||
vi.setSystemTime(date);
|
||||
|
||||
expect(date.getHours()).toBeGreaterThanOrEqual(17);
|
||||
expect(date.getHours()).toBeLessThan(21);
|
||||
});
|
||||
|
||||
it("returns late night for hours 21-4", () => {
|
||||
const date = new Date();
|
||||
date.setHours(23, 0, 0, 0);
|
||||
vi.setSystemTime(date);
|
||||
|
||||
expect(date.getHours() >= 21 || date.getHours() < 5).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("tauri event handling", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
describe("connection events", () => {
|
||||
it("emits connected status payload", () => {
|
||||
const payload = {
|
||||
status: "connected",
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
// Verify payload structure
|
||||
expect(payload.status).toBe("connected");
|
||||
expect(payload.conversation_id).toBe("test-conv-1");
|
||||
});
|
||||
|
||||
it("emits disconnected status payload", () => {
|
||||
const payload = {
|
||||
status: "disconnected",
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.status).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("emits error status payload", () => {
|
||||
const payload = {
|
||||
status: "error",
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.status).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("state change events", () => {
|
||||
it("maps idle state correctly", () => {
|
||||
const payload = {
|
||||
state: "idle",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.state.toLowerCase()).toBe("idle");
|
||||
});
|
||||
|
||||
it("maps thinking state correctly", () => {
|
||||
const payload = {
|
||||
state: "thinking",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.state.toLowerCase()).toBe("thinking");
|
||||
});
|
||||
|
||||
it("maps typing state correctly", () => {
|
||||
const payload = {
|
||||
state: "typing",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.state.toLowerCase()).toBe("typing");
|
||||
});
|
||||
|
||||
it("maps searching state correctly", () => {
|
||||
const payload = {
|
||||
state: "searching",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.state.toLowerCase()).toBe("searching");
|
||||
});
|
||||
|
||||
it("maps coding state correctly", () => {
|
||||
const payload = {
|
||||
state: "coding",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.state.toLowerCase()).toBe("coding");
|
||||
});
|
||||
|
||||
it("maps mcp state correctly", () => {
|
||||
const payload = {
|
||||
state: "mcp",
|
||||
tool_name: "some-tool",
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.state.toLowerCase()).toBe("mcp");
|
||||
expect(payload.tool_name).toBe("some-tool");
|
||||
});
|
||||
|
||||
it("maps permission state correctly", () => {
|
||||
const payload = {
|
||||
state: "permission",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.state.toLowerCase()).toBe("permission");
|
||||
});
|
||||
|
||||
it("maps success state correctly", () => {
|
||||
const payload = {
|
||||
state: "success",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.state.toLowerCase()).toBe("success");
|
||||
});
|
||||
|
||||
it("maps error state correctly", () => {
|
||||
const payload = {
|
||||
state: "error",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.state.toLowerCase()).toBe("error");
|
||||
});
|
||||
|
||||
it("defaults unknown state to idle", () => {
|
||||
const stateMap: Record<string, string> = {
|
||||
idle: "idle",
|
||||
thinking: "thinking",
|
||||
typing: "typing",
|
||||
searching: "searching",
|
||||
coding: "coding",
|
||||
mcp: "mcp",
|
||||
permission: "permission",
|
||||
success: "success",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
const unknownState = "unknown-state";
|
||||
const mappedState = stateMap[unknownState.toLowerCase()] || "idle";
|
||||
expect(mappedState).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("output events", () => {
|
||||
it("handles user output type", () => {
|
||||
const payload = {
|
||||
line_type: "user",
|
||||
content: "Hello, world!",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.line_type).toBe("user");
|
||||
expect(payload.content).toBe("Hello, world!");
|
||||
});
|
||||
|
||||
it("handles assistant output type", () => {
|
||||
const payload = {
|
||||
line_type: "assistant",
|
||||
content: "Hi there!",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.line_type).toBe("assistant");
|
||||
});
|
||||
|
||||
it("handles system output type", () => {
|
||||
const payload = {
|
||||
line_type: "system",
|
||||
content: "Connected to Claude Code",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.line_type).toBe("system");
|
||||
});
|
||||
|
||||
it("handles tool output type with tool name", () => {
|
||||
const payload = {
|
||||
line_type: "tool",
|
||||
content: "Tool executed successfully",
|
||||
tool_name: "Read",
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.line_type).toBe("tool");
|
||||
expect(payload.tool_name).toBe("Read");
|
||||
});
|
||||
|
||||
it("handles error output type", () => {
|
||||
const payload = {
|
||||
line_type: "error",
|
||||
content: "An error occurred",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.line_type).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session events", () => {
|
||||
it("handles session payload with conversation id", () => {
|
||||
const payload = {
|
||||
session_id: "sess-12345678",
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.session_id).toBe("sess-12345678");
|
||||
expect(payload.conversation_id).toBe("test-conv-1");
|
||||
});
|
||||
|
||||
it("creates truncated session display", () => {
|
||||
const sessionId = "sess-12345678-90ab-cdef";
|
||||
const display = `Session: ${sessionId.substring(0, 8)}...`;
|
||||
|
||||
expect(display).toBe("Session: sess-123...");
|
||||
});
|
||||
});
|
||||
|
||||
describe("working directory events", () => {
|
||||
it("handles cwd payload", () => {
|
||||
const payload = {
|
||||
directory: "/home/user/project",
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.directory).toBe("/home/user/project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("permission events", () => {
|
||||
it("handles permission payload structure", () => {
|
||||
const payload = {
|
||||
id: "perm-123",
|
||||
tool_name: "Bash",
|
||||
tool_input: '{"command": "ls -la"}',
|
||||
description: "Run shell command",
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.id).toBe("perm-123");
|
||||
expect(payload.tool_name).toBe("Bash");
|
||||
expect(payload.tool_input).toContain("command");
|
||||
expect(payload.description).toBe("Run shell command");
|
||||
});
|
||||
});
|
||||
|
||||
describe("question events", () => {
|
||||
it("handles question payload structure", () => {
|
||||
const payload = {
|
||||
id: "q-123",
|
||||
question: "Which option would you like?",
|
||||
options: [
|
||||
{ label: "Option A", description: "First option" },
|
||||
{ label: "Option B", description: "Second option" },
|
||||
],
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.id).toBe("q-123");
|
||||
expect(payload.question).toBe("Which option would you like?");
|
||||
expect(payload.options).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mock event system", () => {
|
||||
it("can emit events through mock system", () => {
|
||||
// The emitMockEvent function should work
|
||||
expect(typeof emitMockEvent).toBe("function");
|
||||
});
|
||||
|
||||
it("can set mock invoke results", () => {
|
||||
setMockInvokeResult("test_command", { result: "success" });
|
||||
// This verifies the mock setup is working
|
||||
expect(typeof setMockInvokeResult).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("greeting system", () => {
|
||||
it("generates greeting prompt with time of day", () => {
|
||||
const timeOfDay = "morning";
|
||||
const prompt = `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`;
|
||||
|
||||
expect(prompt).toContain("morning");
|
||||
expect(prompt).toContain("greet the user");
|
||||
});
|
||||
|
||||
it("uses custom greeting prompt when provided", () => {
|
||||
const customPrompt = "Say hello in pirate speak!";
|
||||
const greetingPrompt = customPrompt.trim() || "default greeting";
|
||||
|
||||
expect(greetingPrompt).toBe("Say hello in pirate speak!");
|
||||
});
|
||||
|
||||
it("uses default prompt when custom is empty", () => {
|
||||
const customPrompt = " ";
|
||||
const defaultPrompt = "default greeting";
|
||||
const greetingPrompt = customPrompt.trim() || defaultPrompt;
|
||||
|
||||
expect(greetingPrompt).toBe(defaultPrompt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation tracking", () => {
|
||||
it("tracks connected conversations with Set", () => {
|
||||
const connectedConversations = new Set<string>();
|
||||
|
||||
connectedConversations.add("conv-1");
|
||||
expect(connectedConversations.has("conv-1")).toBe(true);
|
||||
expect(connectedConversations.size).toBe(1);
|
||||
|
||||
connectedConversations.add("conv-2");
|
||||
expect(connectedConversations.size).toBe(2);
|
||||
|
||||
connectedConversations.delete("conv-1");
|
||||
expect(connectedConversations.has("conv-1")).toBe(false);
|
||||
expect(connectedConversations.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skip greeting flag", () => {
|
||||
it("flag can be set and reset", () => {
|
||||
let skipNextGreeting = false;
|
||||
|
||||
skipNextGreeting = true;
|
||||
expect(skipNextGreeting).toBe(true);
|
||||
|
||||
// Simulate reset after use
|
||||
if (skipNextGreeting) {
|
||||
skipNextGreeting = false;
|
||||
}
|
||||
expect(skipNextGreeting).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,11 @@ import { characterState } from "$lib/stores/character";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
import { initStatsListener, resetSessionStats } from "$lib/stores/stats";
|
||||
import { initAchievementsListener } from "$lib/stores/achievements";
|
||||
import type { ConnectionStatus, PermissionPromptEvent } from "$lib/types/messages";
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
PermissionPromptEvent,
|
||||
UserQuestionEvent,
|
||||
} from "$lib/types/messages";
|
||||
import type { CharacterState } from "$lib/types/states";
|
||||
import {
|
||||
initializeNotificationRules,
|
||||
@@ -103,8 +107,15 @@ interface WorkingDirectoryPayload {
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
export function cleanupConversationTracking(conversationId: string) {
|
||||
export async function cleanupConversationTracking(conversationId: string) {
|
||||
connectedConversations.delete(conversationId);
|
||||
|
||||
// Clean up any temp files associated with this conversation
|
||||
try {
|
||||
await invoke("cleanup_temp_files", { conversationId });
|
||||
} catch (error) {
|
||||
console.error("Failed to cleanup temp files for conversation:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializeTauriListeners() {
|
||||
@@ -292,29 +303,50 @@ export async function initializeTauriListeners() {
|
||||
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
|
||||
const { id, tool_name, tool_input, description, conversation_id } = event.payload;
|
||||
|
||||
// Only process permission requests for the active conversation
|
||||
const activeConversationId = get(claudeStore.activeConversationId);
|
||||
if (conversation_id === activeConversationId) {
|
||||
// Store permission request for the specific conversation
|
||||
if (conversation_id) {
|
||||
claudeStore.requestPermissionForConversation(conversation_id, {
|
||||
id,
|
||||
tool: tool_name,
|
||||
description,
|
||||
input: tool_input,
|
||||
});
|
||||
claudeStore.addLineToConversation(
|
||||
conversation_id,
|
||||
"system",
|
||||
`Permission requested for: ${tool_name}`
|
||||
);
|
||||
} else {
|
||||
// Fallback to active conversation if no conversation_id
|
||||
claudeStore.requestPermission({
|
||||
id,
|
||||
tool: tool_name,
|
||||
description,
|
||||
input: tool_input,
|
||||
});
|
||||
}
|
||||
|
||||
// Always store the permission message to the correct conversation
|
||||
if (conversation_id) {
|
||||
claudeStore.addLineToConversation(
|
||||
conversation_id,
|
||||
"system",
|
||||
`Permission requested for: ${tool_name}`
|
||||
);
|
||||
} else if (conversation_id === activeConversationId) {
|
||||
claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
|
||||
}
|
||||
});
|
||||
unlisteners.push(permissionUnlisten);
|
||||
|
||||
const questionUnlisten = await listen<UserQuestionEvent>("claude:question", (event) => {
|
||||
const questionEvent = event.payload;
|
||||
|
||||
// Store question request for the specific conversation
|
||||
if (questionEvent.conversation_id) {
|
||||
claudeStore.requestQuestionForConversation(questionEvent.conversation_id, questionEvent);
|
||||
claudeStore.addLineToConversation(
|
||||
questionEvent.conversation_id,
|
||||
"system",
|
||||
`Question: ${questionEvent.question}`
|
||||
);
|
||||
} else {
|
||||
// Fallback to active conversation if no conversation_id
|
||||
claudeStore.requestQuestion(questionEvent);
|
||||
claudeStore.addLine("system", `Question: ${questionEvent.question}`);
|
||||
}
|
||||
});
|
||||
unlisteners.push(questionUnlisten);
|
||||
}
|
||||
|
||||
export function cleanupTauriListeners() {
|
||||
|
||||
@@ -14,34 +14,52 @@ export type AchievementId =
|
||||
| "GrowingStrong" // 10,000 tokens
|
||||
| "BlossomingCoder" // 100,000 tokens
|
||||
| "TokenMaster" // 1,000,000 tokens
|
||||
| "TokenBillionaire" // 10,000,000 tokens
|
||||
| "TokenTreasure" // 50,000,000 tokens
|
||||
|
||||
// Code Generation
|
||||
| "HelloWorld" // First code block
|
||||
| "CodeWizard" // 100 code blocks
|
||||
| "ThousandBlocks" // 1,000 code blocks
|
||||
| "CodeFactory" // 5,000 code blocks
|
||||
| "CodeEmpire" // 10,000 code blocks
|
||||
|
||||
// File Operations
|
||||
| "FileManipulator" // 10 files edited
|
||||
| "FileArchitect" // 100 files edited
|
||||
| "FileEngineer" // 500 files edited
|
||||
| "FileLegend" // 1,000 files edited
|
||||
|
||||
// Conversation milestones
|
||||
| "ConversationStarter" // 10 messages
|
||||
| "ChattyKathy" // 100 messages
|
||||
| "Conversationalist" // 1,000 messages
|
||||
| "ChatMarathon" // 5,000 messages
|
||||
| "ChatLegend" // 10,000 messages
|
||||
|
||||
// Tool usage
|
||||
| "Toolsmith" // 5 different tools
|
||||
| "ToolMaster" // 10 different tools
|
||||
|
||||
// Time-based achievements
|
||||
| "EarlyBird" // Started session 5-7 AM
|
||||
| "NightOwl" // Coding after midnight
|
||||
| "AllNighter" // Worked 2-5 AM
|
||||
| "WeekendWarrior" // Coding on weekend
|
||||
| "DedicatedDeveloper" // 30 days in a row
|
||||
|
||||
// Search and exploration
|
||||
| "Explorer" // 50 searches
|
||||
| "MasterSearcher" // 500 searches
|
||||
|
||||
// Session achievements
|
||||
| "QuickSession" // Productive session < 5 min
|
||||
| "FocusedWork" // 30 min session
|
||||
| "DeepDive" // 2 hour session
|
||||
| "MarathonSession" // 5+ hour session
|
||||
| "UltraMarathon" // 8 hour session
|
||||
| "CodingRetreat" // 12 hour session
|
||||
|
||||
// Special achievements
|
||||
| "FirstMessage" // First message sent
|
||||
| "FirstTool" // First tool used
|
||||
@@ -51,28 +69,165 @@ export type AchievementId =
|
||||
| "SpeedCoder" // 10 code blocks in 10 minutes
|
||||
| "ClaudeConnoisseur" // Used all Claude models
|
||||
| "MarathonCoder" // 10k tokens in one session
|
||||
|
||||
// Relationship & Greetings
|
||||
| "GoodMorning" // Said good morning
|
||||
| "GoodNight" // Said good night
|
||||
| "ThankYou" // Said thank you
|
||||
| "LoveYou" // Said love you
|
||||
| "GoodMorning" // Say "good morning"
|
||||
| "GoodNight" // Say "good night" or "goodnight"
|
||||
| "ThankYou" // Say "thank you" or "thanks"
|
||||
| "LoveYou" // Say "love you" or "ily"
|
||||
| "HelloHikari" // Say "hello hikari" or "hi hikari"
|
||||
| "HowAreYou" // Ask "how are you"
|
||||
| "MissedYou" // Say "missed you"
|
||||
| "BackAgain" // Say "i'm back" or "back again"
|
||||
|
||||
// Personality & Fun
|
||||
| "EmojiUser" // Used 20+ emojis
|
||||
| "CapsLock" // ALL CAPS MESSAGE
|
||||
| "QuestionMaster" // Asked 50 questions
|
||||
| "PleaseAndThankYou" // Polite user
|
||||
| "EmojiUser" // Use an emoji in a message
|
||||
| "QuestionMaster" // Use "?" in 20 messages
|
||||
| "CapsLock" // Send a message in ALL CAPS
|
||||
| "PleaseAndThankYou" // Use "please" in messages
|
||||
|
||||
// Emotional
|
||||
| "Frustrated" // Say "frustrated" or "ugh" or "argh"
|
||||
| "Excited" // Say "excited" or "yay" or "woohoo"
|
||||
| "Confused" // Say "confused" or "don't understand"
|
||||
| "Curious" // Ask "why" or "how does"
|
||||
| "Impressed" // Say "wow" or "amazing" or "incredible"
|
||||
|
||||
// Git & Development
|
||||
| "CommitMaster" // 100 commits
|
||||
| "PRO" // Created 10 PRs
|
||||
| "Reviewer" // Reviewed 10 PRs
|
||||
| "IssueTracker" // Created 25 issues
|
||||
| "GitGuru" // Used git commands
|
||||
| "GitGuru" // Use git commands 10 times
|
||||
| "TestWriter" // Create test files
|
||||
| "Debugger" // Fix bugs (messages with "fix", "bug", "error")
|
||||
| "CommitKing" // 50 commits
|
||||
| "CommitLegend" // 200 commits
|
||||
| "BranchMaster" // Create 10 branches
|
||||
| "MergeExpert" // Merge 20 PRs
|
||||
| "ConflictResolver" // Resolve merge conflicts
|
||||
|
||||
// Tool Mastery
|
||||
| "BashMaster" // Used bash 100 times
|
||||
| "FileExplorer" // Searched files 100 times
|
||||
| "SearchExpert" // Advanced searches
|
||||
| "AgentCommander" // Used task agents
|
||||
| "MCPMaster"; // Used MCP tools
|
||||
| "BashMaster" // Use Bash tool 50 times
|
||||
| "FileExplorer" // Use Read tool 100 times
|
||||
| "SearchExpert" // Use Grep tool 50 times
|
||||
| "EditMaster" // Use Edit tool 100 times
|
||||
| "WriteMaster" // Use Write tool 50 times
|
||||
| "GlobMaster" // Use Glob tool 100 times
|
||||
| "TaskMaster" // Use Task tool 50 times
|
||||
| "WebFetcher" // Use WebFetch tool 20 times
|
||||
| "McpExplorer" // Use MCP tools 50 times
|
||||
|
||||
// Daily Streaks
|
||||
| "WeekStreak" // 7 days in a row
|
||||
| "TwoWeekStreak" // 14 days in a row
|
||||
| "MonthStreak" // 30 days in a row
|
||||
| "QuarterStreak" // 90 days in a row
|
||||
|
||||
// Time Challenges
|
||||
| "MorningPerson" // 10 sessions started before 9 AM
|
||||
| "NightCoder" // 10 sessions after 10 PM
|
||||
| "LunchBreakCoder" // Session during 12-1 PM
|
||||
| "CoffeeTime" // Session during 3-4 PM
|
||||
|
||||
// Day-specific
|
||||
| "MondayMotivation" // Coding on Monday
|
||||
| "FridayFinisher" // Coding on Friday
|
||||
| "HumpDay" // Coding on Wednesday
|
||||
|
||||
// Seasonal/Special Times
|
||||
| "NewYearCoder" // Coding on January 1st
|
||||
| "ValentinesDev" // Coding on February 14th
|
||||
| "SpookyCode" // Coding on October 31st
|
||||
| "HolidayCoder" // Coding on December 25th
|
||||
| "LeapDayCoder" // Coding on February 29th
|
||||
|
||||
// Message Content
|
||||
| "LongMessage" // Send a message over 500 characters
|
||||
| "NovelWriter" // Send a message over 2000 characters
|
||||
| "ShortAndSweet" // Complete a task with messages under 50 chars each
|
||||
| "CodeInMessage" // Include code block in user message
|
||||
| "MarkdownMaster" // Use markdown formatting in message
|
||||
|
||||
// Programming Languages
|
||||
| "RustDeveloper" // Generate Rust code
|
||||
| "PythonDeveloper" // Generate Python code
|
||||
| "JavaScriptDev" // Generate JavaScript code
|
||||
| "TypeScriptDev" // Generate TypeScript code
|
||||
| "GoDeveloper" // Generate Go code
|
||||
| "CppDeveloper" // Generate C++ code
|
||||
| "JavaDeveloper" // Generate Java code
|
||||
| "HtmlCssDev" // Generate HTML/CSS code
|
||||
| "SqlDeveloper" // Generate SQL code
|
||||
| "ShellScripter" // Generate shell/bash scripts
|
||||
| "FullStackDev" // Generate code in 10+ languages
|
||||
|
||||
// Project Types
|
||||
| "FrontendDev" // Work on frontend files
|
||||
| "BackendDev" // Work on backend files
|
||||
| "ConfigEditor" // Edit config files
|
||||
| "DocWriter" // Edit documentation
|
||||
|
||||
// Error Handling
|
||||
| "ErrorHunter" // Fix 10 errors
|
||||
| "ExceptionSlayer" // Fix 50 errors
|
||||
| "BugExterminator" // Fix 100 bugs
|
||||
|
||||
// Refactoring
|
||||
| "CleanCoder" // Refactor code
|
||||
| "Optimizer" // Optimize performance
|
||||
| "Simplifier" // Simplify complex code
|
||||
|
||||
// Testing
|
||||
| "TestNovice" // Write 10 tests
|
||||
| "TestEnthusiast" // Write 50 tests
|
||||
| "TestMaster" // Write 100 tests
|
||||
| "CoverageKing" // Achieve test coverage mentions
|
||||
|
||||
// Documentation
|
||||
| "Documenter" // Write documentation
|
||||
| "CommentWriter" // Add comments to code
|
||||
| "ReadmeHero" // Create/edit README files
|
||||
|
||||
// API & Integration
|
||||
| "ApiExplorer" // Work with APIs
|
||||
| "DatabaseDev" // Work with databases
|
||||
| "CloudCoder" // Work with cloud services
|
||||
|
||||
// Special Milestones
|
||||
| "CenturyClub" // 100 sessions
|
||||
| "ThousandSessions" // 1000 sessions
|
||||
| "Veteran" // Used Hikari for 30+ days total
|
||||
| "OldTimer" // Used Hikari for 90+ days total
|
||||
| "Loyalist" // Used Hikari for 365+ days total
|
||||
|
||||
// Fun & Easter Eggs
|
||||
| "Perfectionist" // Redo something 5 times
|
||||
| "Persistent" // Ask same question 3 times
|
||||
| "Patient" // Wait for long response
|
||||
| "Speedy" // Send 10 messages in 1 minute
|
||||
|
||||
// UI Exploration
|
||||
| "MultiTasker" // Multiple conversations
|
||||
| "Minimalist" // Use compact mode
|
||||
| "PrivacyFirst" // Use streamer mode
|
||||
| "ThemeChanger" // Change themes
|
||||
| "SettingsTweaker" // Adjust settings
|
||||
|
||||
// Achievement Meta
|
||||
| "AchievementHunter" // Unlock 25 achievements
|
||||
| "Completionist" // Unlock 50 achievements
|
||||
| "MasterUnlocker" // Unlock 100 achievements
|
||||
| "PlatinumStatus" // Unlock all achievements
|
||||
|
||||
// Clipboard & Snippets
|
||||
| "ClipboardCollector" // Use clipboard history
|
||||
| "SnippetCreator" // Create a snippet
|
||||
| "SnippetMaster" // Create 20 snippets
|
||||
|
||||
// Other Features
|
||||
| "QuickActionUser" // Use quick actions
|
||||
| "HistoryBuff" // Browse history
|
||||
| "Archivist" // Export sessions
|
||||
| "SessionExporter" // Export a session
|
||||
| "GitPanelUser" // Use Git panel
|
||||
| "FeatureExplorer"; // Use 10+ features
|
||||
|
||||
export interface Achievement {
|
||||
id: AchievementId;
|
||||
|
||||
@@ -126,4 +126,36 @@ export interface PermissionPromptEvent {
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
export interface QuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UserQuestionEvent {
|
||||
id: string;
|
||||
question: string;
|
||||
header?: string;
|
||||
options: QuestionOption[];
|
||||
multi_select: boolean;
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
path: string;
|
||||
size: number;
|
||||
type: "image" | "document" | "other";
|
||||
mimeType?: string;
|
||||
previewUrl?: string; // For images, a data URL or object URL for preview
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
current_version: string;
|
||||
latest_version: string;
|
||||
has_update: boolean;
|
||||
release_url: string;
|
||||
release_notes?: string;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,247 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
|
||||
import { configStore, applyTheme } from "$lib/stores/config";
|
||||
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
|
||||
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||
import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window";
|
||||
import "$lib/notifications/testNotifications";
|
||||
import Terminal from "$lib/components/Terminal.svelte";
|
||||
import InputBar from "$lib/components/InputBar.svelte";
|
||||
import StatusBar from "$lib/components/StatusBar.svelte";
|
||||
import AnimeGirl from "$lib/components/AnimeGirl.svelte";
|
||||
import CompactMode from "$lib/components/CompactMode.svelte";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import type { CharacterState } from "$lib/types/states";
|
||||
import PermissionModal from "$lib/components/PermissionModal.svelte";
|
||||
import UserQuestionModal from "$lib/components/UserQuestionModal.svelte";
|
||||
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
|
||||
import AchievementNotification from "$lib/components/AchievementNotification.svelte";
|
||||
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
|
||||
import UpdateNotification from "$lib/components/UpdateNotification.svelte";
|
||||
|
||||
let initialized = false;
|
||||
let updateNotification: UpdateNotification | undefined = $state(undefined);
|
||||
let achievementPanelOpen = $state(false);
|
||||
let currentCharacterState: CharacterState = $state("idle");
|
||||
let compactModeActive = $state(false);
|
||||
|
||||
// Window size constants
|
||||
const COMPACT_WIDTH = 280;
|
||||
const COMPACT_HEIGHT = 400;
|
||||
|
||||
// Store the previous window size to restore when exiting compact mode
|
||||
let previousWindowSize: { width: number; height: number } | null = null;
|
||||
|
||||
characterState.subscribe((state) => {
|
||||
currentCharacterState = state;
|
||||
});
|
||||
|
||||
isCompactMode.subscribe((value) => {
|
||||
compactModeActive = value;
|
||||
});
|
||||
|
||||
function getPanelGlowClass(): string {
|
||||
switch (currentCharacterState) {
|
||||
case "thinking":
|
||||
return "panel-glow-thinking";
|
||||
case "typing":
|
||||
return "panel-glow-typing";
|
||||
case "searching":
|
||||
return "panel-glow-searching";
|
||||
case "coding":
|
||||
return "panel-glow-coding";
|
||||
case "mcp":
|
||||
return "panel-glow-mcp";
|
||||
case "success":
|
||||
return "panel-glow-success";
|
||||
case "error":
|
||||
return "panel-glow-error";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Resizable panel state
|
||||
let panelWidth = $state(320); // Default width in pixels
|
||||
let isResizing = $state(false);
|
||||
const MIN_PANEL_WIDTH = 200;
|
||||
const MAX_PANEL_WIDTH = 600;
|
||||
|
||||
function startResize(event: MouseEvent) {
|
||||
isResizing = true;
|
||||
event.preventDefault();
|
||||
document.addEventListener("mousemove", handleResize);
|
||||
document.addEventListener("mouseup", stopResize);
|
||||
}
|
||||
|
||||
function handleResize(event: MouseEvent) {
|
||||
if (!isResizing) return;
|
||||
const newWidth = event.clientX;
|
||||
panelWidth = Math.max(MIN_PANEL_WIDTH, Math.min(MAX_PANEL_WIDTH, newWidth));
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
if (isResizing) {
|
||||
isResizing = false;
|
||||
document.removeEventListener("mousemove", handleResize);
|
||||
document.removeEventListener("mouseup", stopResize);
|
||||
// Save the panel width to config
|
||||
configStore.updateConfig({ character_panel_width: panelWidth });
|
||||
}
|
||||
}
|
||||
|
||||
// Global keyboard shortcuts
|
||||
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||
// Don't trigger shortcuts when typing in inputs (except for specific ones)
|
||||
const target = event.target as HTMLElement;
|
||||
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA";
|
||||
|
||||
// Escape closes panels (always works)
|
||||
if (event.key === "Escape") {
|
||||
// Check if any panels are open and close them
|
||||
if (achievementPanelOpen) {
|
||||
achievementPanelOpen = false;
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
// ConfigSidebar handles its own escape via store
|
||||
if (get(configStore.isSidebarOpen)) {
|
||||
configStore.closeSidebar();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip other shortcuts if user is typing in an input
|
||||
if (isInputFocused) return;
|
||||
|
||||
// Ctrl+L - Clear terminal
|
||||
if (event.ctrlKey && event.key === "l") {
|
||||
event.preventDefault();
|
||||
claudeStore.clearTerminal();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+, - Open settings
|
||||
if (event.ctrlKey && event.key === ",") {
|
||||
event.preventDefault();
|
||||
configStore.openSidebar();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+C - Interrupt (only when processing)
|
||||
if (event.ctrlKey && event.key === "c") {
|
||||
if (get(isClaudeProcessing)) {
|
||||
event.preventDefault();
|
||||
handleInterrupt();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl++ or Ctrl+= - Increase font size
|
||||
if (event.ctrlKey && (event.key === "+" || event.key === "=")) {
|
||||
event.preventDefault();
|
||||
configStore.increaseFontSize();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+- - Decrease font size
|
||||
if (event.ctrlKey && event.key === "-") {
|
||||
event.preventDefault();
|
||||
configStore.decreaseFontSize();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+0 - Reset font size
|
||||
if (event.ctrlKey && event.key === "0") {
|
||||
event.preventDefault();
|
||||
configStore.resetFontSize();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+S - Toggle streamer mode
|
||||
if (event.ctrlKey && event.shiftKey && event.key === "S") {
|
||||
event.preventDefault();
|
||||
configStore.toggleStreamerMode();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+M - Toggle compact mode
|
||||
if (event.ctrlKey && event.shiftKey && event.key === "M") {
|
||||
event.preventDefault();
|
||||
toggleCompactMode();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInterrupt() {
|
||||
try {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) return;
|
||||
|
||||
await invoke("interrupt_claude", { conversationId });
|
||||
claudeStore.addLine("system", "Process interrupted");
|
||||
} catch (error) {
|
||||
console.error("Failed to interrupt:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function enterCompactMode() {
|
||||
try {
|
||||
const window = getCurrentWindow();
|
||||
const currentSize = await window.innerSize();
|
||||
const scaleFactor = await window.scaleFactor();
|
||||
|
||||
// Save current window size (convert from physical to logical pixels)
|
||||
// innerSize() returns physical pixels, but setSize() with LogicalSize expects logical
|
||||
previousWindowSize = {
|
||||
width: Math.round(currentSize.width / scaleFactor),
|
||||
height: Math.round(currentSize.height / scaleFactor),
|
||||
};
|
||||
|
||||
// Resize to compact dimensions
|
||||
await window.setSize(new LogicalSize(COMPACT_WIDTH, COMPACT_HEIGHT));
|
||||
|
||||
// Enable compact mode in config
|
||||
await configStore.setCompactMode(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to enter compact mode:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function exitCompactMode() {
|
||||
try {
|
||||
const window = getCurrentWindow();
|
||||
|
||||
// Only resize if we have a saved previous size
|
||||
// (i.e., user entered compact mode during this session)
|
||||
// Otherwise, just expand to a reasonable default
|
||||
if (previousWindowSize) {
|
||||
await window.setSize(new LogicalSize(previousWindowSize.width, previousWindowSize.height));
|
||||
previousWindowSize = null;
|
||||
} else {
|
||||
// No saved size (e.g., app started in compact mode) - use modest default
|
||||
await window.setSize(new LogicalSize(900, 650));
|
||||
}
|
||||
|
||||
// Disable compact mode in config
|
||||
await configStore.setCompactMode(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to exit compact mode:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleCompactMode() {
|
||||
if (compactModeActive) {
|
||||
await exitCompactMode();
|
||||
} else {
|
||||
await enterCompactMode();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!initialized) {
|
||||
@@ -27,12 +253,38 @@
|
||||
await initializeTauriListeners();
|
||||
await configStore.loadConfig();
|
||||
|
||||
// Apply saved theme on startup
|
||||
// Apply saved settings on startup
|
||||
const config = configStore.getConfig();
|
||||
applyTheme(config.theme);
|
||||
applyTheme(config.theme, config.custom_theme_colors);
|
||||
applyFontSize(config.font_size);
|
||||
|
||||
// Apply always-on-top setting
|
||||
if (config.always_on_top) {
|
||||
const window = getCurrentWindow();
|
||||
await window.setAlwaysOnTop(true);
|
||||
}
|
||||
|
||||
// Load saved panel width
|
||||
if (config.character_panel_width) {
|
||||
panelWidth = config.character_panel_width;
|
||||
}
|
||||
|
||||
// Initialize notification settings sync
|
||||
initNotificationSync();
|
||||
|
||||
// Add global keyboard shortcut listener
|
||||
window.addEventListener("keydown", handleGlobalKeydown);
|
||||
|
||||
// Check for updates on startup
|
||||
if (config.update_checks_enabled) {
|
||||
updateNotification?.checkForUpdates();
|
||||
}
|
||||
|
||||
// Apply compact mode if saved (resize window)
|
||||
if (config.compact_mode) {
|
||||
const window = getCurrentWindow();
|
||||
await window.setSize(new LogicalSize(COMPACT_WIDTH, COMPACT_HEIGHT));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -40,37 +292,62 @@
|
||||
if (initialized) {
|
||||
cleanupTauriListeners();
|
||||
cleanupNotificationSync();
|
||||
window.removeEventListener("keydown", handleGlobalKeydown);
|
||||
initialized = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden">
|
||||
<StatusBar onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)} />
|
||||
{#if compactModeActive}
|
||||
<!-- Compact mode: minimal widget interface -->
|
||||
<div
|
||||
class="app-container compact-app h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"
|
||||
>
|
||||
<CompactMode onExpand={exitCompactMode} />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Full mode: standard interface -->
|
||||
<div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden">
|
||||
<StatusBar
|
||||
onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)}
|
||||
onToggleCompact={enterCompactMode}
|
||||
/>
|
||||
|
||||
<main class="flex-1 flex overflow-hidden">
|
||||
<!-- Left panel: Character display -->
|
||||
<div
|
||||
class="character-panel w-1/3 flex flex-col items-center justify-center border-r border-[var(--border-color)] bg-[var(--bg-secondary)]/50"
|
||||
>
|
||||
<AnimeGirl />
|
||||
</div>
|
||||
<main class="flex-1 flex overflow-hidden">
|
||||
<!-- Left panel: Character display -->
|
||||
<div
|
||||
class="character-panel {getPanelGlowClass()} flex flex-col items-center justify-center bg-[var(--bg-secondary)]/50"
|
||||
style="width: {panelWidth}px; min-width: {MIN_PANEL_WIDTH}px; max-width: {MAX_PANEL_WIDTH}px;"
|
||||
>
|
||||
<AnimeGirl />
|
||||
</div>
|
||||
|
||||
<!-- Right panel: Terminal and input -->
|
||||
<div class="terminal-panel flex-1 flex flex-col">
|
||||
<Terminal />
|
||||
<InputBar />
|
||||
</div>
|
||||
</main>
|
||||
<!-- Resize handle -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="resize-handle w-1 cursor-col-resize bg-[var(--border-color)] hover:bg-[var(--accent-primary)] transition-colors flex-shrink-0"
|
||||
class:bg-[var(--accent-primary)]={isResizing}
|
||||
onmousedown={startResize}
|
||||
></div>
|
||||
|
||||
<PermissionModal />
|
||||
<ConfigSidebar />
|
||||
<AchievementNotification />
|
||||
<AchievementsPanel
|
||||
bind:isOpen={achievementPanelOpen}
|
||||
onClose={() => (achievementPanelOpen = false)}
|
||||
/>
|
||||
</div>
|
||||
<!-- Right panel: Terminal and input -->
|
||||
<div class="terminal-panel flex-1 flex flex-col min-w-0">
|
||||
<Terminal />
|
||||
<InputBar />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<PermissionModal />
|
||||
<UserQuestionModal />
|
||||
<ConfigSidebar />
|
||||
<AchievementNotification />
|
||||
<AchievementsPanel
|
||||
bind:isOpen={achievementPanelOpen}
|
||||
onClose={() => (achievementPanelOpen = false)}
|
||||
/>
|
||||
<UpdateNotification bind:this={updateNotification} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.app-container {
|
||||
@@ -84,7 +361,166 @@
|
||||
}
|
||||
|
||||
.character-panel {
|
||||
min-width: 320px;
|
||||
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%);
|
||||
transition: all 0.5s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.character-panel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 3px;
|
||||
background: transparent;
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Trans pride gradient glow effects for the character panel */
|
||||
.panel-glow-thinking {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--bg-secondary) 85%, #9333ea) 0%,
|
||||
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%,
|
||||
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 60px rgba(147, 51, 234, 0.15),
|
||||
inset 0 0 100px rgba(91, 206, 250, 0.1),
|
||||
0 0 40px rgba(91, 206, 250, 0.2),
|
||||
0 0 80px rgba(245, 169, 184, 0.15);
|
||||
}
|
||||
|
||||
.panel-glow-thinking::before {
|
||||
background: linear-gradient(180deg, #9333ea, var(--trans-blue), var(--trans-pink));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.panel-glow-typing {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--bg-secondary) 85%, #3b82f6) 0%,
|
||||
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%,
|
||||
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 60px rgba(59, 130, 246, 0.15),
|
||||
inset 0 0 100px rgba(91, 206, 250, 0.15),
|
||||
0 0 40px rgba(91, 206, 250, 0.25),
|
||||
0 0 80px rgba(245, 169, 184, 0.15);
|
||||
}
|
||||
|
||||
.panel-glow-typing::before {
|
||||
background: linear-gradient(180deg, #3b82f6, var(--trans-blue), var(--trans-pink));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.panel-glow-searching {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--bg-secondary) 85%, #eab308) 0%,
|
||||
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%,
|
||||
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 60px rgba(234, 179, 8, 0.15),
|
||||
inset 0 0 100px rgba(91, 206, 250, 0.1),
|
||||
0 0 40px rgba(91, 206, 250, 0.2),
|
||||
0 0 80px rgba(245, 169, 184, 0.15);
|
||||
}
|
||||
|
||||
.panel-glow-searching::before {
|
||||
background: linear-gradient(180deg, #eab308, var(--trans-blue), var(--trans-pink));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.panel-glow-coding {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--bg-secondary) 85%, #22c55e) 0%,
|
||||
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%,
|
||||
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 60px rgba(34, 197, 94, 0.15),
|
||||
inset 0 0 100px rgba(91, 206, 250, 0.1),
|
||||
0 0 40px rgba(91, 206, 250, 0.2),
|
||||
0 0 80px rgba(245, 169, 184, 0.15);
|
||||
}
|
||||
|
||||
.panel-glow-coding::before {
|
||||
background: linear-gradient(180deg, #22c55e, var(--trans-blue), var(--trans-pink));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.panel-glow-mcp {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--bg-secondary) 80%, var(--trans-blue)) 0%,
|
||||
color-mix(in srgb, var(--bg-primary) 85%, var(--trans-pink)) 50%,
|
||||
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 80px rgba(91, 206, 250, 0.2),
|
||||
inset 0 0 120px rgba(245, 169, 184, 0.15),
|
||||
0 0 60px rgba(91, 206, 250, 0.3),
|
||||
0 0 100px rgba(245, 169, 184, 0.2);
|
||||
}
|
||||
|
||||
.panel-glow-mcp::before {
|
||||
background: var(--trans-gradient-vibrant);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.panel-glow-success {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--bg-secondary) 85%, #10b981) 0%,
|
||||
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%,
|
||||
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 60px rgba(16, 185, 129, 0.15),
|
||||
inset 0 0 100px rgba(91, 206, 250, 0.1),
|
||||
0 0 40px rgba(91, 206, 250, 0.2),
|
||||
0 0 80px rgba(245, 169, 184, 0.15);
|
||||
}
|
||||
|
||||
.panel-glow-success::before {
|
||||
background: linear-gradient(180deg, #10b981, var(--trans-blue), var(--trans-pink));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.panel-glow-error {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--bg-secondary) 80%, #ef4444) 0%,
|
||||
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 50%,
|
||||
color-mix(in srgb, var(--bg-primary) 95%, var(--trans-blue)) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 60px rgba(239, 68, 68, 0.2),
|
||||
inset 0 0 100px rgba(245, 169, 184, 0.1),
|
||||
0 0 40px rgba(245, 169, 184, 0.2),
|
||||
0 0 80px rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.panel-glow-error::before {
|
||||
background: linear-gradient(180deg, #ef4444, var(--trans-pink), var(--trans-blue));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.resize-handle:hover,
|
||||
.resize-handle:active {
|
||||
width: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
Before Width: | Height: | Size: 690 KiB After Width: | Height: | Size: 688 KiB |
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 391 KiB |
|
Before Width: | Height: | Size: 463 KiB After Width: | Height: | Size: 442 KiB |
|
Before Width: | Height: | Size: 680 KiB After Width: | Height: | Size: 676 KiB |
|
Before Width: | Height: | Size: 442 KiB After Width: | Height: | Size: 432 KiB |
|
Before Width: | Height: | Size: 454 KiB After Width: | Height: | Size: 443 KiB |
|
Before Width: | Height: | Size: 424 KiB After Width: | Height: | Size: 416 KiB |
|
Before Width: | Height: | Size: 413 KiB After Width: | Height: | Size: 390 KiB |
|
Before Width: | Height: | Size: 777 KiB After Width: | Height: | Size: 775 KiB |
@@ -8,5 +8,23 @@ export default defineConfig({
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
globals: true,
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
reportsDirectory: "./coverage",
|
||||
include: ["src/lib/**/*.ts"],
|
||||
exclude: [
|
||||
"src/lib/**/*.test.ts",
|
||||
"src/lib/**/*.spec.ts",
|
||||
"src/lib/**/*.d.ts",
|
||||
"src/lib/components/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 15,
|
||||
branches: 13,
|
||||
functions: 13,
|
||||
lines: 15,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1 +1,288 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock Tauri invoke API
|
||||
const mockInvokeResults: Record<string, unknown> = {};
|
||||
|
||||
export function setMockInvokeResult(command: string, result: unknown) {
|
||||
mockInvokeResults[command] = result;
|
||||
}
|
||||
|
||||
export function clearMockInvokeResults() {
|
||||
Object.keys(mockInvokeResults).forEach((key) => delete mockInvokeResults[key]);
|
||||
}
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn((command: string) => {
|
||||
if (command in mockInvokeResults) {
|
||||
const result = mockInvokeResults[command];
|
||||
if (result instanceof Error) {
|
||||
return Promise.reject(result);
|
||||
}
|
||||
return Promise.resolve(result);
|
||||
}
|
||||
// Default return values for common commands
|
||||
switch (command) {
|
||||
case "get_config":
|
||||
return Promise.resolve({
|
||||
model: null,
|
||||
api_key: null,
|
||||
custom_instructions: null,
|
||||
mcp_servers_json: null,
|
||||
auto_granted_tools: [],
|
||||
theme: "dark",
|
||||
greeting_enabled: true,
|
||||
greeting_custom_prompt: null,
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: false,
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: null,
|
||||
font_size: 14,
|
||||
minimize_to_tray: false,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
profile_name: null,
|
||||
profile_avatar_path: null,
|
||||
profile_bio: null,
|
||||
custom_theme_colors: {},
|
||||
});
|
||||
case "list_quick_actions":
|
||||
return Promise.resolve([]);
|
||||
case "list_snippets":
|
||||
return Promise.resolve([]);
|
||||
case "list_sessions":
|
||||
return Promise.resolve([]);
|
||||
case "get_usage_stats":
|
||||
return Promise.resolve({
|
||||
total_messages: 0,
|
||||
total_sessions: 0,
|
||||
total_tokens: 0,
|
||||
total_cost: 0,
|
||||
});
|
||||
case "get_persisted_stats":
|
||||
return Promise.resolve({
|
||||
lifetime_messages: 0,
|
||||
lifetime_sessions: 0,
|
||||
lifetime_tokens: 0,
|
||||
lifetime_cost: 0,
|
||||
achievements: [],
|
||||
unlocked_achievements: [],
|
||||
});
|
||||
case "load_saved_achievements":
|
||||
return Promise.resolve([]);
|
||||
case "list_clipboard_entries":
|
||||
return Promise.resolve([]);
|
||||
case "cleanup_temp_files":
|
||||
return Promise.resolve();
|
||||
case "validate_directory":
|
||||
return Promise.resolve(true);
|
||||
case "git_status":
|
||||
return Promise.resolve({
|
||||
branch: "main",
|
||||
files: [],
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
});
|
||||
default:
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Tauri event API
|
||||
const eventListeners: Map<string, Array<(event: { payload: unknown }) => void>> = new Map();
|
||||
|
||||
export function emitMockEvent(eventName: string, payload: unknown) {
|
||||
const listeners = eventListeners.get(eventName) || [];
|
||||
listeners.forEach((listener) => listener({ payload }));
|
||||
}
|
||||
|
||||
export function clearMockEventListeners() {
|
||||
eventListeners.clear();
|
||||
}
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn((eventName: string, handler: (event: { payload: unknown }) => void) => {
|
||||
const listeners = eventListeners.get(eventName) || [];
|
||||
listeners.push(handler);
|
||||
eventListeners.set(eventName, listeners);
|
||||
// Return an unlisten function
|
||||
return Promise.resolve(() => {
|
||||
const currentListeners = eventListeners.get(eventName) || [];
|
||||
const index = currentListeners.indexOf(handler);
|
||||
if (index > -1) {
|
||||
currentListeners.splice(index, 1);
|
||||
}
|
||||
});
|
||||
}),
|
||||
emit: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock Tauri plugins
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
save: vi.fn(() => Promise.resolve(null)),
|
||||
open: vi.fn(() => Promise.resolve(null)),
|
||||
message: vi.fn(() => Promise.resolve()),
|
||||
ask: vi.fn(() => Promise.resolve(true)),
|
||||
confirm: vi.fn(() => Promise.resolve(true)),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-fs", () => ({
|
||||
writeTextFile: vi.fn(() => Promise.resolve()),
|
||||
readTextFile: vi.fn(() => Promise.resolve("{}")),
|
||||
exists: vi.fn(() => Promise.resolve(false)),
|
||||
mkdir: vi.fn(() => Promise.resolve()),
|
||||
remove: vi.fn(() => Promise.resolve()),
|
||||
readDir: vi.fn(() => Promise.resolve([])),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-opener", () => ({
|
||||
openPath: vi.fn(() => Promise.resolve()),
|
||||
openUrl: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-notification", () => ({
|
||||
sendNotification: vi.fn(() => Promise.resolve()),
|
||||
requestPermission: vi.fn(() => Promise.resolve("granted")),
|
||||
isPermissionGranted: vi.fn(() => Promise.resolve(true)),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-clipboard-manager", () => ({
|
||||
writeText: vi.fn(() => Promise.resolve()),
|
||||
readText: vi.fn(() => Promise.resolve("")),
|
||||
writeImage: vi.fn(() => Promise.resolve()),
|
||||
readImage: vi.fn(() => Promise.resolve(null)),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-os", () => ({
|
||||
platform: vi.fn(() => Promise.resolve("linux")),
|
||||
arch: vi.fn(() => Promise.resolve("x86_64")),
|
||||
type: vi.fn(() => Promise.resolve("Linux")),
|
||||
version: vi.fn(() => Promise.resolve("1.0.0")),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-http", () => ({
|
||||
fetch: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({}),
|
||||
text: () => Promise.resolve(""),
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock browser APIs
|
||||
class MockAudioElement {
|
||||
src = "";
|
||||
volume = 1;
|
||||
loop = false;
|
||||
currentTime = 0;
|
||||
paused = true;
|
||||
preload = "auto";
|
||||
onloadeddata: (() => void) | null = null;
|
||||
onended: (() => void) | null = null;
|
||||
onerror: ((e: Event) => void) | null = null;
|
||||
|
||||
play() {
|
||||
this.paused = false;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.paused = true;
|
||||
}
|
||||
|
||||
load() {
|
||||
if (this.onloadeddata) {
|
||||
setTimeout(() => this.onloadeddata?.(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener(event: string, handler: () => void) {
|
||||
if (event === "loadeddata") this.onloadeddata = handler;
|
||||
if (event === "ended") this.onended = handler;
|
||||
}
|
||||
|
||||
removeEventListener() {
|
||||
// No-op for tests
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error - Mock Audio constructor
|
||||
globalThis.Audio = MockAudioElement;
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageStore: Record<string, string> = {};
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: {
|
||||
getItem: vi.fn((key: string) => localStorageStore[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
localStorageStore[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete localStorageStore[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
Object.keys(localStorageStore).forEach((key) => delete localStorageStore[key]);
|
||||
}),
|
||||
key: vi.fn((index: number) => Object.keys(localStorageStore)[index] || null),
|
||||
get length() {
|
||||
return Object.keys(localStorageStore).length;
|
||||
},
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock matchMedia
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn((query: string) => ({
|
||||
matches: query.includes("dark"),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock ResizeObserver
|
||||
class MockResizeObserver {
|
||||
observe = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
||||
|
||||
// Mock IntersectionObserver
|
||||
class MockIntersectionObserver {
|
||||
observe = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
}
|
||||
|
||||
globalThis.IntersectionObserver =
|
||||
MockIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||
|
||||
// Mock requestAnimationFrame
|
||||
globalThis.requestAnimationFrame = vi.fn((callback) => {
|
||||
return setTimeout(callback, 0) as unknown as number;
|
||||
});
|
||||
|
||||
globalThis.cancelAnimationFrame = vi.fn((id) => {
|
||||
clearTimeout(id);
|
||||
});
|
||||
|
||||
// Reset all mocks before each test
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearMockInvokeResults();
|
||||
clearMockEventListeners();
|
||||
});
|
||||
|
||||