Compare commits

..

14 Commits

Author SHA1 Message Date
naomi bbeff7ae2e release: v0.3.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 49s
CI / Lint & Test (push) Successful in 14m32s
CI / Build Linux (push) Successful in 15m53s
CI / Build Windows (cross-compile) (push) Successful in 25m32s
2026-01-23 19:08:50 -08:00
naomi 3f30997f0e feat: another wave of features (#61)
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
## Explanation

This PR bundles several user-facing improvements and feature additions for the v0.3.0 release, including quality-of-life improvements to the UI, new slash commands, better state persistence, and auto-update checking.

## Included Changes

- **Resizable chat input** with drag handle (#58 partial)
- **Arrow key navigation fix** - cursor keys now navigate text when user has typed input (#58)
- **Scroll position persistence** per conversation tab
- **/skill command** for invoking Claude Code skills (#57)
- **Stats persistence fix** - stats now persist across session changes, only reset on disconnect (#59)
- **Auto-update checker** on startup (#17)
- **Resizable character panel** with full-height sprites (#10)
- **Font size and zoom settings** with keyboard shortcuts (Ctrl++/Ctrl+-/Ctrl+0) (#19)

## Closes

Closes #10, #17, #19, #57, #58, #59

## Attestations

- [x] I have read and agree to the Code of Conduct
- [x] I have read and agree to the Community Guidelines
- [x] My contribution complies with the Contributor Covenant
- [x] I have run the linter and resolved any errors
- [x] My pull request uses an appropriate title, matching the conventional commit standards
- [x] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request
- [x] All new and existing tests pass locally with my changes
- [x] Code coverage remains at or above the configured threshold

## Documentation

N/A - Internal app features

## Versioning

Minor - My pull request introduces new non-breaking features.

---
 This PR was created with help from Hikari~ 🌸

Co-authored-by: Hikari <hikari@nhcarrigan.com>
Reviewed-on: #61
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-23 19:07:22 -08:00
naomi 06810537a9 feat: add AskUserQuestion tool support (#60)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 53s
CI / Lint & Test (push) Successful in 14m12s
CI / Build Linux (push) Successful in 16m41s
CI / Build Windows (cross-compile) (push) Successful in 27m0s
## Summary

Implements support for Claude's `AskUserQuestion` tool, allowing Claude to ask the user questions with multiple choice options during a conversation.

## Changes

- Add `UserQuestionEvent` and `QuestionOption` types (Rust and TypeScript)
- Detect `AskUserQuestion` in permission denials and emit `claude:question` event
- Create `UserQuestionModal` component with option selection and custom answer input
- Use stop/reconnect approach (same as `PermissionModal`) since Claude API doesn't accept tool_result for permission-denied tools
- Add `pendingQuestion` to conversation store and `hasQuestionPending` derived store

## Technical Notes

We discovered that Claude Code's permission denial system doesn't allow sending tool results back directly - the API rejects them with "unexpected tool_use_id found in tool_result blocks". The solution was to use the same stop/reconnect pattern that permissions use: stop the session, reconnect with context, and include the user's answer in the context restoration message.

## Test Plan

- [x] Build compiles without errors (Rust + TypeScript)
- [x] Question modal appears when Claude uses `AskUserQuestion`
- [x] Can select options and submit answer
- [x] Answer is properly restored to Claude after reconnect

Closes #51

---

 This PR was created with help from Hikari~ 🌸

Co-authored-by: Hikari <hikari@nhcarrigan.com>
Reviewed-on: #60
2026-01-23 14:11:18 -08:00
hikari 94991796be feat: batch of fixes and features (#56)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 57s
CI / Lint & Test (push) Successful in 14m14s
CI / Build Linux (push) Successful in 16m45s
CI / Build Windows (cross-compile) (push) Successful in 26m50s
## Summary

This PR includes a batch of bug fixes and new features:

### Bug Fixes
- **Links in chat history now open in default browser** instead of navigating within the app
  - Closes #54
- **Allow spaces in tab names** - space key no longer acts like enter when renaming tabs
  - Closes #52

### New Features
- **`/cd` command** - Change the working directory of an active tab with context preservation
  - Closes #55
- **`/search` command** - Search and highlight matches within the conversation
  - Closes #32

## Test Plan
- [ ] Click a link in chat history and verify it opens in the default browser
- [ ] Rename a tab and verify spaces can be typed
- [ ] Use `/cd <path>` and verify the directory changes while preserving conversation context
- [ ] Use `/search <query>` and verify matches are highlighted in yellow
- [ ] Use `/search` with no args to clear the search highlighting

 This PR was created with help from Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #56
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-01-23 11:59:21 -08:00
naomi 947e56ef41 feat: naomi did too much at once (#53)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 53s
CI / Lint & Test (push) Successful in 14m10s
CI / Build Linux (push) Successful in 16m47s
CI / Build Windows (cross-compile) (push) Successful in 26m36s
- feat: add slash commands
- feat: toggle window always on top
- fix: save settings button closes settings panel
- feat: input history (both text and commands)
- feat: add keyboard shortcuts
- feat: add confirmation modal when closing connected tabs
- fix: better text colours in light mode
- fix: handle multiple tabs requesting permission

Closes #6
Closes #13
Closes #21
Closes #28

Reviewed-on: #53
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-21 17:38:36 -08:00
naomi 9fe4e8a48a feat: add markdown renderer and code block highlighting (#50)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 56s
CI / Lint & Test (push) Successful in 14m14s
CI / Build Linux (push) Successful in 16m45s
CI / Build Windows (cross-compile) (push) Successful in 26m51s
### Explanation

_No response_

### Issue

Closes #33 Closes #31

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #50
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-21 11:28:09 -08:00
naomi bc596867d4 release: v0.2.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 51s
CI / Lint & Test (push) Successful in 14m13s
CI / Build Linux (push) Successful in 16m38s
CI / Build Windows (cross-compile) (push) Successful in 26m43s
2026-01-20 20:37:36 -08:00
naomi e877f4aaf2 chore: clean up the sprites (#49)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m1s
CI / Lint & Test (push) Successful in 14m51s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
### Explanation

_No response_

### Issue

Closes #9

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #49
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-20 20:19:34 -08:00
naomi 377f81d978 feat: add about and help panels, donate button, and live setting update (#48)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 52s
CI / Lint & Test (push) Successful in 14m11s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
### Explanation

_No response_

### Issue

Closes #26 Closes #27

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #48
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-20 20:04:03 -08:00
naomi d83697e5cf feat: add ability to run multiple agents via tabbed views (#47)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 54s
CI / Lint & Test (push) Successful in 14m18s
CI / Build Linux (push) Successful in 16m46s
CI / Build Windows (cross-compile) (push) Successful in 26m39s
### Explanation

_No response_

### Issue

Closes #30 Closes #41

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #47
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-20 13:57:48 -08:00
naomi 2d3adcab1c feat: add chat modes and interrupt feature (#46)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 52s
CI / Lint & Test (push) Successful in 14m15s
CI / Build Linux (push) Successful in 16m37s
CI / Build Windows (cross-compile) (push) Successful in 26m35s
### Explanation

_No response_

### Issue

Closes #40

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #46
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-20 08:33:39 -08:00
naomi 70fcaa8650 feat: stats and achievements (#45)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 53s
CI / Lint & Test (push) Successful in 14m11s
CI / Build Linux (push) Successful in 16m47s
CI / Build Windows (cross-compile) (push) Successful in 26m56s
### Explanation

_No response_

### Issue

Closes #39

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #45
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-19 20:51:53 -08:00
naomi a8f98406e1 feat: add notification sounds (#44)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 54s
CI / Lint & Test (push) Successful in 14m2s
CI / Build Linux (push) Successful in 16m38s
CI / Build Windows (cross-compile) (push) Successful in 26m27s
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #44
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-19 16:18:25 -08:00
naomi 0065bb4afc fix: reconnect bug, don't greet on reconnects (#43)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 48s
CI / Lint & Test (push) Successful in 14m8s
CI / Build Linux (push) Successful in 16m25s
CI / Build Windows (cross-compile) (push) Successful in 26m35s
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #43
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-19 13:46:51 -08:00
84 changed files with 10179 additions and 417 deletions
+1
View File
@@ -2,6 +2,7 @@ build/
.svelte-kit/ .svelte-kit/
dist/ dist/
src-tauri/target/ src-tauri/target/
src-tauri/gen/
node_modules/ node_modules/
.pnpm-store/ .pnpm-store/
pnpm-lock.yaml pnpm-lock.yaml
Executable
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to run a command and check its status
run_check() {
local desc=$1
local cmd=$2
echo -e "\n${YELLOW}Running: ${desc}${NC}"
echo -e "${YELLOW}Command: ${cmd}${NC}"
if eval "$cmd"; then
echo -e "${GREEN}${desc} passed${NC}"
return 0
else
echo -e "${RED}${desc} failed${NC}"
return 1
fi
}
# Track if any checks fail
failed=0
echo -e "${YELLOW}🔍 Running all checks for Hikari Desktop...${NC}"
# Frontend checks
run_check "Frontend lint" "pnpm lint" || failed=1
run_check "Frontend format check" "pnpm format:check" || failed=1
run_check "Frontend type check" "pnpm check" || failed=1
run_check "Frontend tests" "pnpm test" || failed=1
# Backend checks
run_check "Backend clippy (strict)" "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings" || failed=1
run_check "Backend tests" "cargo test" || failed=1
# Summary
echo -e "\n${YELLOW}========================================${NC}"
if [ $failed -eq 0 ]; then
echo -e "${GREEN}✨ All checks passed! The code is looking great!${NC}"
echo -e "${GREEN} Naomi would be so proud of us! 💖${NC}"
exit 0
else
echo -e "${RED}❌ Some checks failed. Let's fix them together!${NC}"
echo -e "${RED} Don't worry, we'll get through this! 💪${NC}"
exit 1
fi
+6 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "hikari-desktop", "name": "hikari-desktop",
"version": "0.1.0", "version": "0.3.0",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -25,9 +25,13 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2",
"@tauri-apps/plugin-shell": "^2.3.4", "@tauri-apps/plugin-shell": "^2.3.4",
"@tauri-apps/plugin-store": "^2" "@tauri-apps/plugin-store": "^2",
"highlight.js": "^11.11.1",
"marked": "^17.0.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
+39
View File
@@ -14,15 +14,27 @@ importers:
'@tauri-apps/plugin-dialog': '@tauri-apps/plugin-dialog':
specifier: ^2 specifier: ^2
version: 2.6.0 version: 2.6.0
'@tauri-apps/plugin-notification':
specifier: ^2
version: 2.3.3
'@tauri-apps/plugin-opener': '@tauri-apps/plugin-opener':
specifier: ^2 specifier: ^2
version: 2.5.3 version: 2.5.3
'@tauri-apps/plugin-os':
specifier: ^2
version: 2.3.2
'@tauri-apps/plugin-shell': '@tauri-apps/plugin-shell':
specifier: ^2.3.4 specifier: ^2.3.4
version: 2.3.4 version: 2.3.4
'@tauri-apps/plugin-store': '@tauri-apps/plugin-store':
specifier: ^2 specifier: ^2
version: 2.4.2 version: 2.4.2
highlight.js:
specifier: ^11.11.1
version: 11.11.1
marked:
specifier: ^17.0.1
version: 17.0.1
devDependencies: devDependencies:
'@eslint/js': '@eslint/js':
specifier: ^9.39.2 specifier: ^9.39.2
@@ -726,9 +738,15 @@ packages:
'@tauri-apps/plugin-dialog@2.6.0': '@tauri-apps/plugin-dialog@2.6.0':
resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==} resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
'@tauri-apps/plugin-notification@2.3.3':
resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==}
'@tauri-apps/plugin-opener@2.5.3': '@tauri-apps/plugin-opener@2.5.3':
resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==}
'@tauri-apps/plugin-os@2.3.2':
resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==}
'@tauri-apps/plugin-shell@2.3.4': '@tauri-apps/plugin-shell@2.3.4':
resolution: {integrity: sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA==} resolution: {integrity: sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA==}
@@ -1173,6 +1191,10 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
html-encoding-sniffer@6.0.0: html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -1360,6 +1382,11 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@17.0.1:
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
engines: {node: '>= 20'}
hasBin: true
mdn-data@2.12.2: mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
@@ -2263,10 +2290,18 @@ snapshots:
dependencies: dependencies:
'@tauri-apps/api': 2.9.1 '@tauri-apps/api': 2.9.1
'@tauri-apps/plugin-notification@2.3.3':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/plugin-opener@2.5.3': '@tauri-apps/plugin-opener@2.5.3':
dependencies: dependencies:
'@tauri-apps/api': 2.9.1 '@tauri-apps/api': 2.9.1
'@tauri-apps/plugin-os@2.3.2':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/plugin-shell@2.3.4': '@tauri-apps/plugin-shell@2.3.4':
dependencies: dependencies:
'@tauri-apps/api': 2.9.1 '@tauri-apps/api': 2.9.1
@@ -2760,6 +2795,8 @@ snapshots:
has-flag@4.0.0: {} has-flag@4.0.0: {}
highlight.js@11.11.1: {}
html-encoding-sniffer@6.0.0: html-encoding-sniffer@6.0.0:
dependencies: dependencies:
'@exodus/bytes': 1.8.0 '@exodus/bytes': 1.8.0
@@ -2929,6 +2966,8 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
marked@17.0.1: {}
mdn-data@2.12.2: {} mdn-data@2.12.2: {}
min-indent@1.0.1: {} min-indent@1.0.1: {}
+652 -17
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "hikari-desktop" name = "hikari-desktop"
version = "0.1.0" version = "0.3.0"
description = "Hikari - Claude Code Visual Assistant" description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"] authors = ["Naomi Carrigan"]
edition = "2021" edition = "2021"
@@ -23,5 +23,18 @@ tokio = { version = "1", features = ["full"] }
parking_lot = "0.12" parking_lot = "0.12"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
tauri-plugin-store = "2.4.2" tauri-plugin-store = "2.4.2"
tauri-plugin-notification = "2"
tauri-plugin-os = "2"
tauri-plugin-http = "2"
tempfile = "3" tempfile = "3"
semver = "1"
chrono = { version = "0.4.43", features = ["serde"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = [
"Data_Xml_Dom",
"UI_Notifications",
"Win32_System_Com",
"Win32_Foundation",
] }
+5 -1
View File
@@ -9,6 +9,10 @@
"opener:default", "opener:default",
"shell:allow-spawn", "shell:allow-spawn",
"shell:allow-stdin-write", "shell:allow-stdin-write",
"shell:allow-kill" "shell:allow-kill",
"notification:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify"
] ]
} }
+833
View File
@@ -0,0 +1,833 @@
use chrono::{DateTime, Datelike, Timelike, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use tauri_plugin_store::StoreExt;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum AchievementId {
// Token Milestones
FirstSteps, // 1,000 tokens
GrowingStrong, // 10,000 tokens
BlossomingCoder, // 100,000 tokens
TokenMaster, // 1,000,000 tokens
// Code Generation
HelloWorld, // First code block
CodeWizard, // 100 code blocks
ThousandBlocks, // 1,000 code blocks
// File Operations
FileManipulator, // 10 files edited
FileArchitect, // 100 files edited
// Conversation milestones
ConversationStarter, // 10 messages
ChattyKathy, // 100 messages
Conversationalist, // 1,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
// Special achievements
FirstMessage, // First message sent
FirstTool, // First tool used
FirstCodeBlock, // First code generated
FirstFileEdit, // First file edit
Polyglot, // 5+ languages in one session
SpeedCoder, // 10 code blocks in 10 minutes
ClaudeConnoisseur, // Used all Claude models
MarathonCoder, // 10k tokens in one session
// Relationship & Greetings
GoodMorning, // Say "good morning"
GoodNight, // Say "good night" or "goodnight"
ThankYou, // Say "thank you" or "thanks"
LoveYou, // Say "love you" or "ily"
// Personality & Fun
EmojiUser, // Use an emoji in a message
QuestionMaster, // Use "?" in 20 messages
CapsLock, // Send a message in ALL CAPS
PleaseAndThankYou, // Use "please" in messages
// Git & Development
GitGuru, // Use git commands 10 times
TestWriter, // Create test files
Debugger, // Fix bugs (messages with "fix", "bug", "error")
// Tool Mastery
BashMaster, // Use Bash tool 50 times
FileExplorer, // Use Read tool 100 times
SearchExpert, // Use Grep tool 50 times
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Achievement {
pub id: AchievementId,
pub name: String,
pub description: String,
pub icon: String,
pub unlocked_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AchievementProgress {
pub unlocked: HashSet<AchievementId>,
pub newly_unlocked: Vec<AchievementId>, // Achievements unlocked but not yet notified
#[serde(skip)]
pub session_start: Option<DateTime<Utc>>,
}
impl AchievementProgress {
pub fn new() -> Self {
Self {
unlocked: HashSet::new(),
newly_unlocked: Vec::new(),
session_start: None,
}
}
pub fn unlock(&mut self, achievement: AchievementId) -> bool {
if self.unlocked.insert(achievement.clone()) {
self.newly_unlocked.push(achievement);
true
} else {
false
}
}
#[cfg(test)]
pub fn take_newly_unlocked(&mut self) -> Vec<AchievementId> {
std::mem::take(&mut self.newly_unlocked)
}
#[cfg(test)]
pub fn is_unlocked(&self, achievement: &AchievementId) -> bool {
self.unlocked.contains(achievement)
}
pub fn start_session(&mut self) {
self.session_start = Some(Utc::now());
}
}
impl Default for AchievementProgress {
fn default() -> Self {
Self::new()
}
}
pub fn get_achievement_info(id: &AchievementId) -> Achievement {
match id {
// Token Milestones
AchievementId::FirstSteps => Achievement {
id: id.clone(),
name: "First Steps!".to_string(),
description: "Used 1,000 tokens".to_string(),
icon: "🌱".to_string(),
unlocked_at: None,
},
AchievementId::GrowingStrong => Achievement {
id: id.clone(),
name: "Growing Strong!".to_string(),
description: "Used 10,000 tokens".to_string(),
icon: "🌸".to_string(),
unlocked_at: None,
},
AchievementId::BlossomingCoder => Achievement {
id: id.clone(),
name: "Blossoming Coder!".to_string(),
description: "Used 100,000 tokens".to_string(),
icon: "🌺".to_string(),
unlocked_at: None,
},
AchievementId::TokenMaster => Achievement {
id: id.clone(),
name: "Token Master!".to_string(),
description: "Used 1,000,000 tokens".to_string(),
icon: "🌟".to_string(),
unlocked_at: None,
},
// Code Generation
AchievementId::HelloWorld => Achievement {
id: id.clone(),
name: "Hello World!".to_string(),
description: "Generated your first code block".to_string(),
icon: "📝".to_string(),
unlocked_at: None,
},
AchievementId::CodeWizard => Achievement {
id: id.clone(),
name: "Code Wizard!".to_string(),
description: "Generated 100 code blocks".to_string(),
icon: "🎯".to_string(),
unlocked_at: None,
},
AchievementId::ThousandBlocks => Achievement {
id: id.clone(),
name: "Thousand Blocks".to_string(),
description: "1,000 code blocks! You're a code machine!".to_string(),
icon: "🏗️".to_string(),
unlocked_at: None,
},
// File Operations
AchievementId::FileManipulator => Achievement {
id: id.clone(),
name: "File Manipulator".to_string(),
description: "Edited 10 files".to_string(),
icon: "📝".to_string(),
unlocked_at: None,
},
AchievementId::FileArchitect => Achievement {
id: id.clone(),
name: "File Architect".to_string(),
description: "Created or edited 100 files".to_string(),
icon: "🏛️".to_string(),
unlocked_at: None,
},
// Conversation milestones
AchievementId::ConversationStarter => Achievement {
id: id.clone(),
name: "Conversation Starter".to_string(),
description: "Exchanged 10 messages".to_string(),
icon: "💬".to_string(),
unlocked_at: None,
},
AchievementId::ChattyKathy => Achievement {
id: id.clone(),
name: "Chatty Kathy".to_string(),
description: "100 messages exchanged".to_string(),
icon: "🗣️".to_string(),
unlocked_at: None,
},
AchievementId::Conversationalist => Achievement {
id: id.clone(),
name: "Master Conversationalist".to_string(),
description: "1,000 messages! We're really connecting!".to_string(),
icon: "💖".to_string(),
unlocked_at: None,
},
// Tool usage
AchievementId::Toolsmith => Achievement {
id: id.clone(),
name: "Toolsmith".to_string(),
description: "Used 5 different tools".to_string(),
icon: "🔨".to_string(),
unlocked_at: None,
},
AchievementId::ToolMaster => Achievement {
id: id.clone(),
name: "Tool Master".to_string(),
description: "Used 10 different tools efficiently".to_string(),
icon: "🛠️".to_string(),
unlocked_at: None,
},
// Time-based achievements
AchievementId::EarlyBird => Achievement {
id: id.clone(),
name: "Early Bird".to_string(),
description: "Started a session between 5 AM and 7 AM".to_string(),
icon: "🌅".to_string(),
unlocked_at: None,
},
AchievementId::NightOwl => Achievement {
id: id.clone(),
name: "Night Owl".to_string(),
description: "Coding after midnight".to_string(),
icon: "🦉".to_string(),
unlocked_at: None,
},
AchievementId::AllNighter => Achievement {
id: id.clone(),
name: "All Nighter".to_string(),
description: "Worked through the night (2 AM - 5 AM)".to_string(),
icon: "🌙".to_string(),
unlocked_at: None,
},
AchievementId::WeekendWarrior => Achievement {
id: id.clone(),
name: "Weekend Warrior".to_string(),
description: "Coding on a weekend".to_string(),
icon: "⚔️".to_string(),
unlocked_at: None,
},
AchievementId::DedicatedDeveloper => Achievement {
id: id.clone(),
name: "Dedicated Developer".to_string(),
description: "Coded for 30 days in a row".to_string(),
icon: "🏆".to_string(),
unlocked_at: None,
},
// Search and exploration
AchievementId::Explorer => Achievement {
id: id.clone(),
name: "Explorer".to_string(),
description: "Used search tools 50 times".to_string(),
icon: "🔍".to_string(),
unlocked_at: None,
},
AchievementId::MasterSearcher => Achievement {
id: id.clone(),
name: "Master Searcher".to_string(),
description: "Searched 500 times across files".to_string(),
icon: "🕵️‍♀️".to_string(),
unlocked_at: None,
},
// Session achievements
AchievementId::QuickSession => Achievement {
id: id.clone(),
name: "Quick Session".to_string(),
description: "Completed a productive session in under 5 minutes".to_string(),
icon: "".to_string(),
unlocked_at: None,
},
AchievementId::FocusedWork => Achievement {
id: id.clone(),
name: "Focused Work".to_string(),
description: "Worked for 30 minutes straight".to_string(),
icon: "🎯".to_string(),
unlocked_at: None,
},
AchievementId::DeepDive => Achievement {
id: id.clone(),
name: "Deep Dive".to_string(),
description: "Worked for 2 hours continuously".to_string(),
icon: "🏊‍♀️".to_string(),
unlocked_at: None,
},
AchievementId::MarathonSession => Achievement {
id: id.clone(),
name: "Marathon Session".to_string(),
description: "5+ hour coding session!".to_string(),
icon: "🏃‍♀️".to_string(),
unlocked_at: None,
},
// Special achievements
AchievementId::FirstMessage => Achievement {
id: id.clone(),
name: "First Message".to_string(),
description: "Sent your first message to Hikari".to_string(),
icon: "".to_string(),
unlocked_at: None,
},
AchievementId::FirstTool => Achievement {
id: id.clone(),
name: "First Tool".to_string(),
description: "Used your first tool".to_string(),
icon: "🔧".to_string(),
unlocked_at: None,
},
AchievementId::FirstCodeBlock => Achievement {
id: id.clone(),
name: "First Code".to_string(),
description: "Generated your first code block".to_string(),
icon: "📦".to_string(),
unlocked_at: None,
},
AchievementId::FirstFileEdit => Achievement {
id: id.clone(),
name: "First Edit".to_string(),
description: "Made your first file edit".to_string(),
icon: "✏️".to_string(),
unlocked_at: None,
},
AchievementId::Polyglot => Achievement {
id: id.clone(),
name: "Polyglot".to_string(),
description: "Generated code in 5+ languages in one session".to_string(),
icon: "🌍".to_string(),
unlocked_at: None,
},
AchievementId::SpeedCoder => Achievement {
id: id.clone(),
name: "Speed Coder".to_string(),
description: "Generated 10 code blocks in 10 minutes".to_string(),
icon: "🚀".to_string(),
unlocked_at: None,
},
AchievementId::ClaudeConnoisseur => Achievement {
id: id.clone(),
name: "Claude Connoisseur".to_string(),
description: "Used all available Claude models".to_string(),
icon: "🎨".to_string(),
unlocked_at: None,
},
AchievementId::MarathonCoder => Achievement {
id: id.clone(),
name: "Marathon Coder".to_string(),
description: "10,000 tokens in a single session".to_string(),
icon: "🏃‍♂️".to_string(),
unlocked_at: None,
},
// Relationship & Greetings
AchievementId::GoodMorning => Achievement {
id: id.clone(),
name: "Good Morning!".to_string(),
description: "Greeted Hikari with a good morning".to_string(),
icon: "🌅".to_string(),
unlocked_at: None,
},
AchievementId::GoodNight => Achievement {
id: id.clone(),
name: "Good Night".to_string(),
description: "Said good night to Hikari".to_string(),
icon: "🌙".to_string(),
unlocked_at: None,
},
AchievementId::ThankYou => Achievement {
id: id.clone(),
name: "Grateful Heart".to_string(),
description: "Thanked Hikari for her help".to_string(),
icon: "💝".to_string(),
unlocked_at: None,
},
AchievementId::LoveYou => Achievement {
id: id.clone(),
name: "Love Connection".to_string(),
description: "Expressed love to Hikari".to_string(),
icon: "💕".to_string(),
unlocked_at: None,
},
// Personality & Fun
AchievementId::EmojiUser => Achievement {
id: id.clone(),
name: "Emoji Enthusiast".to_string(),
description: "Used an emoji in your message".to_string(),
icon: "😊".to_string(),
unlocked_at: None,
},
AchievementId::QuestionMaster => Achievement {
id: id.clone(),
name: "Question Master".to_string(),
description: "Asked 20 questions".to_string(),
icon: "".to_string(),
unlocked_at: None,
},
AchievementId::CapsLock => Achievement {
id: id.clone(),
name: "CAPS LOCK ENGAGED".to_string(),
description: "SENT A MESSAGE IN ALL CAPS".to_string(),
icon: "📢".to_string(),
unlocked_at: None,
},
AchievementId::PleaseAndThankYou => Achievement {
id: id.clone(),
name: "Polite Programmer".to_string(),
description: "Said please in a request".to_string(),
icon: "🎩".to_string(),
unlocked_at: None,
},
// Git & Development
AchievementId::GitGuru => Achievement {
id: id.clone(),
name: "Git Guru".to_string(),
description: "Used git commands 10 times".to_string(),
icon: "🌿".to_string(),
unlocked_at: None,
},
AchievementId::TestWriter => Achievement {
id: id.clone(),
name: "Test Writer".to_string(),
description: "Created test files".to_string(),
icon: "🧪".to_string(),
unlocked_at: None,
},
AchievementId::Debugger => Achievement {
id: id.clone(),
name: "Bug Squasher".to_string(),
description: "Fixed bugs and errors".to_string(),
icon: "🐛".to_string(),
unlocked_at: None,
},
// Tool Mastery
AchievementId::BashMaster => Achievement {
id: id.clone(),
name: "Bash Master".to_string(),
description: "Used Bash tool 50 times".to_string(),
icon: "🐚".to_string(),
unlocked_at: None,
},
AchievementId::FileExplorer => Achievement {
id: id.clone(),
name: "File Explorer".to_string(),
description: "Read 100 files".to_string(),
icon: "📂".to_string(),
unlocked_at: None,
},
AchievementId::SearchExpert => Achievement {
id: id.clone(),
name: "Search Expert".to_string(),
description: "Used Grep tool 50 times".to_string(),
icon: "🔎".to_string(),
unlocked_at: None,
},
}
}
// Check achievements based on message content
pub fn check_message_achievements(
message: &str,
progress: &mut AchievementProgress,
) -> Vec<AchievementId> {
let mut newly_unlocked = Vec::new();
let message_lower = message.to_lowercase();
println!("Checking message achievements for: {}", message);
// Relationship & Greetings
if message_lower.contains("good morning") && progress.unlock(AchievementId::GoodMorning) {
newly_unlocked.push(AchievementId::GoodMorning);
}
if (message_lower.contains("good night") || message_lower.contains("goodnight"))
&& progress.unlock(AchievementId::GoodNight)
{
newly_unlocked.push(AchievementId::GoodNight);
}
if (message_lower.contains("thank you")
|| message_lower.contains("thanks")
|| message_lower.contains("thx"))
&& progress.unlock(AchievementId::ThankYou)
{
newly_unlocked.push(AchievementId::ThankYou);
}
if (message_lower.contains("love you") || message_lower.contains("ily"))
&& progress.unlock(AchievementId::LoveYou)
{
newly_unlocked.push(AchievementId::LoveYou);
}
// Personality & Fun
if message.chars().any(|c| c as u32 >= 0x1F300) && progress.unlock(AchievementId::EmojiUser) {
newly_unlocked.push(AchievementId::EmojiUser);
}
if message == message.to_uppercase()
&& message.len() > 5
&& message.chars().any(|c| c.is_alphabetic())
&& progress.unlock(AchievementId::CapsLock)
{
newly_unlocked.push(AchievementId::CapsLock);
}
if message_lower.contains("please") && progress.unlock(AchievementId::PleaseAndThankYou) {
newly_unlocked.push(AchievementId::PleaseAndThankYou);
}
// Git & Development patterns in messages
if (message_lower.contains("fix")
|| message_lower.contains("bug")
|| message_lower.contains("error"))
&& progress.unlock(AchievementId::Debugger)
{
newly_unlocked.push(AchievementId::Debugger);
}
newly_unlocked
}
// Check which achievements should be unlocked based on current stats
pub fn check_achievements(
stats: &crate::stats::UsageStats,
progress: &mut AchievementProgress,
) -> Vec<AchievementId> {
let mut newly_unlocked = Vec::new();
println!(
"Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
stats.messages_exchanged,
stats.total_input_tokens + stats.total_output_tokens,
stats.code_blocks_generated
);
println!("Currently unlocked: {:?}", progress.unlocked);
// Token milestones
let total_tokens = stats.total_input_tokens + stats.total_output_tokens;
if total_tokens >= 1_000 && progress.unlock(AchievementId::FirstSteps) {
println!("Unlocked FirstSteps achievement!");
newly_unlocked.push(AchievementId::FirstSteps);
}
if total_tokens >= 10_000 && progress.unlock(AchievementId::GrowingStrong) {
newly_unlocked.push(AchievementId::GrowingStrong);
}
if total_tokens >= 100_000 && progress.unlock(AchievementId::BlossomingCoder) {
newly_unlocked.push(AchievementId::BlossomingCoder);
}
if total_tokens >= 1_000_000 && progress.unlock(AchievementId::TokenMaster) {
newly_unlocked.push(AchievementId::TokenMaster);
}
// Code generation
if stats.code_blocks_generated >= 1 && progress.unlock(AchievementId::HelloWorld) {
newly_unlocked.push(AchievementId::HelloWorld);
}
if stats.code_blocks_generated >= 100 && progress.unlock(AchievementId::CodeWizard) {
newly_unlocked.push(AchievementId::CodeWizard);
}
if stats.code_blocks_generated >= 1000 && progress.unlock(AchievementId::ThousandBlocks) {
newly_unlocked.push(AchievementId::ThousandBlocks);
}
// File operations
if stats.files_edited >= 10 && progress.unlock(AchievementId::FileManipulator) {
newly_unlocked.push(AchievementId::FileManipulator);
}
let total_files = stats.files_edited + stats.files_created;
if total_files >= 100 && progress.unlock(AchievementId::FileArchitect) {
newly_unlocked.push(AchievementId::FileArchitect);
}
// Conversation milestones
if stats.messages_exchanged >= 1 && progress.unlock(AchievementId::FirstMessage) {
newly_unlocked.push(AchievementId::FirstMessage);
}
if stats.messages_exchanged >= 10 && progress.unlock(AchievementId::ConversationStarter) {
newly_unlocked.push(AchievementId::ConversationStarter);
}
if stats.messages_exchanged >= 100 && progress.unlock(AchievementId::ChattyKathy) {
newly_unlocked.push(AchievementId::ChattyKathy);
}
if stats.messages_exchanged >= 1000 && progress.unlock(AchievementId::Conversationalist) {
newly_unlocked.push(AchievementId::Conversationalist);
}
// Tool usage
let unique_tools = stats.tools_usage.len();
if unique_tools >= 5 && progress.unlock(AchievementId::Toolsmith) {
newly_unlocked.push(AchievementId::Toolsmith);
}
if unique_tools >= 10 && progress.unlock(AchievementId::ToolMaster) {
newly_unlocked.push(AchievementId::ToolMaster);
}
// Search and exploration
let search_tools = ["Glob", "Grep", "search", "Task"];
let search_count: u64 = search_tools
.iter()
.filter_map(|tool| stats.tools_usage.get(*tool))
.sum();
if search_count >= 50 && progress.unlock(AchievementId::Explorer) {
newly_unlocked.push(AchievementId::Explorer);
}
if search_count >= 500 && progress.unlock(AchievementId::MasterSearcher) {
newly_unlocked.push(AchievementId::MasterSearcher);
}
// Session duration achievements
let session_secs = stats.session_duration_seconds;
if session_secs < 300
&& stats.session_messages_exchanged >= 5
&& progress.unlock(AchievementId::QuickSession)
{
newly_unlocked.push(AchievementId::QuickSession);
}
if session_secs >= 1800 && progress.unlock(AchievementId::FocusedWork) {
newly_unlocked.push(AchievementId::FocusedWork);
}
if session_secs >= 7200 && progress.unlock(AchievementId::DeepDive) {
newly_unlocked.push(AchievementId::DeepDive);
}
if session_secs >= 18000 && progress.unlock(AchievementId::MarathonSession) {
newly_unlocked.push(AchievementId::MarathonSession);
}
// Session token achievement
let session_tokens = stats.session_input_tokens + stats.session_output_tokens;
if session_tokens >= 10000 && progress.unlock(AchievementId::MarathonCoder) {
newly_unlocked.push(AchievementId::MarathonCoder);
}
// Special first-time achievements
if !stats.tools_usage.is_empty() && progress.unlock(AchievementId::FirstTool) {
newly_unlocked.push(AchievementId::FirstTool);
}
if stats.code_blocks_generated >= 1 && progress.unlock(AchievementId::FirstCodeBlock) {
newly_unlocked.push(AchievementId::FirstCodeBlock);
}
if stats.files_edited >= 1 && progress.unlock(AchievementId::FirstFileEdit) {
newly_unlocked.push(AchievementId::FirstFileEdit);
}
// Speed coder - need to track time for this
// TODO: Implement tracking for 10 code blocks in 10 minutes
// Polyglot - need to track languages
// TODO: Implement tracking for multiple programming languages
// Claude Connoisseur - check model usage
// TODO: Track different Claude models used
// Tool mastery achievements
if let Some(bash_count) = stats.tools_usage.get("Bash") {
if *bash_count >= 50 && progress.unlock(AchievementId::BashMaster) {
newly_unlocked.push(AchievementId::BashMaster);
}
}
if let Some(read_count) = stats.tools_usage.get("Read") {
if *read_count >= 100 && progress.unlock(AchievementId::FileExplorer) {
newly_unlocked.push(AchievementId::FileExplorer);
}
}
if let Some(grep_count) = stats.tools_usage.get("Grep") {
if *grep_count >= 50 && progress.unlock(AchievementId::SearchExpert) {
newly_unlocked.push(AchievementId::SearchExpert);
}
}
// Git Guru - check git command usage in Bash
if let Some(bash_count) = stats.tools_usage.get("Bash") {
if *bash_count >= 10 && progress.unlock(AchievementId::GitGuru) {
// TODO: More specific git command tracking
newly_unlocked.push(AchievementId::GitGuru);
}
}
// Time-based achievements
if let Some(session_start) = progress.session_start {
let hour = session_start.hour();
let weekday = session_start.weekday();
// Early bird - 5 AM to 7 AM
if (5..=7).contains(&hour) && progress.unlock(AchievementId::EarlyBird) {
newly_unlocked.push(AchievementId::EarlyBird);
}
// Night owl - after midnight
let current_hour = Utc::now().hour();
if current_hour < 6 && progress.unlock(AchievementId::NightOwl) {
newly_unlocked.push(AchievementId::NightOwl);
}
// All nighter - 2 AM to 5 AM
if (2..=5).contains(&current_hour) && progress.unlock(AchievementId::AllNighter) {
newly_unlocked.push(AchievementId::AllNighter);
}
// Weekend warrior
use chrono::Weekday;
if (weekday == Weekday::Sat || weekday == Weekday::Sun)
&& progress.unlock(AchievementId::WeekendWarrior)
{
newly_unlocked.push(AchievementId::WeekendWarrior);
}
}
// Dedicated Developer - need to track consecutive days
// TODO: Implement 30 days in a row tracking
newly_unlocked
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AchievementUnlockedEvent {
pub achievement: Achievement,
}
// Save achievements to persistent store
pub async fn save_achievements(
app: &tauri::AppHandle,
progress: &AchievementProgress,
) -> Result<(), String> {
let store = app.store("achievements.json").map_err(|e| e.to_string())?;
// Create a serializable version with just the unlocked achievement IDs
let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect();
println!("Saving achievements: {:?}", unlocked_list);
store.set(
"unlocked",
serde_json::to_value(unlocked_list).map_err(|e| e.to_string())?,
);
store.save().map_err(|e| e.to_string())?;
println!("Achievements saved successfully");
Ok(())
}
// Load achievements from persistent store
pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
println!("Loading achievements from store...");
let store = match app.store("achievements.json") {
Ok(s) => s,
Err(e) => {
println!("Failed to open achievements store: {}", e);
return AchievementProgress::new();
}
};
let mut progress = AchievementProgress::new();
// Get unlocked achievements
if let Some(unlocked_value) = store.get("unlocked") {
println!("Found unlocked value in store: {:?}", unlocked_value);
if let Ok(unlocked_list) =
serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone())
{
println!("Loaded {} achievements", unlocked_list.len());
for achievement_id in unlocked_list {
progress.unlocked.insert(achievement_id);
}
} else {
println!("Failed to parse unlocked achievements");
}
} else {
println!("No unlocked achievements found in store");
}
progress
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_achievement_unlock() {
let mut progress = AchievementProgress::new();
// First unlock should return true
assert!(progress.unlock(AchievementId::FirstSteps));
assert!(progress.is_unlocked(&AchievementId::FirstSteps));
// Second unlock of same achievement should return false
assert!(!progress.unlock(AchievementId::FirstSteps));
// Newly unlocked should contain the achievement
let newly = progress.take_newly_unlocked();
assert_eq!(newly.len(), 1);
assert_eq!(newly[0], AchievementId::FirstSteps);
// After taking, newly unlocked should be empty
let newly = progress.take_newly_unlocked();
assert!(newly.is_empty());
}
}
+169
View File
@@ -0,0 +1,169 @@
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::Arc;
use tauri::AppHandle;
use crate::config::ClaudeStartOptions;
use crate::stats::UsageStats;
use crate::wsl_bridge::WslBridge;
pub struct BridgeManager {
bridges: HashMap<String, WslBridge>,
app_handle: Option<AppHandle>,
}
impl BridgeManager {
pub fn new() -> Self {
BridgeManager {
bridges: HashMap::new(),
app_handle: None,
}
}
pub fn set_app_handle(&mut self, app: AppHandle) {
self.app_handle = Some(app);
}
pub fn start_claude(
&mut self,
conversation_id: &str,
options: ClaudeStartOptions,
) -> Result<(), String> {
// 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()
.ok_or_else(|| "App handle not set".to_string())?
.clone();
// 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)?;
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()
.ok_or_else(|| "App handle not set".to_string())?;
bridge.stop(app);
Ok(())
} else {
Err("No Claude instance found for this conversation".to_string())
}
}
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()
.ok_or_else(|| "App handle not set".to_string())?;
bridge.interrupt(app)
} else {
Err("No Claude instance found for this conversation".to_string())
}
}
pub fn send_prompt(&mut self, conversation_id: &str, message: String) -> Result<(), String> {
if let Some(bridge) = self.bridges.get_mut(conversation_id) {
bridge.send_message(&message)
} else {
Err("No Claude instance found for this conversation".to_string())
}
}
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)
.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)
.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)
.map(|b| b.get_stats())
.ok_or_else(|| "No Claude instance found for this conversation".to_string())
}
#[allow(dead_code)]
pub fn cleanup_stopped_bridges(&mut self) {
// Remove bridges that are no longer running
self.bridges.retain(|_, bridge| bridge.is_running());
}
#[allow(dead_code)]
pub fn stop_all(&mut self) {
if let Some(app) = &self.app_handle {
for (_, bridge) in self.bridges.iter_mut() {
bridge.stop(app);
}
}
self.bridges.clear();
}
#[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)
})
.cloned()
.collect()
}
}
impl Default for BridgeManager {
fn default() -> Self {
Self::new()
}
}
pub type SharedBridgeManager = Arc<Mutex<BridgeManager>>;
pub fn create_shared_bridge_manager() -> SharedBridgeManager {
Arc::new(Mutex::new(BridgeManager::new()))
}
+253 -27
View File
@@ -1,44 +1,68 @@
use tauri::{AppHandle, State}; use tauri::{AppHandle, State};
use tauri_plugin_http::reqwest;
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
use crate::achievements::{get_achievement_info, load_achievements, AchievementUnlockedEvent};
use crate::bridge_manager::SharedBridgeManager;
use crate::config::{ClaudeStartOptions, HikariConfig}; use crate::config::{ClaudeStartOptions, HikariConfig};
use crate::wsl_bridge::SharedBridge; use crate::stats::UsageStats;
const CONFIG_STORE_KEY: &str = "config"; const CONFIG_STORE_KEY: &str = "config";
#[tauri::command] #[tauri::command]
pub async fn start_claude( pub async fn start_claude(
app: AppHandle, bridge_manager: State<'_, SharedBridgeManager>,
bridge: State<'_, SharedBridge>, conversation_id: String,
options: ClaudeStartOptions, options: ClaudeStartOptions,
) -> Result<(), String> { ) -> Result<(), String> {
let mut bridge = bridge.lock(); let mut manager = bridge_manager.lock();
bridge.start(app, options) manager.start_claude(&conversation_id, options)
} }
#[tauri::command] #[tauri::command]
pub async fn stop_claude(app: AppHandle, bridge: State<'_, SharedBridge>) -> Result<(), String> { pub async fn stop_claude(
let mut bridge = bridge.lock(); bridge_manager: State<'_, SharedBridgeManager>,
bridge.stop(&app); conversation_id: String,
Ok(()) ) -> Result<(), String> {
let mut manager = bridge_manager.lock();
manager.stop_claude(&conversation_id)
} }
#[tauri::command] #[tauri::command]
pub async fn send_prompt(bridge: State<'_, SharedBridge>, message: String) -> Result<(), String> { pub async fn interrupt_claude(
let mut bridge = bridge.lock(); bridge_manager: State<'_, SharedBridgeManager>,
bridge.send_message(&message) conversation_id: String,
) -> Result<(), String> {
let mut manager = bridge_manager.lock();
manager.interrupt_claude(&conversation_id)
} }
#[tauri::command] #[tauri::command]
pub async fn is_claude_running(bridge: State<'_, SharedBridge>) -> Result<bool, String> { pub async fn send_prompt(
let bridge = bridge.lock(); bridge_manager: State<'_, SharedBridgeManager>,
Ok(bridge.is_running()) conversation_id: String,
message: String,
) -> Result<(), String> {
let mut manager = bridge_manager.lock();
manager.send_prompt(&conversation_id, message)
} }
#[tauri::command] #[tauri::command]
pub async fn get_working_directory(bridge: State<'_, SharedBridge>) -> Result<String, String> { pub async fn is_claude_running(
let bridge = bridge.lock(); bridge_manager: State<'_, SharedBridgeManager>,
Ok(bridge.get_working_directory().to_string()) conversation_id: String,
) -> Result<bool, String> {
let manager = bridge_manager.lock();
Ok(manager.is_claude_running(&conversation_id))
}
#[tauri::command]
pub async fn get_working_directory(
bridge_manager: State<'_, SharedBridgeManager>,
conversation_id: String,
) -> Result<String, String> {
let manager = bridge_manager.lock();
manager.get_working_directory(&conversation_id)
} }
#[tauri::command] #[tauri::command]
@@ -48,23 +72,17 @@ pub async fn select_wsl_directory() -> Result<String, String> {
#[tauri::command] #[tauri::command]
pub async fn get_config(app: AppHandle) -> Result<HikariConfig, String> { pub async fn get_config(app: AppHandle) -> Result<HikariConfig, String> {
let store = app let store = app.store("hikari-config.json").map_err(|e| e.to_string())?;
.store("hikari-config.json")
.map_err(|e| e.to_string())?;
match store.get(CONFIG_STORE_KEY) { match store.get(CONFIG_STORE_KEY) {
Some(value) => { Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()),
serde_json::from_value(value.clone()).map_err(|e| e.to_string())
}
None => Ok(HikariConfig::default()), None => Ok(HikariConfig::default()),
} }
} }
#[tauri::command] #[tauri::command]
pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), String> { pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), String> {
let store = app let store = app.store("hikari-config.json").map_err(|e| e.to_string())?;
.store("hikari-config.json")
.map_err(|e| e.to_string())?;
let value = serde_json::to_value(&config).map_err(|e| e.to_string())?; let value = serde_json::to_value(&config).map_err(|e| e.to_string())?;
store.set(CONFIG_STORE_KEY, value); store.set(CONFIG_STORE_KEY, value);
@@ -72,3 +90,211 @@ pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), Str
Ok(()) Ok(())
} }
#[tauri::command]
pub async fn get_usage_stats(
bridge_manager: State<'_, SharedBridgeManager>,
conversation_id: String,
) -> Result<UsageStats, String> {
let manager = bridge_manager.lock();
manager.get_usage_stats(&conversation_id)
}
#[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
let progress = load_achievements(&app).await;
// Create events for all previously unlocked achievements
let mut events = Vec::new();
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 });
}
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(),
})
}
+60 -1
View File
@@ -19,6 +19,12 @@ pub struct ClaudeStartOptions {
#[serde(default)] #[serde(default)]
pub allowed_tools: Vec<String>, pub allowed_tools: Vec<String>,
#[serde(default)]
pub skip_greeting: bool,
#[serde(default)]
pub resume_session_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -46,6 +52,24 @@ pub struct HikariConfig {
#[serde(default)] #[serde(default)]
pub greeting_custom_prompt: Option<String>, pub greeting_custom_prompt: Option<String>,
#[serde(default = "default_notifications_enabled")]
pub notifications_enabled: bool,
#[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,
} }
impl Default for HikariConfig { impl Default for HikariConfig {
@@ -59,14 +83,36 @@ impl Default for HikariConfig {
theme: Theme::default(), theme: Theme::default(),
greeting_enabled: true, greeting_enabled: true,
greeting_custom_prompt: None, 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,
} }
} }
} }
fn default_update_checks_enabled() -> bool {
true
}
fn default_greeting_enabled() -> bool { fn default_greeting_enabled() -> bool {
true true
} }
fn default_notifications_enabled() -> bool {
true
}
fn default_notification_volume() -> f32 {
0.7
}
fn default_font_size() -> u32 {
14
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum Theme { pub enum Theme {
@@ -90,6 +136,10 @@ mod tests {
assert_eq!(config.theme, Theme::Dark); assert_eq!(config.theme, Theme::Dark);
assert!(config.greeting_enabled); assert!(config.greeting_enabled);
assert!(config.greeting_custom_prompt.is_none()); assert!(config.greeting_custom_prompt.is_none());
assert!(!config.always_on_top);
assert!(config.update_checks_enabled);
assert!(config.character_panel_width.is_none());
assert_eq!(config.font_size, 14);
} }
#[test] #[test]
@@ -103,6 +153,12 @@ mod tests {
theme: Theme::Light, theme: Theme::Light,
greeting_enabled: true, greeting_enabled: true,
greeting_custom_prompt: Some("Hello!".to_string()), 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,
}; };
let json = serde_json::to_string(&config).unwrap(); let json = serde_json::to_string(&config).unwrap();
@@ -113,7 +169,10 @@ mod tests {
assert_eq!(deserialized.auto_granted_tools, config.auto_granted_tools); assert_eq!(deserialized.auto_granted_tools, config.auto_granted_tools);
assert_eq!(deserialized.theme, Theme::Light); assert_eq!(deserialized.theme, Theme::Light);
assert!(deserialized.greeting_enabled); assert!(deserialized.greeting_enabled);
assert_eq!(deserialized.greeting_custom_prompt, Some("Hello!".to_string())); assert_eq!(
deserialized.greeting_custom_prompt,
Some("Hello!".to_string())
);
} }
#[test] #[test]
+36 -3
View File
@@ -1,30 +1,63 @@
mod achievements;
mod bridge_manager;
mod commands; mod commands;
mod config; mod config;
mod notifications;
mod stats;
mod types; mod types;
mod vbs_notification;
mod windows_toast;
mod wsl_bridge; mod wsl_bridge;
mod wsl_notifications;
use bridge_manager::create_shared_bridge_manager;
use commands::load_saved_achievements;
use commands::*; use commands::*;
use wsl_bridge::create_shared_bridge; use notifications::*;
use vbs_notification::*;
use windows_toast::*;
use wsl_notifications::*;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let bridge = create_shared_bridge(); let bridge_manager = create_shared_bridge_manager();
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build())
.manage(bridge) .plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_http::init())
.manage(bridge_manager.clone())
.setup(move |app| {
// Initialize the app handle in the bridge manager
bridge_manager.lock().set_app_handle(app.handle().clone());
Ok(())
})
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
start_claude, start_claude,
stop_claude, stop_claude,
interrupt_claude,
send_prompt, send_prompt,
is_claude_running, is_claude_running,
get_working_directory, get_working_directory,
select_wsl_directory, select_wsl_directory,
get_config, get_config,
save_config, save_config,
get_usage_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,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+101
View File
@@ -0,0 +1,101 @@
use std::process::Command;
use tauri::command;
#[command]
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
// Use notify-send for Linux/WSL
let output = Command::new("notify-send")
.arg(&title)
.arg(&body)
.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
)
})?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
return Err(format!("notify-send failed: {}", error));
}
Ok(())
}
#[command]
pub async fn send_windows_notification(title: String, body: String) -> Result<(), String> {
// Create PowerShell script for Windows Toast Notification
let ps_script = format!(
r#"
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
$APP_ID = 'Hikari Desktop'
$template = @"
<toast>
<visual>
<binding template="ToastText02">
<text id="1">{}</text>
<text id="2">{}</text>
</binding>
</visual>
<audio src="ms-winsoundevent:Notification.Default" />
</toast>
"@
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
$xml.LoadXml($template)
$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($APP_ID).Show($toast)
"#,
title.replace("\"", "`\""),
body.replace("\"", "`\"")
);
// Try PowerShell Core first (pwsh), then fall back to Windows PowerShell
let output = Command::new("pwsh.exe")
.arg("-NoProfile")
.arg("-WindowStyle")
.arg("Hidden")
.arg("-Command")
.arg(&ps_script)
.output()
.or_else(|_| {
Command::new("powershell.exe")
.arg("-NoProfile")
.arg("-WindowStyle")
.arg("Hidden")
.arg("-Command")
.arg(&ps_script)
.output()
})
.map_err(|e| format!("Failed to execute PowerShell: {}", e))?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
return Err(format!("PowerShell script failed: {}", error));
}
Ok(())
}
// Alternative: Use Windows built-in MSG command for simple notifications
#[command]
pub async fn send_simple_notification(title: String, body: String) -> Result<(), String> {
let message = format!("{}\n\n{}", title, body);
Command::new("cmd.exe")
.arg("/c")
.arg("msg")
.arg("*")
.arg(&message)
.output()
.map_err(|e| format!("Failed to send message: {}", e))?;
Ok(())
}
+219
View File
@@ -0,0 +1,219 @@
use crate::achievements::{check_achievements, AchievementProgress};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Instant;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UsageStats {
pub total_input_tokens: u64,
pub total_output_tokens: u64,
pub total_cost_usd: f64,
pub session_input_tokens: u64,
pub session_output_tokens: u64,
pub session_cost_usd: f64,
pub model: Option<String>,
// New fields
pub messages_exchanged: u64,
pub session_messages_exchanged: u64,
pub code_blocks_generated: u64,
pub session_code_blocks_generated: u64,
pub files_edited: u64,
pub session_files_edited: u64,
pub files_created: u64,
pub session_files_created: u64,
pub tools_usage: HashMap<String, u64>,
pub session_tools_usage: HashMap<String, u64>,
pub session_duration_seconds: u64,
#[serde(skip)]
pub session_start: Option<Instant>,
// Achievement tracking
#[serde(skip)]
pub achievements: AchievementProgress,
}
impl UsageStats {
pub fn new() -> Self {
let mut stats = Self::default();
stats.achievements.start_session();
stats
}
pub fn add_usage(&mut self, input_tokens: u64, output_tokens: u64, model: &str) {
self.total_input_tokens += input_tokens;
self.total_output_tokens += output_tokens;
self.session_input_tokens += input_tokens;
self.session_output_tokens += output_tokens;
let cost = calculate_cost(input_tokens, output_tokens, model);
self.total_cost_usd += cost;
self.session_cost_usd += cost;
self.model = Some(model.to_string());
}
pub fn reset_session(&mut self) {
self.session_input_tokens = 0;
self.session_output_tokens = 0;
self.session_cost_usd = 0.0;
self.session_messages_exchanged = 0;
self.session_code_blocks_generated = 0;
self.session_files_edited = 0;
self.session_files_created = 0;
self.session_tools_usage.clear();
self.session_duration_seconds = 0;
self.session_start = Some(Instant::now());
self.achievements.start_session();
}
pub fn increment_messages(&mut self) {
self.messages_exchanged += 1;
self.session_messages_exchanged += 1;
}
pub fn increment_code_blocks(&mut self) {
self.code_blocks_generated += 1;
self.session_code_blocks_generated += 1;
}
pub fn increment_files_edited(&mut self) {
self.files_edited += 1;
self.session_files_edited += 1;
}
pub fn increment_files_created(&mut self) {
self.files_created += 1;
self.session_files_created += 1;
}
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;
}
pub fn get_session_duration(&mut self) -> u64 {
// Only update if more than 1 second has passed to reduce calculations
if let Some(start) = self.session_start {
let elapsed = start.elapsed().as_secs();
if elapsed > self.session_duration_seconds {
self.session_duration_seconds = elapsed;
}
}
self.session_duration_seconds
}
pub fn check_achievements(&mut self) -> Vec<crate::achievements::AchievementId> {
let stats_copy = UsageStats {
total_input_tokens: self.total_input_tokens,
total_output_tokens: self.total_output_tokens,
total_cost_usd: self.total_cost_usd,
session_input_tokens: self.session_input_tokens,
session_output_tokens: self.session_output_tokens,
session_cost_usd: self.session_cost_usd,
model: self.model.clone(),
messages_exchanged: self.messages_exchanged,
session_messages_exchanged: self.session_messages_exchanged,
code_blocks_generated: self.code_blocks_generated,
session_code_blocks_generated: self.session_code_blocks_generated,
files_edited: self.files_edited,
session_files_edited: self.session_files_edited,
files_created: self.files_created,
session_files_created: self.session_files_created,
tools_usage: self.tools_usage.clone(),
session_tools_usage: self.session_tools_usage.clone(),
session_duration_seconds: self.session_duration_seconds,
session_start: self.session_start,
achievements: AchievementProgress::new(), // Dummy for copy
};
check_achievements(&stats_copy, &mut self.achievements)
}
}
// Pricing as of January 2025
// https://www.anthropic.com/pricing
fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
let (input_price_per_million, output_price_per_million) = match model {
// Opus 4.5
"claude-opus-4-5-20251101" => (15.0, 75.0),
// Opus 4
"claude-opus-4-20250514" => (15.0, 75.0),
// Sonnet 4
"claude-sonnet-4-20250514" => (3.0, 15.0),
// Previous generation models
"claude-3-5-sonnet-20241022" => (3.0, 15.0),
"claude-3-5-sonnet-20240620" => (3.0, 15.0),
"claude-3-5-haiku-20241022" => (1.0, 5.0),
"claude-3-opus-20240229" => (15.0, 75.0),
"claude-3-sonnet-20240229" => (3.0, 15.0),
"claude-3-haiku-20240307" => (0.25, 1.25),
// Default to Sonnet pricing if model unknown
_ => (3.0, 15.0),
};
let input_cost = (input_tokens as f64 / 1_000_000.0) * input_price_per_million;
let output_cost = (output_tokens as f64 / 1_000_000.0) * output_price_per_million;
input_cost + output_cost
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatsUpdateEvent {
pub stats: UsageStats,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cost_calculation_sonnet() {
let cost = calculate_cost(1000, 2000, "claude-sonnet-4-20250514");
// 1000 input * $3/M = $0.003
// 2000 output * $15/M = $0.030
// Total = $0.033
assert!((cost - 0.033).abs() < 0.0001);
}
#[test]
fn test_cost_calculation_opus() {
let cost = calculate_cost(1000, 2000, "claude-opus-4-20250514");
// 1000 input * $15/M = $0.015
// 2000 output * $75/M = $0.150
// Total = $0.165
assert!((cost - 0.165).abs() < 0.0001);
}
#[test]
fn test_usage_stats_accumulation() {
let mut stats = UsageStats::new();
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
assert_eq!(stats.total_input_tokens, 1000);
assert_eq!(stats.total_output_tokens, 2000);
assert_eq!(stats.session_input_tokens, 1000);
assert_eq!(stats.session_output_tokens, 2000);
assert!((stats.total_cost_usd - 0.033).abs() < 0.0001);
}
#[test]
fn test_session_reset() {
let mut stats = UsageStats::new();
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
stats.reset_session();
assert_eq!(stats.total_input_tokens, 1000);
assert_eq!(stats.total_output_tokens, 2000);
assert_eq!(stats.session_input_tokens, 0);
assert_eq!(stats.session_output_tokens, 0);
assert_eq!(stats.session_cost_usd, 0.0);
assert!(stats.total_cost_usd > 0.0);
}
}
+57
View File
@@ -1,5 +1,11 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsageInfo {
pub input_tokens: u64,
pub output_tokens: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum CharacterState { pub enum CharacterState {
@@ -87,6 +93,8 @@ pub enum ClaudeMessage {
num_turns: Option<u32>, num_turns: Option<u32>,
#[serde(default)] #[serde(default)]
permission_denials: Option<Vec<PermissionDenial>>, permission_denials: Option<Vec<PermissionDenial>>,
#[serde(default)]
usage: Option<UsageInfo>,
}, },
} }
@@ -97,6 +105,8 @@ pub struct AssistantMessageContent {
pub model: Option<String>, pub model: Option<String>,
#[serde(default)] #[serde(default)]
pub stop_reason: Option<String>, pub stop_reason: Option<String>,
#[serde(default)]
pub usage: Option<UsageInfo>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -162,6 +172,8 @@ pub struct DeltaContent {
pub struct StateChangeEvent { pub struct StateChangeEvent {
pub state: CharacterState, pub state: CharacterState,
pub tool_name: Option<String>, pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -169,6 +181,8 @@ pub struct OutputEvent {
pub line_type: String, pub line_type: String,
pub content: String, pub content: String,
pub tool_name: Option<String>, pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -177,6 +191,47 @@ pub struct PermissionPromptEvent {
pub tool_name: String, pub tool_name: String,
pub tool_input: serde_json::Value, pub tool_input: serde_json::Value,
pub description: String, pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionEvent {
pub status: ConnectionStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionEvent {
pub session_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkingDirectoryEvent {
pub directory: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuestionOption {
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserQuestionEvent {
pub id: String,
pub question: String,
pub header: Option<String>,
pub options: Vec<QuestionOption>,
pub multi_select: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
} }
#[cfg(test)] #[cfg(test)]
@@ -284,6 +339,7 @@ mod tests {
let event = StateChangeEvent { let event = StateChangeEvent {
state: CharacterState::Coding, state: CharacterState::Coding,
tool_name: Some("Edit".to_string()), tool_name: Some("Edit".to_string()),
conversation_id: None,
}; };
let serialized = serde_json::to_string(&event).unwrap(); let serialized = serde_json::to_string(&event).unwrap();
@@ -297,6 +353,7 @@ mod tests {
line_type: "assistant".to_string(), line_type: "assistant".to_string(),
content: "Test output".to_string(), content: "Test output".to_string(),
tool_name: None, tool_name: None,
conversation_id: None,
}; };
let serialized = serde_json::to_string(&event).unwrap(); let serialized = serde_json::to_string(&event).unwrap();
+71
View File
@@ -0,0 +1,71 @@
use std::io::Write;
use std::process::Command;
use tauri::command;
use tempfile::NamedTempFile;
#[command]
pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> {
// Create a VBScript that shows a Windows notification
let vbs_content = format!(
r#"
Set objShell = CreateObject("WScript.Shell")
objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
"#,
body.replace("\"", "\"\"").replace("\n", "\" & vbCrLf & \""),
title.replace("\"", "\"\""),
title.replace("\"", "\"\"")
);
// Create a temporary VBS file
let mut temp_file =
NamedTempFile::new().map_err(|e| format!("Failed to create temp file: {}", e))?;
temp_file
.write_all(vbs_content.as_bytes())
.map_err(|e| format!("Failed to write VBS content: {}", e))?;
let temp_path = temp_file.path().to_string_lossy().to_string();
// Convert WSL path to Windows path
let windows_path = if temp_path.starts_with("/mnt/") {
// Convert /mnt/c/... to C:\...
let path_parts: Vec<&str> = temp_path.split('/').collect();
if path_parts.len() > 2 {
let drive_letter = path_parts[2].to_uppercase();
let rest_of_path = path_parts[3..].join("\\");
format!("{}:\\{}", drive_letter, rest_of_path)
} else {
temp_path.clone()
}
} 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();
if let Ok(result) = output {
if result.status.success() {
String::from_utf8_lossy(&result.stdout).trim().to_string()
} else {
temp_path.clone()
}
} else {
temp_path.clone()
}
} else {
temp_path.clone()
};
// Execute the VBScript using wscript.exe
let output = Command::new("/mnt/c/Windows/System32/wscript.exe")
.arg("//NoLogo")
.arg(&windows_path)
.output()
.map_err(|e| format!("Failed to execute VBScript: {}", e))?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
return Err(format!("VBScript execution failed: {}", error));
}
Ok(())
}
+64
View File
@@ -0,0 +1,64 @@
use tauri::command;
#[cfg(target_os = "windows")]
use windows::{
core::{Result as WindowsResult, HSTRING},
Data::Xml::Dom::*,
UI::Notifications::*,
};
#[cfg(target_os = "windows")]
#[command]
pub async fn send_windows_toast(title: String, body: String) -> Result<(), String> {
show_toast_notification(&title, &body)
.map_err(|e| format!("Failed to show toast notification: {}", e))
}
#[cfg(target_os = "windows")]
fn show_toast_notification(title: &str, body: &str) -> WindowsResult<()> {
// Create the XML for the toast notification
let toast_xml = format!(
r#"<toast>
<visual>
<binding template="ToastGeneric">
<text>{}</text>
<text>{}</text>
</binding>
</visual>
<audio src="ms-winsoundevent:Notification.Default" />
</toast>"#,
escape_xml(title),
escape_xml(body)
);
let xml_doc = XmlDocument::new()?;
xml_doc.LoadXml(&HSTRING::from(toast_xml))?;
// Create the toast notification
let toast = ToastNotification::CreateToastNotification(&xml_doc)?;
// Create a toast notifier with an application ID
let notifier =
ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from("Hikari Desktop"))?;
// Show the notification
notifier.Show(&toast)?;
Ok(())
}
#[cfg(target_os = "windows")]
fn escape_xml(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
// Stub for non-Windows platforms
#[cfg(not(target_os = "windows"))]
#[command]
pub async fn send_windows_toast(_title: String, _body: String) -> Result<(), String> {
Err("Windows toast notifications are only available on Windows".to_string())
}
+530 -68
View File
@@ -1,4 +1,3 @@
use parking_lot::Mutex;
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio}; use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::Arc; use std::sync::Arc;
@@ -9,8 +8,15 @@ use tempfile::NamedTempFile;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
use crate::config::ClaudeStartOptions; use crate::config::ClaudeStartOptions;
use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent}; 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 SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"]; const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
@@ -72,6 +78,8 @@ pub struct WslBridge {
working_directory: String, working_directory: String,
session_id: Option<String>, session_id: Option<String>,
mcp_config_file: Option<NamedTempFile>, mcp_config_file: Option<NamedTempFile>,
stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>,
} }
impl WslBridge { impl WslBridge {
@@ -82,6 +90,20 @@ impl WslBridge {
working_directory: String::new(), working_directory: String::new(),
session_id: None, session_id: None,
mcp_config_file: None, mcp_config_file: None,
stats: Arc::new(RwLock::new(UsageStats::new())),
conversation_id: None,
}
}
pub fn new_with_conversation_id(conversation_id: String) -> Self {
WslBridge {
process: None,
stdin: None,
working_directory: String::new(),
session_id: None,
mcp_config_file: None,
stats: Arc::new(RwLock::new(UsageStats::new())),
conversation_id: Some(conversation_id),
} }
} }
@@ -90,10 +112,27 @@ impl WslBridge {
return Err("Process already running".to_string()); return Err("Process already running".to_string());
} }
// Load saved achievements 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;
});
let working_dir = &options.working_dir; let working_dir = &options.working_dir;
self.working_directory = working_dir.clone(); self.working_directory = working_dir.clone();
emit_connection_status(&app, ConnectionStatus::Connecting); emit_connection_status(
&app,
ConnectionStatus::Connecting,
self.conversation_id.clone(),
);
// Create temp file for MCP config if provided // Create temp file for MCP config if provided
let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json { let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json {
@@ -129,16 +168,19 @@ impl WslBridge {
let mut command = if is_wsl { let mut command = if is_wsl {
// Running inside WSL - call claude directly // Running inside WSL - call claude directly
// Try to find claude in common locations since GUI apps may not inherit shell PATH // Try to find claude in common locations since GUI apps may not inherit shell PATH
let claude_path = find_claude_binary() let claude_path = find_claude_binary().ok_or_else(|| {
.ok_or_else(|| "Could not find claude binary. Is Claude Code installed?".to_string())?; "Could not find claude binary. Is Claude Code installed?".to_string()
})?;
eprintln!("[DEBUG] Found claude at: {}", claude_path); eprintln!("[DEBUG] Found claude at: {}", claude_path);
eprintln!("[DEBUG] Working dir: {}", working_dir); eprintln!("[DEBUG] Working dir: {}", working_dir);
let mut cmd = Command::new(&claude_path); let mut cmd = Command::new(&claude_path);
cmd.args([ cmd.args([
"--output-format", "stream-json", "--output-format",
"--input-format", "stream-json", "stream-json",
"--input-format",
"stream-json",
"--verbose", "--verbose",
]); ]);
@@ -166,6 +208,13 @@ impl WslBridge {
cmd.args(["--mcp-config", mcp_path]); cmd.args(["--mcp-config", mcp_path]);
} }
// Add resume flag if session ID provided
if let Some(ref session_id) = options.resume_session_id {
if !session_id.is_empty() {
cmd.args(["--resume", session_id]);
}
}
cmd.current_dir(working_dir); cmd.current_dir(working_dir);
// Set API key as environment variable if specified // Set API key as environment variable if specified
@@ -182,10 +231,7 @@ impl WslBridge {
let mut cmd = Command::new("wsl"); let mut cmd = Command::new("wsl");
// Build the claude command with all arguments // Build the claude command with all arguments
let mut claude_cmd = format!( let mut claude_cmd = format!("cd '{}' && ", working_dir);
"cd '{}' && ",
working_dir
);
// Set API key as environment variable if specified // Set API key as environment variable if specified
if let Some(ref api_key) = options.api_key { if let Some(ref api_key) = options.api_key {
@@ -194,7 +240,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 // Add model if specified
if let Some(ref model) = options.model { if let Some(ref model) = options.model {
@@ -222,6 +270,13 @@ impl WslBridge {
claude_cmd.push_str(&format!(" --mcp-config '{}'", mcp_path)); claude_cmd.push_str(&format!(" --mcp-config '{}'", mcp_path));
} }
// Add resume flag if session ID provided
if let Some(ref session_id) = options.resume_session_id {
if !session_id.is_empty() {
claude_cmd.push_str(&format!(" --resume '{}'", session_id));
}
}
// Use bash -lc to load login profile (ensures PATH includes claude) // Use bash -lc to load login profile (ensures PATH includes claude)
cmd.args(["-e", "bash", "-lc", &claude_cmd]); cmd.args(["-e", "bash", "-lc", &claude_cmd]);
@@ -249,21 +304,39 @@ impl WslBridge {
self.stdin = stdin; self.stdin = stdin;
self.process = Some(child); self.process = Some(child);
// Note: We no longer reset stats here - stats persist across reconnects
// Stats are only reset when explicitly disconnecting via stop()
// Load saved achievements
let app_handle = app.clone();
let stats_clone = self.stats.clone();
tokio::spawn(async move {
let saved_progress = crate::achievements::load_achievements(&app_handle).await;
stats_clone.write().achievements = saved_progress;
});
if let Some(stdout) = stdout { if let Some(stdout) = stdout {
let app_clone = app.clone(); let app_clone = app.clone();
let stats_clone = self.stats.clone();
let conv_id = self.conversation_id.clone();
thread::spawn(move || { thread::spawn(move || {
handle_stdout(stdout, app_clone); handle_stdout(stdout, app_clone, stats_clone, conv_id);
}); });
} }
if let Some(stderr) = stderr { if let Some(stderr) = stderr {
let app_clone = app.clone(); let app_clone = app.clone();
let conv_id = self.conversation_id.clone();
thread::spawn(move || { thread::spawn(move || {
handle_stderr(stderr, app_clone); handle_stderr(stderr, app_clone, conv_id);
}); });
} }
emit_connection_status(&app, ConnectionStatus::Connected); emit_connection_status(
&app,
ConnectionStatus::Connected,
self.conversation_id.clone(),
);
Ok(()) Ok(())
} }
@@ -288,11 +361,76 @@ impl WslBridge {
.write_all(format!("{}\n", json_line).as_bytes()) .write_all(format!("{}\n", json_line).as_bytes())
.map_err(|e| format!("Failed to write to stdin: {}", e))?; .map_err(|e| format!("Failed to write to stdin: {}", e))?;
stdin.flush().map_err(|e| format!("Failed to flush stdin: {}", e))?; stdin
.flush()
.map_err(|e| format!("Failed to flush stdin: {}", e))?;
Ok(()) 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(())
}
pub fn interrupt(&mut self, app: &AppHandle) -> Result<(), String> {
// Due to persistent bug in Claude Code where ESC/Ctrl+C doesn't work,
// we have to kill the process. This is the only reliable way to stop it.
// See: https://github.com/anthropics/claude-code/issues/3455
if let Some(mut process) = self.process.take() {
// Kill the process immediately
let _ = process.kill();
let _ = process.wait();
// Clear stdin
self.stdin = None;
// Keep session_id and working directory for user reference
// The user will see what session was interrupted
// Emit disconnected status
emit_connection_status(
app,
ConnectionStatus::Disconnected,
self.conversation_id.clone(),
);
Ok(())
} else {
Err("No active process to interrupt".to_string())
}
}
pub fn stop(&mut self, app: &AppHandle) { pub fn stop(&mut self, app: &AppHandle) {
if let Some(mut process) = self.process.take() { if let Some(mut process) = self.process.take() {
let _ = process.kill(); let _ = process.kill();
@@ -301,7 +439,15 @@ impl WslBridge {
self.stdin = None; self.stdin = None;
self.session_id = None; self.session_id = None;
self.mcp_config_file = None; // Temp file is automatically deleted when dropped self.mcp_config_file = None; // Temp file is automatically deleted when dropped
emit_connection_status(app, ConnectionStatus::Disconnected);
// Reset session stats on explicit disconnect
self.stats.write().reset_session();
emit_connection_status(
app,
ConnectionStatus::Disconnected,
self.conversation_id.clone(),
);
} }
pub fn is_running(&self) -> bool { pub fn is_running(&self) -> bool {
@@ -311,6 +457,10 @@ impl WslBridge {
pub fn get_working_directory(&self) -> &str { pub fn get_working_directory(&self) -> &str {
&self.working_directory &self.working_directory
} }
pub fn get_stats(&self) -> UsageStats {
self.stats.read().clone()
}
} }
impl Default for WslBridge { impl Default for WslBridge {
@@ -319,13 +469,18 @@ impl Default for WslBridge {
} }
} }
fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle) { fn handle_stdout(
stdout: std::process::ChildStdout,
app: AppHandle,
stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>,
) {
let reader = BufReader::new(stdout); let reader = BufReader::new(stdout);
for line in reader.lines() { for line in reader.lines() {
match line { match line {
Ok(line) if !line.is_empty() => { Ok(line) if !line.is_empty() => {
if let Err(e) = process_json_line(&line, &app) { if let Err(e) = process_json_line(&line, &app, &stats, &conversation_id) {
eprintln!("Error processing line: {}", e); eprintln!("Error processing line: {}", e);
} }
} }
@@ -337,20 +492,28 @@ fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle) {
} }
} }
emit_connection_status(&app, ConnectionStatus::Disconnected); emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
} }
fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) { fn handle_stderr(
stderr: std::process::ChildStderr,
app: AppHandle,
conversation_id: Option<String>,
) {
let reader = BufReader::new(stderr); let reader = BufReader::new(stderr);
for line in reader.lines() { for line in reader.lines() {
match line { match line {
Ok(line) if !line.is_empty() => { Ok(line) if !line.is_empty() => {
let _ = app.emit("claude:output", OutputEvent { let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "error".to_string(), line_type: "error".to_string(),
content: line, content: line,
tool_name: None, tool_name: None,
}); conversation_id: conversation_id.clone(),
},
);
} }
Err(_) => break, Err(_) => break,
_ => {} _ => {}
@@ -358,20 +521,42 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) {
} }
} }
fn process_json_line(line: &str, app: &AppHandle) -> 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) let message: ClaudeMessage = serde_json::from_str(line)
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?; .map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
match &message { match &message {
ClaudeMessage::System { subtype, session_id, cwd, .. } => { ClaudeMessage::System {
subtype,
session_id,
cwd,
..
} => {
if subtype == "init" { if subtype == "init" {
if let Some(id) = session_id { if let Some(id) = session_id {
let _ = app.emit("claude:session", id.clone()); let _ = app.emit(
"claude:session",
SessionEvent {
session_id: id.clone(),
conversation_id: conversation_id.clone(),
},
);
} }
if let Some(dir) = cwd { if let Some(dir) = cwd {
let _ = app.emit("claude:cwd", dir.clone()); let _ = app.emit(
"claude:cwd",
WorkingDirectoryEvent {
directory: dir.clone(),
conversation_id: conversation_id.clone(),
},
);
} }
emit_state_change(app, CharacterState::Idle, None); emit_state_change(app, CharacterState::Idle, None, conversation_id.clone());
} }
} }
@@ -379,39 +564,92 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> {
let mut state = CharacterState::Typing; let mut state = CharacterState::Typing;
let mut tool_name = None; let mut tool_name = None;
// Only update stats if we have usage information
if let Some(usage) = &message.usage {
if let Some(model) = &message.model {
// Batch all stats updates in a single write lock
{
let mut stats_guard = stats.write();
stats_guard.increment_messages();
stats_guard.add_usage(usage.input_tokens, usage.output_tokens, model);
stats_guard.get_session_duration();
}
// Don't emit here - we'll emit on Result message instead
// This reduces the frequency of updates
} else {
// Just increment message count if no usage info
stats.write().increment_messages();
}
} else {
// Just increment message count if no usage info
stats.write().increment_messages();
}
for block in &message.content { for block in &message.content {
match block { match block {
ContentBlock::ToolUse { name, input, .. } => { ContentBlock::ToolUse { name, input, .. } => {
tool_name = Some(name.clone()); tool_name = Some(name.clone());
state = get_tool_state(name); state = get_tool_state(name);
// Batch tool tracking updates
{
let mut stats_guard = stats.write();
stats_guard.increment_tool_usage(name);
// Track file operations
match name.as_str() {
"Edit" => stats_guard.increment_files_edited(),
"Write" => stats_guard.increment_files_created(),
_ => {}
}
}
let desc = format_tool_description(name, input); let desc = format_tool_description(name, input);
let _ = app.emit("claude:output", OutputEvent { let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "tool".to_string(), line_type: "tool".to_string(),
content: desc, content: desc,
tool_name: Some(name.clone()), tool_name: Some(name.clone()),
}); conversation_id: conversation_id.clone(),
},
);
} }
ContentBlock::Text { text } => { ContentBlock::Text { text } => {
let _ = app.emit("claude:output", OutputEvent { // Count code blocks in the text
let code_blocks = text.matches("```").count() / 2;
for _ in 0..code_blocks {
stats.write().increment_code_blocks();
}
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "assistant".to_string(), line_type: "assistant".to_string(),
content: text.clone(), content: text.clone(),
tool_name: None, tool_name: None,
}); conversation_id: conversation_id.clone(),
},
);
} }
ContentBlock::Thinking { thinking } => { ContentBlock::Thinking { thinking } => {
state = CharacterState::Thinking; state = CharacterState::Thinking;
let _ = app.emit("claude:output", OutputEvent { let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "system".to_string(), line_type: "system".to_string(),
content: format!("[Thinking] {}", thinking), content: format!("[Thinking] {}", thinking),
tool_name: None, tool_name: None,
}); conversation_id: conversation_id.clone(),
},
);
} }
_ => {} _ => {}
} }
} }
emit_state_change(app, state, tool_name); emit_state_change(app, state, tool_name, conversation_id.clone());
} }
ClaudeMessage::StreamEvent { event } => { ClaudeMessage::StreamEvent { event } => {
@@ -429,7 +667,7 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> {
} }
_ => CharacterState::Typing, _ => CharacterState::Typing,
}; };
emit_state_change(app, state, block.name.clone()); emit_state_change(app, state, block.name.clone(), conversation_id.clone());
} }
} else if event.event_type == "content_block_delta" { } else if event.event_type == "content_block_delta" {
if let Some(delta) = &event.delta { if let Some(delta) = &event.delta {
@@ -440,48 +678,237 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> {
} }
} }
ClaudeMessage::Result { subtype, result, permission_denials, .. } => { ClaudeMessage::Result {
subtype,
result,
permission_denials,
usage: _,
..
} => {
let state = if subtype == "success" { let state = if subtype == "success" {
CharacterState::Success CharacterState::Success
} else { } else {
CharacterState::Error CharacterState::Error
}; };
// Always emit updated stats on result message (less frequent)
// This includes the latest session duration
let newly_unlocked = {
let mut stats_guard = stats.write();
stats_guard.get_session_duration();
println!("Checking achievements after result message...");
let unlocked = stats_guard.check_achievements();
println!("Newly unlocked achievements: {:?}", unlocked);
unlocked
};
// 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 },
);
}
// Save achievements after unlocking new ones
if !newly_unlocked.is_empty() {
println!("Saving newly unlocked achievements: {:?}", newly_unlocked);
let app_handle = app.clone();
let achievements_progress = stats.read().achievements.clone();
// 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
{
eprintln!("Failed to save achievements: {}", e);
} else {
println!("Achievement save task completed successfully");
}
});
}
let current_stats = stats.read().clone();
let stats_event = StatsUpdateEvent {
stats: current_stats,
};
let _ = app.emit("claude:stats", stats_event);
// Only emit error results - success content is already sent via Assistant message // Only emit error results - success content is already sent via Assistant message
if subtype != "success" { if subtype != "success" {
if let Some(text) = result { if let Some(text) = result {
let _ = app.emit("claude:output", OutputEvent { let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "error".to_string(), line_type: "error".to_string(),
content: text.clone(), content: text.clone(),
tool_name: None, tool_name: None,
}); conversation_id: conversation_id.clone(),
},
);
} }
} }
// Check for permission denials and emit prompts for each // Check for permission denials and emit prompts for each
if let Some(denials) = permission_denials { if let Some(denials) = permission_denials {
let mut has_regular_denials = false;
for denial in denials { for denial in denials {
let description = format_tool_description(&denial.tool_name, &denial.tool_input); // Special handling for AskUserQuestion tool
let _ = app.emit("claude:permission", PermissionPromptEvent { 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(), id: denial.tool_use_id.clone(),
tool_name: denial.tool_name.clone(), tool_name: denial.tool_name.clone(),
tool_input: denial.tool_input.clone(), tool_input: denial.tool_input.clone(),
description, description,
}); conversation_id: conversation_id.clone(),
},
);
}
} }
// Show permission state if there were denials // Show permission state if there were any denials (questions or regular)
if !denials.is_empty() { if has_regular_denials || !denials.is_empty() {
emit_state_change(app, CharacterState::Permission, None); emit_state_change(
app,
CharacterState::Permission,
None,
conversation_id.clone(),
);
return Ok(()); return Ok(());
} }
} }
emit_state_change(app, state, None); emit_state_change(app, state, None, conversation_id.clone());
} }
ClaudeMessage::User { .. } => { ClaudeMessage::User { message } => {
emit_state_change(app, CharacterState::Thinking, None); // Increment message count for user messages
stats.write().increment_messages();
// Extract text content from the message
let message_text = message
.content
.iter()
.filter_map(|block| match block {
crate::types::ContentBlock::Text { text } => Some(text.clone()),
_ => None,
})
.collect::<Vec<String>>()
.join(" ");
// Check achievements after user message
let newly_unlocked = {
let mut stats_guard = stats.write();
println!("User sent message, checking achievements...");
// Check message-based achievements
let mut unlocked = crate::achievements::check_message_achievements(
&message_text,
&mut stats_guard.achievements,
);
// Check stats-based achievements
let stats_unlocked = stats_guard.check_achievements();
unlocked.extend(stats_unlocked);
unlocked
};
// Emit achievement events for any newly unlocked achievements
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 },
);
}
// Save achievements after unlocking new ones
if !newly_unlocked.is_empty() {
println!("Saving newly unlocked achievements from user message");
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
{
eprintln!("Failed to save achievements: {}", e);
} else {
println!("Achievements saved after user message");
}
});
}
emit_state_change(app, CharacterState::Thinking, None, conversation_id.clone());
} }
} }
@@ -548,18 +975,34 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
} }
} }
fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option<String>) { fn emit_state_change(
let _ = app.emit("claude:state", StateChangeEvent { state, tool_name }); 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) { fn emit_connection_status(
let _ = app.emit("claude:connection", status); app: &AppHandle,
} status: ConnectionStatus,
conversation_id: Option<String>,
pub type SharedBridge = Arc<Mutex<WslBridge>>; ) {
let _ = app.emit(
pub fn create_shared_bridge() -> SharedBridge { "claude:connection",
Arc::new(Mutex::new(WslBridge::new())) ConnectionEvent {
status,
conversation_id,
},
);
} }
#[cfg(test)] #[cfg(test)]
@@ -571,21 +1014,36 @@ mod tests {
assert!(matches!(get_tool_state("Read"), CharacterState::Searching)); assert!(matches!(get_tool_state("Read"), CharacterState::Searching));
assert!(matches!(get_tool_state("Glob"), CharacterState::Searching)); assert!(matches!(get_tool_state("Glob"), CharacterState::Searching));
assert!(matches!(get_tool_state("Grep"), CharacterState::Searching)); assert!(matches!(get_tool_state("Grep"), CharacterState::Searching));
assert!(matches!(get_tool_state("WebSearch"), CharacterState::Searching)); assert!(matches!(
assert!(matches!(get_tool_state("WebFetch"), CharacterState::Searching)); get_tool_state("WebSearch"),
CharacterState::Searching
));
assert!(matches!(
get_tool_state("WebFetch"),
CharacterState::Searching
));
} }
#[test] #[test]
fn test_get_tool_state_coding_tools() { fn test_get_tool_state_coding_tools() {
assert!(matches!(get_tool_state("Edit"), CharacterState::Coding)); assert!(matches!(get_tool_state("Edit"), CharacterState::Coding));
assert!(matches!(get_tool_state("Write"), CharacterState::Coding)); assert!(matches!(get_tool_state("Write"), CharacterState::Coding));
assert!(matches!(get_tool_state("NotebookEdit"), CharacterState::Coding)); assert!(matches!(
get_tool_state("NotebookEdit"),
CharacterState::Coding
));
} }
#[test] #[test]
fn test_get_tool_state_mcp_tools() { fn test_get_tool_state_mcp_tools() {
assert!(matches!(get_tool_state("mcp__github__create_issue"), CharacterState::Mcp)); assert!(matches!(
assert!(matches!(get_tool_state("mcp__notion__search"), CharacterState::Mcp)); get_tool_state("mcp__github__create_issue"),
CharacterState::Mcp
));
assert!(matches!(
get_tool_state("mcp__notion__search"),
CharacterState::Mcp
));
} }
#[test] #[test]
@@ -595,7 +1053,10 @@ mod tests {
#[test] #[test]
fn test_get_tool_state_unknown() { 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)); assert!(matches!(get_tool_state("Bash"), CharacterState::Typing));
} }
@@ -679,9 +1140,10 @@ mod tests {
} }
#[test] #[test]
fn test_create_shared_bridge() { fn test_create_shared_bridge_manager() {
let shared = create_shared_bridge(); use crate::bridge_manager::create_shared_bridge_manager;
let bridge = shared.lock(); let shared = create_shared_bridge_manager();
assert!(!bridge.is_running()); let manager = shared.lock();
assert!(manager.get_active_conversations().is_empty());
} }
} }
+84
View File
@@ -0,0 +1,84 @@
use std::process::Command;
use tauri::command;
#[command]
pub async fn send_wsl_notification(title: String, body: String) -> Result<(), String> {
// Method 1: Try Windows 10/11 toast notification using PowerShell
let toast_command = format!(
r#"
Add-Type -AssemblyName System.Runtime.WindowsRuntime
$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
$null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
$APP_ID = 'Hikari Desktop'
$template = @"
<toast>
<visual>
<binding template="ToastGeneric">
<text>{0}</text>
<text>{1}</text>
</binding>
</visual>
</toast>
"@
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
$xml.LoadXml($template -f ('{0}' -replace "'", "''"), ('{1}' -replace "'", "''"))
$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($APP_ID)
$notifier.Show($toast)
"#,
title.replace("'", "''").replace("\"", "\\\""),
body.replace("'", "''").replace("\"", "\\\"")
);
// Try PowerShell.exe through WSL
let output = Command::new("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe")
.arg("-NoProfile")
.arg("-ExecutionPolicy")
.arg("Bypass")
.arg("-WindowStyle")
.arg("Hidden")
.arg("-Command")
.arg(&toast_command)
.output();
match output {
Ok(result) => {
if result.status.success() {
println!("WSL notification sent successfully");
return Ok(());
} else {
let stderr = String::from_utf8_lossy(&result.stderr);
println!("PowerShell toast failed: {}", stderr);
}
}
Err(e) => {
println!("Failed to run PowerShell: {}", e);
}
}
// Skip msg.exe as it creates alert boxes
// Method 2 removed
// Method 3: Try wsl-notify-send if available
let notify_result = Command::new("wsl-notify-send")
.arg("--appId")
.arg("HikariDesktop")
.arg("--category")
.arg(&title)
.arg(&body)
.output();
if let Ok(result) = notify_result {
if result.status.success() {
println!("Notification sent via wsl-notify-send");
return Ok(());
}
}
// If all methods fail, return an error
Err("All WSL notification methods failed".to_string())
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop", "productName": "hikari-desktop",
"version": "0.1.0", "version": "0.3.0",
"identifier": "com.naomi.hikari-desktop", "identifier": "com.naomi.hikari-desktop",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
+36
View File
@@ -6,11 +6,29 @@
--bg-secondary: #16213e; --bg-secondary: #16213e;
--bg-terminal: #0f0f1a; --bg-terminal: #0f0f1a;
--bg-hover: #2a2a4a; --bg-hover: #2a2a4a;
--bg-code: #1e1e2e;
--accent-primary: #e94560; --accent-primary: #e94560;
--accent-secondary: #ff6b9d; --accent-secondary: #ff6b9d;
--text-primary: #ffffff; --text-primary: #ffffff;
--text-secondary: #a0a0a0; --text-secondary: #a0a0a0;
--text-tertiary: #6b7280;
--border-color: #2a2a4a; --border-color: #2a2a4a;
/* 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"] { [data-theme="light"] {
@@ -18,11 +36,29 @@
--bg-secondary: #ffffff; --bg-secondary: #ffffff;
--bg-terminal: #f1f3f4; --bg-terminal: #f1f3f4;
--bg-hover: #e8e8e8; --bg-hover: #e8e8e8;
--bg-code: #f5f5f5;
--accent-primary: #e94560; --accent-primary: #e94560;
--accent-secondary: #ff6b9d; --accent-secondary: #ff6b9d;
--text-primary: #1a1a2e; --text-primary: #1a1a2e;
--text-secondary: #5a5a7a; --text-secondary: #5a5a7a;
--text-tertiary: #9ca3af;
--border-color: #d0d0e0; --border-color: #d0d0e0;
/* 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;
} }
html, html,
+280
View File
@@ -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("/");
}
+153
View File
@@ -0,0 +1,153 @@
<script lang="ts">
import { openUrl } from "@tauri-apps/plugin-opener";
import { getVersion } from "@tauri-apps/api/app";
import { onMount } from "svelte";
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
let appVersion = $state("");
onMount(async () => {
appVersion = await getVersion();
});
const links = {
source: "https://git.nhcarrigan.com/nhcarrigan/hikari-desktop",
discord: "https://chat.nhcarrigan.com",
website: "https://nhcarrigan.com",
license: "https://docs.nhcarrigan.com/legal/license/",
changelog: "https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/releases",
};
</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-md w-full p-6"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="about-title"
tabindex="-1"
>
<div class="flex items-center justify-between mb-4">
<h2 id="about-title" class="text-xl font-semibold text-[var(--text-primary)]">
About Hikari Desktop
</h2>
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="space-y-4 text-sm">
<div>
<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-[var(--text-primary)] mb-2">Version</h3>
<p class="text-[var(--text-secondary)] mb-1">
{appVersion || "Loading..."}
</p>
<button
onclick={() => openUrl(links.changelog)}
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
>
View Changelog
</button>
</div>
<div>
<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"
>
View on Git
</button>
</div>
<div>
<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"
>
Join our Discord
</button>
</div>
<div>
<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"
>
Naomi Carrigan
</button>
</div>
<div>
<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
onclick={() => openUrl(links.license)}
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
>
View License
</button>
</div>
<div class="pt-4 mt-4 border-t border-[var(--border-color)]">
<p class="text-xs text-[var(--text-tertiary)] text-center">
Copyright © {new Date().getFullYear()} Naomi Carrigan. All rights reserved.
</p>
</div>
</div>
</div>
</div>
<style>
/* Ensure the panel appears above other content */
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>
@@ -0,0 +1,202 @@
<script lang="ts">
import { onMount } from "svelte";
import { fade, fly } from "svelte/transition";
import { cubicOut } from "svelte/easing";
import { listen } from "@tauri-apps/api/event";
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
let achievements = $state<AchievementUnlockedEvent[]>([]);
let currentAchievement = $state<AchievementUnlockedEvent | null>(null);
let showNotification = $state(false);
onMount(() => {
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
achievements.push(event.payload);
if (!showNotification) {
showNext();
}
});
};
setupListener();
return () => {
if (unlisten) {
unlisten();
}
};
});
function showNext() {
if (achievements.length > 0) {
currentAchievement = achievements.shift() || null;
showNotification = true;
// Auto-hide after 5 seconds
setTimeout(() => {
showNotification = false;
// Show next achievement after animation completes
setTimeout(() => showNext(), 300);
}, 5000);
}
}
function dismiss() {
showNotification = false;
// Show next achievement after animation completes
setTimeout(() => showNext(), 300);
}
function getRarityColor(rarity: string): string {
switch (rarity) {
case "legendary":
return "from-yellow-400 to-orange-500";
case "epic":
return "from-purple-400 to-pink-500";
case "rare":
return "from-blue-400 to-indigo-500";
default:
return "from-green-400 to-emerald-500";
}
}
function getAchievementRarity(id: string): string {
// Determine rarity based on achievement ID
if (id === "TokenMaster") return "legendary";
if (["CodeMachine", "Unstoppable"].includes(id)) return "epic";
if (
[
"BlossomingCoder",
"CodeWizard",
"MasterBuilder",
"EnduranceChamp",
"DeepDive",
"CreativeCoder",
].includes(id)
)
return "rare";
return "common";
}
</script>
{#if showNotification && currentAchievement}
<div
class="fixed top-20 right-4 z-50 max-w-sm"
in:fly={{ x: 300, duration: 500, easing: cubicOut }}
out:fade={{ duration: 300 }}
>
<!-- Backdrop with animated gradient border -->
<div class="relative p-[2px] rounded-lg overflow-hidden">
<!-- Animated gradient border -->
<div
class="absolute inset-0 bg-gradient-to-r {getRarityColor(
getAchievementRarity(currentAchievement.achievement.id)
)} animate-pulse"
></div>
<!-- Main notification content -->
<div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm">
<button
onclick={dismiss}
onkeydown={(e) => e.key === "Enter" && dismiss()}
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
aria-label="Dismiss notification"
>
<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"
></path>
</svg>
</button>
<div class="flex items-start gap-4">
<!-- Icon with animated sparkles -->
<div class="relative flex-shrink-0">
<div class="text-5xl animate-bounce">{currentAchievement.achievement.icon}</div>
<!-- Sparkle animations -->
<div class="absolute -top-1 -right-1 text-yellow-400 animate-ping"></div>
<div
class="absolute -bottom-1 -left-1 text-yellow-400 animate-ping animation-delay-200"
>
</div>
<div class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400">
</div>
</div>
<!-- Text content -->
<div class="flex-1 min-w-0 pt-1">
<h3
class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide"
>
Achievement Unlocked!
</h3>
<p class="text-lg font-bold text-[var(--text-primary)] mt-1">
{currentAchievement.achievement.name}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{currentAchievement.achievement.description}
</p>
<!-- Rarity badge -->
<div class="mt-2 inline-flex items-center">
<span
class="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r {getRarityColor(
getAchievementRarity(currentAchievement.achievement.id)
)} text-white capitalize"
>
{getAchievementRarity(currentAchievement.achievement.id)}
</span>
</div>
</div>
</div>
<!-- Celebration confetti effect (CSS only) -->
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
{#each Array(10) as _ (_)}
<div
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
getAchievementRarity(currentAchievement.achievement.id)
)} rounded-full animate-fall"
style="left: {Math.random() * 100}%; animation-delay: {Math.random() *
2}s; animation-duration: {2 + Math.random() * 2}s;"
></div>
{/each}
</div>
</div>
</div>
</div>
{/if}
<style>
@keyframes fall {
0% {
transform: translateY(-20px) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(400px) rotate(720deg);
opacity: 0;
}
}
.animate-fall {
animation: fall linear infinite;
}
.animation-delay-200 {
animation-delay: 200ms;
}
.animation-delay-400 {
animation-delay: 400ms;
}
</style>
+266
View File
@@ -0,0 +1,266 @@
<script lang="ts">
import { slide } from "svelte/transition";
import { quintOut } from "svelte/easing";
import {
achievementsStore,
achievementProgress,
achievementCategories,
} from "$lib/stores/achievements";
import type { Achievement } from "$lib/types/achievements";
interface Props {
isOpen: boolean;
onClose?: () => void;
}
const { isOpen = $bindable(false), onClose }: Props = $props();
let selectedCategory = $state<string | null>(null);
const achievementsState = $derived($achievementsStore);
const progress = $derived($achievementProgress);
function getRarityColor(rarity: string): string {
switch (rarity) {
case "legendary":
return "text-yellow-500 dark:text-yellow-400";
case "epic":
return "text-purple-500 dark:text-purple-400";
case "rare":
return "text-blue-500 dark:text-blue-400";
default:
return "text-green-500 dark:text-green-400";
}
}
function getRarityBg(rarity: string): string {
switch (rarity) {
case "legendary":
return "bg-yellow-500/10";
case "epic":
return "bg-purple-500/10";
case "rare":
return "bg-blue-500/10";
default:
return "bg-green-500/10";
}
}
function formatDate(date: Date | undefined): string {
if (!date) return "";
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
function getAchievementsForCategory(categoryIds: string[]): Achievement[] {
return categoryIds
.map(
(id) => achievementsState.achievements[id as keyof typeof achievementsState.achievements]
)
.filter(Boolean);
}
</script>
<!-- Achievements panel -->
{#if isOpen}
<div
class="fixed inset-0 bg-black/50 z-40"
onclick={onClose}
onkeydown={(e) => e.key === "Escape" && onClose?.()}
role="button"
tabindex="-1"
aria-label="Close achievements panel"
transition:slide={{ duration: 300, easing: quintOut }}
></div>
<div
class="fixed left-0 top-0 h-full w-96 bg-[var(--bg-primary)] border-r border-[var(--border-color)]
shadow-2xl z-50 flex flex-col"
transition:slide={{ duration: 300, easing: quintOut, axis: "x" }}
>
<!-- Header -->
<div class="p-6 border-b border-[var(--border-color)]">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold text-[var(--text-primary)]">Achievements</h2>
<button
onclick={onClose}
onkeydown={(e) => e.key === "Enter" && onClose?.()}
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
aria-label="Close achievements panel"
>
<svg class="w-6 h-6" 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"
></path>
</svg>
</button>
</div>
<!-- Overall progress -->
<div class="mt-4">
<div
class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400 mb-2"
>
<span>{progress.unlocked} / {progress.total} Unlocked</span>
<span>{progress.percentage}%</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] h-2 rounded-full transition-all duration-500"
style="width: {progress.percentage}%"
></div>
</div>
</div>
</div>
<!-- Categories -->
<div class="flex-1 overflow-y-auto">
{#each achievementCategories as category (category.name)}
{@const achievements = getAchievementsForCategory(category.ids)}
{@const unlockedCount = achievements.filter((a) => a.unlocked).length}
<div class="border-b border-[var(--border-color)]">
<button
onclick={() =>
(selectedCategory = selectedCategory === category.name ? null : category.name)}
onkeydown={(e) =>
e.key === "Enter" &&
(selectedCategory = selectedCategory === category.name ? null : category.name)}
class="w-full p-4 text-left hover:bg-[var(--bg-secondary)] transition-colors"
>
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-[var(--text-primary)]">{category.name}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">{category.description}</p>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{unlockedCount} / {achievements.length}
</span>
<svg
class="w-5 h-5 transition-transform {selectedCategory === category.name
? 'rotate-180'
: ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
></path>
</svg>
</div>
</div>
</button>
{#if selectedCategory === category.name}
<div class="p-4 space-y-3" transition:slide={{ duration: 200, easing: quintOut }}>
{#each achievements as achievement (achievement.id)}
<div
class="p-3 rounded-lg border {achievement.unlocked
? 'border-[var(--border-color)] bg-[var(--bg-secondary)]'
: 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-800 opacity-50'}"
>
<div class="flex items-start gap-3">
<!-- Icon -->
<div class="text-3xl flex-shrink-0 {achievement.unlocked ? '' : 'grayscale'}">
{achievement.icon}
</div>
<!-- Details -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h4 class="font-semibold text-[var(--text-primary)]">
{achievement.name}
</h4>
<span
class="text-xs px-2 py-0.5 rounded-full {getRarityBg(
achievement.rarity
)} {getRarityColor(achievement.rarity)} capitalize"
>
{achievement.rarity}
</span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{achievement.description}
</p>
{#if achievement.unlocked && achievement.unlockedAt}
<p class="text-xs text-gray-500 dark:text-gray-500 mt-2">
Unlocked {formatDate(achievement.unlockedAt)}
</p>
{:else if achievement.maxProgress && achievement.progress !== undefined}
<!-- Progress bar for locked achievements -->
<div class="mt-2">
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
<span>Progress</span>
<span>{achievement.progress} / {achievement.maxProgress}</span>
</div>
<div class="w-full bg-gray-300 dark:bg-gray-700 rounded-full h-1.5">
<div
class="bg-gray-500 h-1.5 rounded-full transition-all duration-300"
style="width: {Math.min(
(achievement.progress / achievement.maxProgress) * 100,
100
)}%"
></div>
</div>
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
<!-- Footer with last unlocked -->
{#if achievementsState.lastUnlocked}
<div class="p-4 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Last Unlocked:</p>
<div class="flex items-center gap-2">
<span class="text-xl">{achievementsState.lastUnlocked.icon}</span>
<div>
<p class="font-semibold text-[var(--text-primary)]">
{achievementsState.lastUnlocked.name}
</p>
<p class="text-xs text-gray-500">
{formatDate(achievementsState.lastUnlocked.unlockedAt)}
</p>
</div>
</div>
</div>
{/if}
</div>
{/if}
<style>
/* Custom scrollbar for achievement list */
:global(.overflow-y-auto::-webkit-scrollbar) {
width: 8px;
}
:global(.overflow-y-auto::-webkit-scrollbar-track) {
background: var(--bg-secondary);
}
:global(.overflow-y-auto::-webkit-scrollbar-thumb) {
background: var(--border-color);
border-radius: 4px;
}
:global(.overflow-y-auto::-webkit-scrollbar-thumb:hover) {
background: var(--accent-primary);
}
</style>
+11 -7
View File
@@ -57,30 +57,34 @@
} }
</script> </script>
<div class="anime-girl-container flex flex-col items-center justify-end h-full p-4"> <div
<div class="character-frame relative {getBackgroundGlow()} w-full max-w-md"> class="anime-girl-container flex flex-col items-center justify-between h-full p-4 overflow-hidden"
<div class="sprite-container {getAnimationClass()}"> >
<div
class="character-frame relative {getBackgroundGlow()} flex-1 flex items-center justify-center min-h-0"
>
<div class="sprite-container {getAnimationClass()} h-full flex items-center justify-center">
<img <img
src="/sprites/{info.spriteFile}" src="/sprites/{info.spriteFile}"
alt="Hikari - {info.label}" alt="Hikari - {info.label}"
class="character-sprite w-full h-auto object-contain" class="character-sprite h-full w-auto max-w-full object-contain"
onerror={(e) => { onerror={(e) => {
const target = e.currentTarget as HTMLImageElement; const target = e.currentTarget as HTMLImageElement;
target.src = "/sprites/placeholder.svg"; target.src = "/sprites/placeholder.svg";
}} }}
/> />
</div> </div>
</div>
<div class="state-indicator absolute -bottom-2 left-1/2 transform -translate-x-1/2"> <div class="state-indicator mt-2">
<div <div
class="px-3 py-1 rounded-full text-xs font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[var(--accent-primary)]" class="px-3 py-1 rounded-full text-xs font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[var(--accent-primary)]"
> >
{info.label} {info.label}
</div> </div>
</div> </div>
</div>
<div class="speech-bubble mt-4 max-w-xs"> <div class="speech-bubble mt-2 max-w-xs flex-shrink-0">
<div <div
class="relative bg-[var(--bg-secondary)] rounded-lg px-4 py-2 border border-[var(--border-color)]" class="relative bg-[var(--bg-secondary)] rounded-lg px-4 py-2 border border-[var(--border-color)]"
> >
@@ -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>
+195 -20
View File
@@ -1,6 +1,15 @@
<script lang="ts"> <script lang="ts">
import { configStore, type HikariConfig, type Theme } from "$lib/stores/config"; import {
configStore,
type HikariConfig,
type Theme,
applyFontSize,
MIN_FONT_SIZE,
MAX_FONT_SIZE,
DEFAULT_FONT_SIZE,
} from "$lib/stores/config";
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { getCurrentWindow } from "@tauri-apps/api/window";
let config: HikariConfig = $state({ let config: HikariConfig = $state({
model: null, model: null,
@@ -11,6 +20,12 @@
theme: "dark", theme: "dark",
greeting_enabled: true, greeting_enabled: true,
greeting_custom_prompt: null, 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,
}); });
let isOpen = $state(false); let isOpen = $state(false);
@@ -59,6 +74,7 @@
saveError = null; saveError = null;
try { try {
await configStore.saveConfig(config); await configStore.saveConfig(config);
configStore.closeSidebar();
} catch { } catch {
// Error is handled by the store // Error is handled by the store
} finally { } finally {
@@ -93,6 +109,13 @@
function importFromSession() { function importFromSession() {
config.auto_granted_tools = [...new Set([...config.auto_granted_tools, ...grantedTools])]; config.auto_granted_tools = [...new Set([...config.auto_granted_tools, ...grantedTools])];
} }
async function handleAlwaysOnTopChange(enabled: boolean) {
config.always_on_top = enabled;
const window = getCurrentWindow();
await window.setAlwaysOnTop(enabled);
await configStore.updateConfig({ always_on_top: enabled });
}
</script> </script>
<!-- Backdrop --> <!-- Backdrop -->
@@ -119,7 +142,7 @@
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Settings</h2> <h2 class="text-lg font-semibold text-[var(--text-primary)]">Settings</h2>
<button <button
onclick={configStore.closeSidebar} onclick={configStore.closeSidebar}
class="p-1 text-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" aria-label="Close settings"
> >
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -147,7 +170,7 @@
<!-- Model Selection --> <!-- Model Selection -->
<div class="mb-4"> <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 <select
id="model" id="model"
bind:value={config.model} bind:value={config.model}
@@ -161,8 +184,8 @@
<!-- API Key --> <!-- API Key -->
<div class="mb-4"> <div class="mb-4">
<label for="api-key" class="block text-sm text-gray-400 mb-1"> <label for="api-key" class="block text-sm text-[var(--text-secondary)] mb-1">
API Key <span class="text-gray-600">(optional override)</span> API Key <span class="text-[var(--text-tertiary)]">(optional override)</span>
</label> </label>
<div class="relative"> <div class="relative">
<input <input
@@ -175,7 +198,7 @@
<button <button
type="button" type="button"
onclick={() => (showApiKey = !showApiKey)} onclick={() => (showApiKey = !showApiKey)}
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300" 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"} aria-label={showApiKey ? "Hide API key" : "Show API key"}
> >
{#if showApiKey} {#if showApiKey}
@@ -209,7 +232,7 @@
<!-- Custom Instructions --> <!-- Custom Instructions -->
<div class="mb-4"> <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 >Custom Instructions</label
> >
<textarea <textarea
@@ -236,9 +259,9 @@
bind:checked={config.greeting_enabled} bind:checked={config.greeting_enabled}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]" class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
/> />
<span class="text-sm text-gray-300">Send greeting on connect</span> <span class="text-sm text-[var(--text-primary)]">Send greeting on connect</span>
</label> </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 Automatically greet you when a session starts with time-based messages
</p> </p>
</div> </div>
@@ -246,8 +269,8 @@
<!-- Custom Greeting Prompt --> <!-- Custom Greeting Prompt -->
{#if config.greeting_enabled} {#if config.greeting_enabled}
<div class="mb-4"> <div class="mb-4">
<label for="greeting-prompt" class="block text-sm text-gray-400 mb-1"> <label for="greeting-prompt" class="block text-sm text-[var(--text-secondary)] mb-1">
Custom Greeting Prompt <span class="text-gray-600">(optional)</span> Custom Greeting Prompt <span class="text-[var(--text-tertiary)]">(optional)</span>
</label> </label>
<textarea <textarea
id="greeting-prompt" id="greeting-prompt"
@@ -266,8 +289,8 @@
MCP Servers MCP Servers
</h3> </h3>
<div class="mb-2"> <div class="mb-2">
<label for="mcp-config" class="block text-sm text-gray-400 mb-1"> <label for="mcp-config" class="block text-sm text-[var(--text-secondary)] mb-1">
Server Configuration <span class="text-gray-600">(JSON)</span> Server Configuration <span class="text-[var(--text-tertiary)]">(JSON)</span>
</label> </label>
<textarea <textarea
id="mcp-config" id="mcp-config"
@@ -284,14 +307,14 @@
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3"> <h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Auto-Granted Tools Auto-Granted Tools
</h3> </h3>
<p class="text-xs text-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). These tools will be pre-approved for every session (no permission prompts).
</p> </p>
<!-- Common tools checkboxes --> <!-- Common tools checkboxes -->
<div class="grid grid-cols-2 gap-2 mb-3"> <div class="grid grid-cols-2 gap-2 mb-3">
{#each commonTools as tool (tool)} {#each commonTools as tool (tool)}
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer"> <label class="flex items-center gap-2 text-sm text-[var(--text-primary)] cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={config.auto_granted_tools.includes(tool)} checked={config.auto_granted_tools.includes(tool)}
@@ -307,7 +330,7 @@
{#if grantedTools.length > 0} {#if grantedTools.length > 0}
<div class="mb-3"> <div class="mb-3">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-500">Session-granted tools:</span> <span class="text-xs text-[var(--text-tertiary)]">Session-granted tools:</span>
<button <button
onclick={importFromSession} onclick={importFromSession}
class="text-xs text-[var(--accent-primary)] hover:text-[var(--accent-secondary)] transition-colors" class="text-xs text-[var(--accent-primary)] hover:text-[var(--accent-secondary)] transition-colors"
@@ -330,7 +353,7 @@
<!-- Custom tools list --> <!-- Custom tools list -->
{#if config.auto_granted_tools.filter((t) => !commonTools.includes(t)).length > 0} {#if config.auto_granted_tools.filter((t) => !commonTools.includes(t)).length > 0}
<div class="mb-3"> <div class="mb-3">
<span class="text-xs text-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"> <div class="flex flex-wrap gap-1">
{#each config.auto_granted_tools.filter((t) => !commonTools.includes(t)) as tool (tool)} {#each config.auto_granted_tools.filter((t) => !commonTools.includes(t)) as tool (tool)}
<span <span
@@ -339,7 +362,7 @@
{tool} {tool}
<button <button
onclick={() => removeTool(tool)} onclick={() => removeTool(tool)}
class="text-gray-500 hover:text-red-400" class="text-[var(--text-tertiary)] hover:text-red-400"
aria-label="Remove {tool}" aria-label="Remove {tool}"
> >
× ×
@@ -374,12 +397,16 @@
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3"> <h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Appearance Appearance
</h3> </h3>
<!-- Theme Selection -->
<div class="mb-4">
<label class="block text-sm text-[var(--text-secondary)] mb-2">Theme</label>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
onclick={() => handleThemeChange("dark")} onclick={() => handleThemeChange("dark")}
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === '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(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-gray-400 hover:border-[var(--accent-primary)]'}" : 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
> >
Dark Dark
</button> </button>
@@ -387,11 +414,128 @@
onclick={() => handleThemeChange("light")} onclick={() => handleThemeChange("light")}
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === '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(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-gray-400 hover:border-[var(--accent-primary)]'}" : 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
> >
Light Light
</button> </button>
</div> </div>
</div>
<!-- Font Size -->
<div class="mb-4">
<label for="font-size" class="block text-sm text-[var(--text-secondary)] mb-2">
Terminal Font Size
</label>
<div class="flex items-center gap-3">
<input
id="font-size"
type="range"
bind:value={config.font_size}
oninput={() => applyFontSize(config.font_size)}
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
step="1"
class="flex-1 h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
/>
<span class="text-sm text-gray-300 w-12 text-right">{config.font_size}px</span>
<button
onclick={() => {
config.font_size = DEFAULT_FONT_SIZE;
applyFontSize(DEFAULT_FONT_SIZE);
}}
class="px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:border-[var(--accent-primary)] text-[var(--text-secondary)] transition-colors"
title="Reset to default (14px)"
>
Reset
</button>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">
Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset
</p>
</div>
</section>
<!-- Window Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Window
</h3>
<!-- Always on Top Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={config.always_on_top}
onchange={(e) => handleAlwaysOnTopChange(e.currentTarget.checked)}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Always on top</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Keep the window above other windows
</p>
</div>
<!-- Update Checks Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.update_checks_enabled}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Check for updates on startup</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Notify when a new version is available
</p>
</div>
</section>
<!-- Notifications Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Notifications
</h3>
<!-- Enable/Disable Notifications -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
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-[var(--text-primary)]">Enable sound notifications</span>
</label>
</div>
<!-- Volume Control -->
<div class="mb-4">
<label for="notification-volume" class="block text-sm text-[var(--text-secondary)] mb-2">
Notification Volume
</label>
<div class="flex items-center gap-3">
<input
id="notification-volume"
type="range"
bind:value={config.notification_volume}
min="0"
max="1"
step="0.1"
disabled={!config.notifications_enabled}
class="flex-1 h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer disabled:opacity-50"
/>
<span class="text-sm text-gray-300 w-12 text-right">
{Math.round(config.notification_volume * 100)}%
</span>
</div>
</div>
<div class="text-xs text-[var(--text-tertiary)]">
Sound notifications will play when I complete tasks, encounter errors, or need permissions.
</div>
</section> </section>
<!-- Save Button --> <!-- Save Button -->
@@ -406,3 +550,34 @@
</div> </div>
</div> </div>
</aside> </aside>
<style>
/* Custom range input styling */
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
background: var(--accent-primary);
border-radius: 50%;
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
background: var(--accent-primary);
border-radius: 50%;
cursor: pointer;
border: none;
}
input[type="range"]:disabled::-webkit-slider-thumb {
background: var(--text-tertiary);
cursor: not-allowed;
}
input[type="range"]:disabled::-moz-range-thumb {
background: var(--text-tertiary);
cursor: not-allowed;
}
</style>
+296
View File
@@ -0,0 +1,296 @@
<script lang="ts">
import { claudeStore } from "$lib/stores/claude";
import { onMount } from "svelte";
import type { Conversation } from "$lib/stores/conversations";
import { SvelteMap } from "svelte/reactivity";
import CloseTabConfirmModal from "./CloseTabConfirmModal.svelte";
// Use store subscriptions with $ syntax
const conversations = $derived(claudeStore.conversations);
const activeConversationId = $derived(claudeStore.activeConversationId);
let editingTabId = $state<string | null>(null);
let editingName = $state("");
// Track last seen message count for each conversation
let lastSeenMessageCount = new SvelteMap<string, number>();
// Confirmation modal state
let showConfirmModal = $state(false);
let tabToClose = $state<string | null>(null);
let tabToCloseName = $state("");
// Update last seen count when active conversation changes
$effect(() => {
if ($activeConversationId) {
const activeConv = $conversations.get($activeConversationId);
if (activeConv) {
lastSeenMessageCount.set($activeConversationId, activeConv.terminalLines.length);
// Trigger reactivity
lastSeenMessageCount = lastSeenMessageCount;
}
}
});
function createNewTab() {
claudeStore.createConversation();
}
async function switchTab(id: string) {
if (editingTabId) {
saveTabName();
}
await claudeStore.switchConversation(id);
// Mark messages as seen when switching to this tab
const conv = $conversations.get(id);
if (conv) {
lastSeenMessageCount.set(id, conv.terminalLines.length);
// Trigger reactivity
lastSeenMessageCount = lastSeenMessageCount;
}
}
function deleteTab(id: string, event: MouseEvent) {
event.stopPropagation();
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;
editingName = name;
// Focus input after DOM update
setTimeout(() => {
const input = document.querySelector('.tab-item input[type="text"]') as HTMLInputElement;
if (input) input.focus();
}, 0);
}
function saveTabName() {
if (editingTabId && editingName.trim()) {
claudeStore.renameConversation(editingTabId, editingName.trim());
}
editingTabId = null;
editingName = "";
}
function getConnectionStatusColor(status: Conversation["connectionStatus"]): string {
switch (status) {
case "connected":
return "bg-green-500";
case "connecting":
return "bg-yellow-500";
case "disconnected":
return "bg-red-500";
default:
return "bg-gray-500";
}
}
function hasUnreadMessages(id: string, conversation: Conversation): boolean {
if (id === $activeConversationId) return false; // Active tab never has unread
const lastSeen = lastSeenMessageCount.get(id) || 0;
return conversation.terminalLines.length > lastSeen;
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
saveTabName();
} else if (event.key === "Escape") {
editingTabId = null;
editingName = "";
} else if (event.key === " ") {
event.stopPropagation();
}
}
function handleTabKeydown(id: string, event: KeyboardEvent) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
switchTab(id);
}
}
// Keyboard shortcuts
onMount(() => {
function handleGlobalKeydown(event: KeyboardEvent) {
// Ctrl/Cmd + T: New tab
if ((event.ctrlKey || event.metaKey) && event.key === "t") {
event.preventDefault();
createNewTab();
}
// Ctrl/Cmd + W: Close current tab
else if ((event.ctrlKey || event.metaKey) && event.key === "w") {
event.preventDefault();
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);
if (currentIndex !== -1) {
const nextIndex = (currentIndex + 1) % tabs.length;
claudeStore.switchConversation(tabs[nextIndex]);
}
}
// 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);
if (currentIndex !== -1) {
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
claudeStore.switchConversation(tabs[prevIndex]);
}
}
}
window.addEventListener("keydown", handleGlobalKeydown);
return () => window.removeEventListener("keydown", handleGlobalKeydown);
});
</script>
<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)}
<div
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all
{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}
>
{#if editingTabId === id}
<input
type="text"
bind:value={editingName}
onblur={saveTabName}
onkeydown={handleKeydown}
onclick={(e) => e.stopPropagation()}
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32"
/>
{:else}
<div class="flex items-center gap-2">
<div
class="w-2 h-2 rounded-full {getConnectionStatusColor(conversation.connectionStatus)}"
title="Connection: {conversation.connectionStatus}"
></div>
<span
class="text-sm pr-2 max-w-[150px] truncate"
ondblclick={(e) => startEditing(id, conversation.name, e)}
role="button"
tabindex={-1}
>
{conversation.name}
</span>
{#if hasUnreadMessages(id, conversation)}
<div
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"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
{/each}
<button
onclick={createNewTab}
class="new-tab-btn flex items-center justify-center w-7 h-7 rounded hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)] transition-colors"
title="New conversation (Ctrl+T)"
>
<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="M12 4v16m8-8H4" />
</svg>
</button>
</div>
<CloseTabConfirmModal
isOpen={showConfirmModal}
tabName={tabToCloseName}
onConfirm={confirmCloseTab}
onCancel={cancelCloseTab}
/>
<style>
.terminal-tabs {
min-height: 36px;
}
.tab-item {
min-width: 100px;
}
</style>
@@ -0,0 +1,7 @@
<script lang="ts">
console.log("ConversationTabs component loading...");
</script>
<div class="terminal-tabs" style="background: red; height: 36px; color: white;">
Debug: Tabs Component Loaded
</div>
+150
View File
@@ -0,0 +1,150 @@
<script lang="ts">
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
const sections = [
{
title: "Getting Started",
items: [
"Enter your Claude API key in Settings (gear icon)",
"Set your working directory and click Connect",
"Start chatting with Hikari - your AI assistant!",
],
},
{
title: "Key Features",
items: [
"🗂️ File Management: Hikari can read, write, and edit files in your project",
"💻 Terminal Access: Execute commands and run scripts",
"🔍 Code Search: Find files and search through code",
"🌐 Web Access: Fetch information from the web",
"📊 MCP Servers: Connect to external tools via Model Context Protocol",
"📁 Multi-tab Support: Work on multiple conversations simultaneously",
],
},
{
title: "Available Commands",
items: [
"Type naturally - Hikari understands context!",
"Ask to read, create, or modify files",
"Request code explanations or debugging help",
"Have Hikari run tests or build commands",
"Search for specific functions or patterns",
],
},
{
title: "Tips & Tricks",
items: [
"💡 Use the stats panel to track your usage",
"🎯 Be specific about file paths and requirements",
"🔒 Grant tool permissions as needed for security",
"📌 Pin important conversations for quick access",
"🎨 Customize your theme and preferences in Settings",
"⌨️ Check the keyboard icon for available shortcuts",
],
},
];
</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="help-title"
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-[var(--text-primary)]">
How to Use Hikari Desktop
</h2>
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="overflow-y-auto flex-1 p-6 space-y-6">
{#each sections as section (section.title)}
<div>
<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>
<span>{item}</span>
</li>
{/each}
</ul>
</div>
{/each}
<div class="pt-4 border-t border-[var(--border-color)]">
<p class="text-sm text-[var(--text-tertiary)]">
<strong>Need more help?</strong> Join our Discord community for support and updates!
</p>
</div>
</div>
</div>
</div>
<style>
/* Ensure the panel appears above other content */
[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);
}
}
/* Custom scrollbar styling */
.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>
+61
View File
@@ -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>
+430 -15
View File
@@ -1,30 +1,231 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { claudeStore } from "$lib/stores/claude"; import { get } from "svelte/store";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character"; import { characterState } from "$lib/stores/character";
import { handleNewUserMessage } from "$lib/notifications/rules";
import { setSkipNextGreeting } from "$lib/tauri";
import {
setShouldRestoreHistory,
setSavedHistory,
getShouldRestoreHistory,
getSavedHistory,
clearHistoryRestore,
} from "$lib/stores/historyRestore";
import MessageModeSelector from "$lib/components/MessageModeSelector.svelte";
import SlashCommandMenu from "$lib/components/SlashCommandMenu.svelte";
import { getCurrentMode } from "$lib/stores/messageMode";
import { formatMessageWithMode } from "$lib/types/messageMode";
import {
parseSlashCommand,
getMatchingCommands,
isSlashCommand,
type SlashCommand,
} from "$lib/commands/slashCommands";
const INPUT_HISTORY_KEY = "hikari-input-history";
const MAX_HISTORY_SIZE = 100;
let inputValue = $state(""); let inputValue = $state("");
let isSubmitting = $state(false); let isSubmitting = $state(false);
let isConnected = $state(false); let isConnected = $state(false);
let isProcessing = $state(false);
let showCommandMenu = $state(false);
let matchingCommands = $state<SlashCommand[]>([]);
let selectedCommandIndex = $state(0);
// Input history state
let inputHistory = $state<string[]>([]);
let historyIndex = $state(-1);
let tempInput = $state("");
let userHasTyped = $state(false); // Track if user manually typed (vs history navigation)
// Textarea resize state
let textareaHeight = $state(48);
const MIN_HEIGHT = 48;
const MAX_HEIGHT = 200;
let isResizing = $state(false);
let startY = 0;
let startHeight = 0;
function handleResizeStart(event: MouseEvent) {
isResizing = true;
startY = event.clientY;
startHeight = textareaHeight;
document.addEventListener("mousemove", handleResizeMove);
document.addEventListener("mouseup", handleResizeEnd);
event.preventDefault();
}
function handleResizeMove(event: MouseEvent) {
if (!isResizing) return;
// Dragging up (negative deltaY) should increase height
const deltaY = startY - event.clientY;
const newHeight = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, startHeight + deltaY));
textareaHeight = newHeight;
}
function handleResizeEnd() {
isResizing = false;
document.removeEventListener("mousemove", handleResizeMove);
document.removeEventListener("mouseup", handleResizeEnd);
}
// Load history from localStorage on init
function loadHistory(): string[] {
try {
const stored = localStorage.getItem(INPUT_HISTORY_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
function saveHistory(history: string[]) {
try {
localStorage.setItem(INPUT_HISTORY_KEY, JSON.stringify(history));
} catch {
// Ignore storage errors
}
}
function addToHistory(input: string) {
const trimmed = input.trim();
if (!trimmed) return;
// Don't add duplicates of the most recent entry
if (inputHistory.length > 0 && inputHistory[0] === trimmed) return;
// Add to front of history
inputHistory = [trimmed, ...inputHistory.slice(0, MAX_HISTORY_SIZE - 1)];
saveHistory(inputHistory);
}
// Initialize history on mount
inputHistory = loadHistory();
claudeStore.connectionStatus.subscribe((status) => { claudeStore.connectionStatus.subscribe((status) => {
isConnected = status === "connected"; isConnected = status === "connected";
}); });
isClaudeProcessing.subscribe((processing) => {
isProcessing = processing;
});
function handleInputChange() {
// If input is empty, allow history navigation again
// Otherwise, mark that user has manually typed
if (inputValue === "") {
userHasTyped = false;
} else {
userHasTyped = true;
}
// Reset history navigation when user types
historyIndex = -1;
tempInput = "";
if (isSlashCommand(inputValue)) {
matchingCommands = getMatchingCommands(inputValue);
showCommandMenu = matchingCommands.length > 0;
selectedCommandIndex = 0;
} else {
showCommandMenu = false;
matchingCommands = [];
}
}
function selectCommand(command: SlashCommand) {
inputValue = `/${command.name} `;
showCommandMenu = false;
matchingCommands = [];
}
async function executeSlashCommand(): Promise<boolean> {
const { command, args } = parseSlashCommand(inputValue);
if (command) {
inputValue = "";
showCommandMenu = false;
matchingCommands = [];
await command.execute(args);
return true;
}
return false;
}
async function handleSubmit(event: Event) { async function handleSubmit(event: Event) {
event.preventDefault(); event.preventDefault();
const message = inputValue.trim(); const message = inputValue.trim();
if (!message || isSubmitting || !isConnected) return; if (!message || isSubmitting) return;
// Check for slash commands first (these work even when disconnected)
if (isSlashCommand(message)) {
// Add slash commands to history too
addToHistory(message);
historyIndex = -1;
tempInput = "";
userHasTyped = false;
const wasCommand = await executeSlashCommand();
if (wasCommand) return;
// If it started with / but wasn't a valid command, show error
claudeStore.addLine(
"error",
`Unknown command: ${message.split(" ")[0]}. Type /help for available commands.`
);
inputValue = "";
return;
}
// Regular messages require connection
if (!isConnected) return;
// Add to history before clearing
addToHistory(message);
historyIndex = -1;
tempInput = "";
userHasTyped = false;
isSubmitting = true; isSubmitting = true;
inputValue = ""; inputValue = "";
claudeStore.addLine("user", message); // Apply mode prefix if needed
const currentMode = getCurrentMode();
const formattedMessage = formatMessageWithMode(message, currentMode);
// Check if we need to restore conversation history
let messageToSend = formattedMessage;
if (getShouldRestoreHistory()) {
const savedHistory = getSavedHistory();
if (savedHistory) {
// Prepend the conversation history with a context message
messageToSend = `[Previous conversation context:]
${savedHistory}
[Continuing conversation after reconnection:]
User: ${formattedMessage}`;
// Clear the restoration flags
clearHistoryRestore();
}
}
// Reset notification state for new user message
handleNewUserMessage();
claudeStore.addLine("user", formattedMessage);
characterState.setState("thinking"); characterState.setState("thinking");
try { try {
await invoke("send_prompt", { message }); const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("send_prompt", {
conversationId,
message: messageToSend,
});
} catch (error) { } catch (error) {
console.error("Failed to send prompt:", error); console.error("Failed to send prompt:", error);
claudeStore.addLine("error", `Failed to send: ${error}`); claudeStore.addLine("error", `Failed to send: ${error}`);
@@ -34,36 +235,170 @@
} }
} }
async function handleInterrupt() {
// Save the conversation history FIRST before anything else
const history = claudeStore.getConversationHistory();
if (history) {
setSavedHistory(history);
setShouldRestoreHistory(true);
}
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted - reconnecting...");
characterState.setState("idle");
// Show connecting status while we reconnect
claudeStore.setConnectionStatus("connecting");
// Auto-reconnect after a brief delay
setTimeout(async () => {
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
// Get current working directory before reconnecting
const workingDir = await invoke<string>("get_working_directory", { conversationId });
// Set the flag to skip greeting on next connection
setSkipNextGreeting(true);
// Reconnect to Claude
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDir,
},
});
} catch (reconnectError) {
console.error("Failed to auto-reconnect:", reconnectError);
claudeStore.addLine("error", `Failed to reconnect: ${reconnectError}`);
claudeStore.addLine("system", "Please manually reconnect to continue");
}
}, 500); // Brief delay to ensure process is fully terminated
} catch (error) {
console.error("Failed to interrupt:", error);
claudeStore.addLine("error", `Failed to interrupt: ${error}`);
}
}
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
// Handle command menu navigation
if (showCommandMenu && matchingCommands.length > 0) {
if (event.key === "ArrowDown") {
event.preventDefault();
selectedCommandIndex = (selectedCommandIndex + 1) % matchingCommands.length;
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
selectedCommandIndex =
(selectedCommandIndex - 1 + matchingCommands.length) % matchingCommands.length;
return;
}
if (event.key === "Tab") {
event.preventDefault();
const selected = matchingCommands[selectedCommandIndex];
if (selected) {
selectCommand(selected);
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
showCommandMenu = false;
return;
}
}
// Handle input history navigation (when command menu is closed AND user hasn't typed)
// If user has typed something, let arrow keys navigate the cursor instead
if (event.key === "ArrowUp" && inputHistory.length > 0 && !userHasTyped) {
event.preventDefault();
if (historyIndex === -1) {
// Save current input before navigating history
tempInput = inputValue;
}
if (historyIndex < inputHistory.length - 1) {
historyIndex++;
inputValue = inputHistory[historyIndex];
}
return;
}
if (event.key === "ArrowDown" && historyIndex >= 0 && !userHasTyped) {
event.preventDefault();
historyIndex--;
if (historyIndex === -1) {
// Restore the temp input when going back to current
inputValue = tempInput;
userHasTyped = false; // Reset since we're back to empty/temp state
} else {
inputValue = inputHistory[historyIndex];
}
return;
}
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
handleSubmit(event); handleSubmit(event);
} }
} }
</script> </script>
<form onsubmit={handleSubmit} class="input-bar flex gap-3 items-end"> <form onsubmit={handleSubmit} class="input-bar">
<div class="flex-1 relative"> <div class="input-controls flex gap-2 mb-2">
<MessageModeSelector />
</div>
<div class="input-row">
<div class="textarea-wrapper">
<SlashCommandMenu
commands={matchingCommands}
selectedIndex={selectedCommandIndex}
onSelect={selectCommand}
/>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="resize-handle" onmousedown={handleResizeStart} title="Drag to resize"></div>
<textarea <textarea
bind:value={inputValue} bind:value={inputValue}
onkeydown={handleKeyDown} onkeydown={handleKeyDown}
placeholder={isConnected ? "Ask Hikari anything..." : "Connect to Claude first..."} oninput={handleInputChange}
disabled={!isConnected || isSubmitting} placeholder={isConnected
? "Ask Hikari anything... (type / for commands)"
: "Connect to Claude first..."}
disabled={isSubmitting}
rows={1} rows={1}
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px);"
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
rounded-lg text-white placeholder-gray-500 resize-none rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]
disabled:opacity-50 disabled:cursor-not-allowed disabled:opacity-50 disabled:cursor-not-allowed"
transition-all duration-200"
></textarea> ></textarea>
</div> </div>
<div class="button-wrapper">
{#if isProcessing}
<button
type="button"
onclick={handleInterrupt}
class="send-button bg-red-600 hover:bg-red-700"
title="Interrupt the current response (Ctrl+C)"
>
<span class="font-bold"></span> Stop
</button>
{:else}
<button <button
type="submit" type="submit"
disabled={!isConnected || isSubmitting || !inputValue.trim()} disabled={!isConnected || isSubmitting || !inputValue.trim()}
class="px-6 py-3 bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)] class="send-button bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)]
text-white font-medium rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200 transform hover:scale-105 active:scale-95"
> >
{#if isSubmitting} {#if isSubmitting}
<span class="inline-block animate-spin"></span> <span class="inline-block animate-spin"></span>
@@ -71,4 +406,84 @@
Send Send
{/if} {/if}
</button> </button>
{/if}
</div>
</div>
</form> </form>
<style>
.input-bar {
display: flex;
flex-direction: column;
gap: 8px;
}
.input-controls {
display: flex;
align-items: center;
gap: 8px;
}
.input-row {
display: flex;
gap: 12px;
align-items: flex-end;
}
.textarea-wrapper {
flex: 1;
position: relative;
display: flex;
flex-direction: column;
}
.resize-handle {
height: 6px;
cursor: ns-resize;
background: transparent;
border-radius: 3px;
margin-bottom: 2px;
display: flex;
justify-content: center;
align-items: center;
}
.resize-handle::before {
content: "";
width: 40px;
height: 3px;
background: var(--border-color);
border-radius: 2px;
opacity: 0.5;
transition: opacity 0.2s;
}
.resize-handle:hover::before {
opacity: 1;
background: var(--accent-primary);
}
.button-wrapper {
display: flex;
align-items: flex-end;
height: 100%;
}
.send-button {
padding: 0 24px;
height: 48px;
color: white;
font-weight: 500;
border-radius: 8px;
transition: all 0.2s;
white-space: nowrap;
}
.send-button:hover:not(:disabled) {
transform: scale(1.05);
}
.send-button:active:not(:disabled) {
transform: scale(0.95);
}
</style>
@@ -0,0 +1,177 @@
<script lang="ts">
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
const shortcuts = [
{
category: "General",
items: [
{ keys: ["Escape"], description: "Close modals and panels" },
{ keys: ["Ctrl", "L"], description: "Clear the terminal" },
{ keys: ["Ctrl", ","], description: "Open settings" },
],
},
{
category: "Chat",
items: [
{ keys: ["Enter"], description: "Send message" },
{ keys: ["Shift", "Enter"], description: "New line in message" },
{ keys: ["Ctrl", "C"], description: "Interrupt/stop response" },
{ keys: ["↑"], description: "Previous input from history" },
{ keys: ["↓"], description: "Next input from history" },
],
},
{
category: "Slash Commands",
items: [
{ keys: ["↑", "↓"], description: "Navigate command menu" },
{ keys: ["Tab"], description: "Complete selected command" },
{ keys: ["Escape"], description: "Close command menu" },
],
},
{
category: "Permission Prompts",
items: [
{ keys: ["Enter"], description: "Allow & reconnect" },
{ keys: ["Escape"], description: "Dismiss" },
],
},
];
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] overflow-hidden flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="shortcuts-title"
tabindex="-1"
>
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-lg bg-[var(--accent-primary)]/20 flex items-center justify-center"
>
<svg
class="w-5 h-5 text-[var(--accent-primary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3C10.22 3 8.47 3.23 6.86 3.68A2 2 0 005 5.57V18.43a2 2 0 001.86 1.89C8.47 20.77 10.22 21 12 21s3.53-.23 5.14-.68A2 2 0 0019 18.43V5.57a2 2 0 00-1.86-1.89C15.53 3.23 13.78 3 12 3z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7h.01M12 7h.01M16 7h.01M8 11h.01M12 11h.01M16 11h.01M8 15h8"
/>
</svg>
</div>
<h2 id="shortcuts-title" class="text-xl font-semibold text-[var(--text-primary)]">
Keyboard Shortcuts
</h2>
</div>
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="overflow-y-auto flex-1 p-6 space-y-6">
{#each shortcuts as section (section.category)}
<div>
<h3
class="text-sm font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-3"
>
{section.category}
</h3>
<div class="space-y-2">
{#each section.items as item (item.description)}
<div
class="flex items-center justify-between py-2 px-3 bg-[var(--bg-secondary)] rounded-lg"
>
<span class="text-sm text-[var(--text-primary)]">{item.description}</span>
<div class="flex items-center gap-1">
{#each item.keys as key, i (key)}
{#if i > 0}
<span class="text-[var(--text-secondary)] text-xs">+</span>
{/if}
<kbd
class="px-2 py-1 text-xs font-mono bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] shadow-sm min-w-[24px] text-center"
>
{key}
</kbd>
{/each}
</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.overflow-y-auto {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.overflow-y-auto::-webkit-scrollbar {
width: 8px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 4px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background-color: var(--accent-primary);
}
</style>
+352
View File
@@ -0,0 +1,352 @@
<script lang="ts">
import { marked } from "marked";
import hljs from "highlight.js";
import { onMount } from "svelte";
import { openUrl } from "@tauri-apps/plugin-opener";
interface Props {
content: string;
searchQuery?: string;
}
let { content, searchQuery = "" }: Props = $props();
let containerElement: HTMLDivElement;
const renderer = new marked.Renderer();
renderer.code = ({ text, lang }) => {
const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
const highlighted = hljs.highlight(text, { language }).value;
return `<pre class="hljs-code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`;
};
renderer.codespan = ({ text }) => {
return `<code class="hljs-inline">${text}</code>`;
};
marked.setOptions({
renderer,
gfm: true,
breaks: true,
});
function processSpoilers(html: string): string {
const codeBlockPlaceholders: string[] = [];
// Temporarily replace code blocks and inline code with placeholders
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
codeBlockPlaceholders.push(match);
return `__CODE_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
});
// Apply spoiler transformation only to non-code content
processed = processed.replace(
/\|\|(.+?)\|\|/g,
'<span class="spoiler" role="button" tabindex="0">$1</span>'
);
// Restore code blocks
processed = processed.replace(/__CODE_PLACEHOLDER_(\d+)__/g, (_, index) => {
return codeBlockPlaceholders[parseInt(index)];
});
return processed;
}
function highlightSearchMatches(html: string, query: string): string {
if (!query) return html;
const codeBlockPlaceholders: string[] = [];
const tagPlaceholders: string[] = [];
// Temporarily replace code blocks with placeholders (don't highlight in code)
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
codeBlockPlaceholders.push(match);
return `__CODE_SEARCH_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
});
// Temporarily replace all HTML tags with placeholders
processed = processed.replace(/<[^>]+>/g, (match) => {
tagPlaceholders.push(match);
return `__TAG_PLACEHOLDER_${tagPlaceholders.length - 1}__`;
});
// Apply search highlighting to text content
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedQuery})`, "gi");
processed = processed.replace(regex, '<mark class="search-highlight">$1</mark>');
// Restore HTML tags
processed = processed.replace(/__TAG_PLACEHOLDER_(\d+)__/g, (_, index) => {
return tagPlaceholders[parseInt(index)];
});
// Restore code blocks
processed = processed.replace(/__CODE_SEARCH_PLACEHOLDER_(\d+)__/g, (_, index) => {
return codeBlockPlaceholders[parseInt(index)];
});
return processed;
}
function renderMarkdown(text: string): string {
try {
const html = marked.parse(text) as string;
const withSpoilers = processSpoilers(html);
return highlightSearchMatches(withSpoilers, searchQuery);
} catch {
return text;
}
}
function handleSpoilerClick(event: Event) {
const target = event.target as HTMLElement;
if (target.classList.contains("spoiler")) {
target.classList.toggle("revealed");
}
}
function handleSpoilerKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target.classList.contains("spoiler") && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
target.classList.toggle("revealed");
}
}
function handleLinkClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const anchor = target.closest("a");
if (anchor?.href) {
event.preventDefault();
openUrl(anchor.href);
}
}
onMount(() => {
if (containerElement) {
containerElement.querySelectorAll("pre code:not(.hljs)").forEach((block) => {
hljs.highlightElement(block as HTMLElement);
});
}
});
</script>
<div
bind:this={containerElement}
class="markdown-content"
onclick={(e) => {
handleSpoilerClick(e);
handleLinkClick(e);
}}
onkeydown={handleSpoilerKeydown}
role="presentation"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown rendering requires @html; content is from Claude API -->
{@html renderMarkdown(content)}
</div>
<style>
.markdown-content {
line-height: 1.6;
}
.markdown-content :global(p) {
margin: 0.5em 0;
}
.markdown-content :global(p:first-child) {
margin-top: 0;
}
.markdown-content :global(p:last-child) {
margin-bottom: 0;
}
.markdown-content :global(.hljs-code-block) {
background: var(--bg-code, #1e1e2e);
border-radius: 6px;
padding: 1em;
margin: 0.75em 0;
overflow-x: auto;
border: 1px solid var(--border-color);
}
.markdown-content :global(.hljs-code-block code) {
background: transparent;
padding: 0;
font-size: 0.9em;
font-family: "JetBrains Mono", "Fira Code", monospace;
}
.markdown-content :global(.hljs-inline) {
background: var(--bg-code, #1e1e2e);
padding: 0.2em 0.4em;
border-radius: 4px;
font-size: 0.9em;
font-family: "JetBrains Mono", "Fira Code", monospace;
}
.markdown-content :global(ul),
.markdown-content :global(ol) {
margin: 0.5em 0;
padding-left: 1.5em;
}
.markdown-content :global(li) {
margin: 0.25em 0;
}
.markdown-content :global(blockquote) {
border-left: 3px solid var(--border-color);
margin: 0.75em 0;
padding-left: 1em;
color: var(--text-secondary);
}
.markdown-content :global(a) {
color: var(--accent-primary, #f472b6);
text-decoration: underline;
}
.markdown-content :global(a:hover) {
color: var(--accent-secondary, #e879f9);
}
.markdown-content :global(strong) {
font-weight: 600;
}
.markdown-content :global(h1),
.markdown-content :global(h2),
.markdown-content :global(h3),
.markdown-content :global(h4) {
margin: 1em 0 0.5em 0;
font-weight: 600;
}
.markdown-content :global(h1:first-child),
.markdown-content :global(h2:first-child),
.markdown-content :global(h3:first-child),
.markdown-content :global(h4:first-child) {
margin-top: 0;
}
.markdown-content :global(hr) {
border: none;
border-top: 1px solid var(--border-color);
margin: 1em 0;
}
.markdown-content :global(table) {
border-collapse: collapse;
margin: 0.75em 0;
width: 100%;
}
.markdown-content :global(th),
.markdown-content :global(td) {
border: 1px solid var(--border-color);
padding: 0.5em;
text-align: left;
}
.markdown-content :global(th) {
background: var(--bg-secondary);
font-weight: 600;
}
/* Highlight.js theme colors - using CSS variables for light/dark mode support */
.markdown-content :global(.hljs) {
color: var(--text-primary);
}
.markdown-content :global(.hljs-keyword),
.markdown-content :global(.hljs-selector-tag),
.markdown-content :global(.hljs-built_in),
.markdown-content :global(.hljs-name) {
color: var(--hljs-keyword);
}
.markdown-content :global(.hljs-string),
.markdown-content :global(.hljs-attr),
.markdown-content :global(.hljs-symbol),
.markdown-content :global(.hljs-bullet) {
color: var(--hljs-string);
}
.markdown-content :global(.hljs-number),
.markdown-content :global(.hljs-literal) {
color: var(--hljs-number);
}
.markdown-content :global(.hljs-comment),
.markdown-content :global(.hljs-quote) {
color: var(--hljs-comment);
font-style: italic;
}
.markdown-content :global(.hljs-function),
.markdown-content :global(.hljs-title) {
color: var(--hljs-function);
}
.markdown-content :global(.hljs-type),
.markdown-content :global(.hljs-class) {
color: var(--hljs-type);
}
.markdown-content :global(.hljs-variable),
.markdown-content :global(.hljs-template-variable) {
color: var(--hljs-variable);
}
.markdown-content :global(.hljs-meta) {
color: var(--hljs-meta);
}
.markdown-content :global(.hljs-tag) {
color: var(--hljs-keyword);
}
.markdown-content :global(.hljs-attribute) {
color: var(--hljs-function);
}
.markdown-content :global(.hljs-params) {
color: var(--text-primary);
}
/* Spoiler tag styles */
.markdown-content :global(.spoiler) {
background: var(--text-primary);
color: transparent;
border-radius: 4px;
padding: 0 0.25em;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.markdown-content :global(.spoiler:hover) {
opacity: 0.8;
}
.markdown-content :global(.spoiler:focus) {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.markdown-content :global(.spoiler.revealed) {
background: var(--bg-hover);
color: var(--text-primary);
user-select: text;
}
.markdown-content :global(.search-highlight) {
background-color: var(--search-highlight, #fbbf24);
color: var(--search-highlight-text, #000);
border-radius: 2px;
padding: 0 2px;
}
</style>
@@ -0,0 +1,157 @@
<script lang="ts">
import { MESSAGE_MODES, type MessageMode } from "$lib/types/messageMode";
import { messageMode } from "$lib/stores/messageMode";
let currentMode = $state("chat");
let isOpen = $state(false);
messageMode.subscribe((mode) => {
currentMode = mode;
});
let selectedMode = $derived(MESSAGE_MODES.find((m) => m.id === currentMode) || MESSAGE_MODES[0]);
function selectMode(mode: MessageMode) {
messageMode.set(mode.id);
isOpen = false;
}
function toggleDropdown(event: MouseEvent) {
event.stopPropagation();
isOpen = !isOpen;
}
// Close dropdown when clicking outside
function handleClickOutside() {
if (isOpen) {
isOpen = false;
}
}
</script>
<svelte:window onclick={handleClickOutside} />
<div class="mode-selector-container">
<button
class="mode-selector-button"
onclick={toggleDropdown}
title={`Current mode: ${selectedMode.name} - ${selectedMode.description}`}
>
<span class="mode-icon">{selectedMode.icon}</span>
<span class="mode-name">{selectedMode.name}</span>
<svg class="dropdown-arrow" class:open={isOpen} width="12" height="12" viewBox="0 0 12 12">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" fill="none" />
</svg>
</button>
{#if isOpen}
<div class="dropdown-menu">
{#each MESSAGE_MODES as mode (mode.id)}
<button
class="dropdown-item"
class:active={mode.id === currentMode}
onclick={() => selectMode(mode)}
>
<span class="mode-icon">{mode.icon}</span>
<div class="mode-info">
<div class="mode-name">{mode.name}</div>
<div class="mode-description">{mode.description}</div>
</div>
</button>
{/each}
</div>
{/if}
</div>
<style>
.mode-selector-container {
position: relative;
}
.mode-selector-button {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.mode-selector-button:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.mode-icon {
font-size: 16px;
}
.mode-name {
font-weight: 500;
}
.dropdown-arrow {
margin-left: 4px;
transition: transform 0.2s;
}
.dropdown-arrow.open {
transform: rotate(180deg);
}
.dropdown-menu {
position: absolute;
bottom: calc(100% + 4px);
left: 0;
min-width: 280px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
overflow: hidden;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px 16px;
background: transparent;
border: none;
color: var(--text-primary);
cursor: pointer;
transition: background 0.2s;
text-align: left;
}
.dropdown-item:hover {
background: var(--bg-hover);
}
.dropdown-item.active {
background: var(--accent-primary);
color: white;
}
.mode-info {
flex: 1;
}
.dropdown-item .mode-name {
font-weight: 500;
font-size: 14px;
margin-bottom: 2px;
}
.mode-description {
font-size: 12px;
opacity: 0.7;
}
</style>
@@ -0,0 +1,153 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { notificationManager } from "$lib/notifications/notificationManager";
let results: { method: string; success: boolean; error?: string }[] = [];
let testing = false;
async function testNotificationMethod(method: string, invokeCommand: string) {
try {
await invoke(invokeCommand, {
title: "Hikari Test",
body: `Testing ${method} notification method`,
});
return { method, success: true };
} catch (error) {
return { method, success: false, error: String(error) };
}
}
async function testAllMethods() {
testing = true;
results = [];
const methods = [
{ name: "WSL Toast (System Tray)", command: "send_wsl_notification" },
{ name: "VBScript (Popup Dialog)", command: "send_vbs_notification" },
{ name: "Notify-send (Linux)", command: "send_notify_send" },
{ name: "Windows PowerShell", command: "send_windows_notification" },
{ name: "Simple Message (Dialog)", command: "send_simple_notification" },
];
for (const method of methods) {
const result = await testNotificationMethod(method.name, method.command);
results = [...results, result];
// Wait a bit between tests
await new Promise((resolve) => setTimeout(resolve, 500));
}
testing = false;
}
async function testIntegratedNotification() {
await notificationManager.notifySuccess("Integrated notification test!");
}
</script>
<div class="notification-debugger">
<h3>Notification Method Debugger</h3>
<div class="test-buttons">
<button on:click={testAllMethods} disabled={testing}>
{testing ? "Testing..." : "Test All Methods"}
</button>
<button on:click={testIntegratedNotification}> Test Integrated Notification </button>
</div>
{#if results.length > 0}
<div class="results">
<h4>Test Results:</h4>
{#each results as result (result.method)}
<div class="result" class:success={result.success} class:failed={!result.success}>
<span class="method">{result.method}:</span>
<span class="status">{result.success ? "✓ Success" : "✗ Failed"}</span>
{#if result.error}
<div class="error">{result.error}</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<style>
.notification-debugger {
padding: 1rem;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
margin: 1rem;
background: var(--bg-secondary);
}
h3,
h4 {
margin-top: 0;
color: var(--text-primary);
}
.test-buttons {
display: flex;
gap: 1rem;
margin: 1rem 0;
}
button {
padding: 0.5rem 1rem;
background: var(--accent-color);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
transition: opacity 0.2s;
}
button:hover:not(:disabled) {
opacity: 0.8;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.results {
margin-top: 1rem;
}
.result {
padding: 0.5rem;
margin: 0.25rem 0;
border-radius: 0.25rem;
display: flex;
align-items: center;
gap: 1rem;
}
.result.success {
background: rgba(0, 255, 0, 0.1);
border: 1px solid rgba(0, 255, 0, 0.3);
}
.result.failed {
background: rgba(255, 0, 0, 0.1);
border: 1px solid rgba(255, 0, 0, 0.3);
}
.method {
font-weight: bold;
min-width: 150px;
}
.status {
flex: 1;
}
.error {
width: 100%;
margin-top: 0.25rem;
font-size: 0.875rem;
color: var(--error-color);
word-break: break-word;
}
</style>
+38 -12
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { claudeStore, hasPermissionPending } from "$lib/stores/claude"; import { claudeStore, hasPermissionPending } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character"; import { characterState } from "$lib/stores/character";
import type { PermissionRequest } from "$lib/types/messages"; import type { PermissionRequest } from "$lib/types/messages";
@@ -45,14 +46,22 @@
// Stop current session and reconnect with new permissions // Stop current session and reconnect with new permissions
try { try {
await invoke("stop_claude"); const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("stop_claude", { conversationId });
// Small delay to ensure clean shutdown // Small delay to ensure clean shutdown
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
await invoke("start_claude", { await invoke("start_claude", {
workingDir: workingDirectory || "/home/naomi", conversationId,
allowedTools: newGrantedTools, options: {
working_dir: workingDirectory || "/home/naomi",
allowed_tools: newGrantedTools,
},
}); });
// Wait for connection to establish // Wait for connection to establish
@@ -70,7 +79,10 @@ ${JSON.stringify(toolInput, null, 2)}
Please continue where we left off and retry that action now that you have permission.`; Please continue where we left off and retry that action now that you have permission.`;
await invoke("send_prompt", { message: contextMessage }); await invoke("send_prompt", {
conversationId,
message: contextMessage,
});
} }
} catch (error) { } catch (error) {
console.error("Failed to reconnect:", error); console.error("Failed to reconnect:", error);
@@ -97,8 +109,22 @@ Please continue where we left off and retry that action now that you have permis
function isToolAlreadyGranted(toolName: string): boolean { function isToolAlreadyGranted(toolName: string): boolean {
return grantedToolsList.includes(toolName); return grantedToolsList.includes(toolName);
} }
function handleKeydown(event: KeyboardEvent) {
if (!isVisible || !permission) return;
if (event.key === "Enter") {
event.preventDefault();
handleApproveAndReconnect();
} else if (event.key === "Escape") {
event.preventDefault();
handleDismiss();
}
}
</script> </script>
<svelte:window onkeydown={handleKeydown} />
{#if isVisible && permission} {#if isVisible && permission}
<div <div
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm" class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
@@ -111,8 +137,8 @@ Please continue where we left off and retry that action now that you have permis
<span class="text-xl">🔐</span> <span class="text-xl">🔐</span>
</div> </div>
<div> <div>
<h2 class="text-lg font-semibold text-white">Permission Blocked</h2> <h2 class="text-lg font-semibold text-[var(--text-primary)]">Permission Blocked</h2>
<p class="text-sm text-gray-400">Hikari tried to use a restricted tool</p> <p class="text-sm text-[var(--text-secondary)]">Hikari tried to use a restricted tool</p>
</div> </div>
</div> </div>
@@ -123,7 +149,7 @@ Please continue where we left off and retry that action now that you have permis
</div> </div>
<div class="mb-4"> <div class="mb-4">
<div class="text-sm text-gray-400 mb-1">Tool</div> <div class="text-sm text-[var(--text-secondary)] mb-1">Tool</div>
<div <div
class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--accent-primary)] font-mono flex items-center justify-between" class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--accent-primary)] font-mono flex items-center justify-between"
> >
@@ -137,17 +163,17 @@ Please continue where we left off and retry that action now that you have permis
</div> </div>
<div class="mb-4"> <div class="mb-4">
<div class="text-sm text-gray-400 mb-1">Description</div> <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-gray-300"> <div class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--text-primary)]">
{permission.description} {permission.description}
</div> </div>
</div> </div>
{#if Object.keys(permission.input).length > 0} {#if Object.keys(permission.input).length > 0}
<div class="mb-6"> <div class="mb-6">
<div class="text-sm text-gray-400 mb-1">Details</div> <div class="text-sm text-[var(--text-secondary)] mb-1">Details</div>
<pre <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 permission.input
)}</pre> )}</pre>
</div> </div>
@@ -156,7 +182,7 @@ Please continue where we left off and retry that action now that you have permis
<div class="flex gap-3"> <div class="flex gap-3">
<button <button
onclick={handleDismiss} onclick={handleDismiss}
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-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 Dismiss
</button> </button>
@@ -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>
+168
View File
@@ -0,0 +1,168 @@
<script lang="ts">
import { formattedStats } from "$lib/stores/stats";
import { fade } from "svelte/transition";
let showToolsBreakdown = false;
</script>
<div class="stats-display" transition:fade={{ duration: 200 }}>
<div class="stats-row">
<span class="stat-label">Duration:</span>
<span class="stat-value">{$formattedStats.sessionDuration}</span>
</div>
<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">
<h3>Tokens & Cost</h3>
<div class="stat-row">
<span class="stat-label">Session:</span>
<span class="stat-value">{$formattedStats.sessionTokens}</span>
<span class="stat-cost">{$formattedStats.sessionCost}</span>
</div>
<div class="stat-row stat-detail">
<span class="stat-label">Input:</span>
<span class="stat-value">{$formattedStats.sessionInputTokens}</span>
</div>
<div class="stat-row stat-detail">
<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">
<h3>Activity</h3>
<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>
{#if Object.keys($formattedStats.sessionToolsUsage).length > 0}
<div class="stats-section">
<h3 class="tools-header">
<button class="tools-toggle" onclick={() => (showToolsBreakdown = !showToolsBreakdown)}>
Tools Used
<span class="toggle-icon">{showToolsBreakdown ? "▼" : "▶"}</span>
</button>
</h3>
{#if showToolsBreakdown}
<div class="tools-breakdown">
{#each Object.entries($formattedStats.sessionToolsUsage).sort((a, b) => b[1] - a[1]) as [tool, count] (tool)}
<div class="stat-row stat-detail">
<span class="stat-label">{tool}:</span>
<span class="stat-value">{count}</span>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<div class="model-info">
<span class="model-label">Model:</span>
<span class="model-value">{$formattedStats.model}</span>
</div>
</div>
<style>
.stats-display {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.75rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 0.85rem;
box-shadow:
0 4px 6px rgba(0, 0, 0, 0.1),
0 1px 3px rgba(0, 0, 0, 0.08);
}
.stats-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.stats-section h3 {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.25rem 0;
padding-bottom: 0.25rem;
border-bottom: 1px solid var(--border-color);
}
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.125rem 0;
}
.stat-detail {
margin-left: 1rem;
font-size: 0.8rem;
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);
}
.stat-value {
font-family: var(--font-mono, monospace);
color: var(--text-primary, #e5e7eb);
}
.model-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: var(--bg-primary);
border-radius: 4px;
font-size: 0.8rem;
}
.model-label {
color: var(--text-secondary, #9ca3af);
font-weight: 600;
}
.model-value {
font-family: var(--font-mono, monospace);
color: var(--text-primary, #e5e7eb);
font-size: 0.75rem;
}
</style>
+154 -1
View File
@@ -1,14 +1,27 @@
<script lang="ts"> <script lang="ts">
interface Props {
onToggleAchievements?: () => void;
}
const { onToggleAchievements = () => {} }: Props = $props();
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app"; import { getVersion } from "@tauri-apps/api/app";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { openUrl } from "@tauri-apps/plugin-opener"; import { openUrl } from "@tauri-apps/plugin-opener";
import { get } from "svelte/store";
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { configStore, type HikariConfig } from "$lib/stores/config"; import { configStore, type HikariConfig } from "$lib/stores/config";
import type { ConnectionStatus } from "$lib/types/messages"; import type { ConnectionStatus } from "$lib/types/messages";
import { onMount } from "svelte"; 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";
const DISCORD_URL = "https://chat.nhcarrigan.com"; const DISCORD_URL = "https://chat.nhcarrigan.com";
const DONATE_URL = "https://donate.nhcarrigan.com";
let connectionStatus: ConnectionStatus = $state("disconnected"); let connectionStatus: ConnectionStatus = $state("disconnected");
let workingDirectory = $state(""); let workingDirectory = $state("");
@@ -16,6 +29,11 @@
let isConnecting = $state(false); let isConnecting = $state(false);
let grantedToolsList: string[] = $state([]); let grantedToolsList: string[] = $state([]);
let appVersion = $state(""); let appVersion = $state("");
let showStats = $state(false);
let showAbout = $state(false);
let showHelp = $state(false);
let showKeyboardShortcuts = $state(false);
const progress = $derived($achievementProgress);
let currentConfig: HikariConfig = $state({ let currentConfig: HikariConfig = $state({
model: null, model: null,
api_key: null, api_key: null,
@@ -25,6 +43,12 @@
theme: "dark", theme: "dark",
greeting_enabled: true, greeting_enabled: true,
greeting_custom_prompt: null, 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,
}); });
onMount(async () => { onMount(async () => {
@@ -75,7 +99,12 @@
]; ];
try { try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("start_claude", { await invoke("start_claude", {
conversationId,
options: { options: {
working_dir: targetDir, working_dir: targetDir,
model: currentConfig.model || null, model: currentConfig.model || null,
@@ -93,7 +122,11 @@
async function handleDisconnect() { async function handleDisconnect() {
try { try {
await invoke("stop_claude"); const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("stop_claude", { conversationId });
} catch (error) { } catch (error) {
console.error("Failed to stop Claude:", error); console.error("Failed to stop Claude:", error);
} }
@@ -124,6 +157,10 @@
return "Disconnected"; return "Disconnected";
} }
} }
function toggleAchievements() {
onToggleAchievements();
}
</script> </script>
<div <div
@@ -165,6 +202,36 @@
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button
onclick={toggleAchievements}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors relative"
title="Achievements"
>
<span class="text-lg">🏆</span>
{#if progress.unlocked > 0}
<span
class="absolute -top-1 -right-1 bg-[var(--accent-primary)] text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px]"
>
{progress.unlocked}
</span>
{/if}
</button>
<button
onclick={() => (showStats = !showStats)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors {showStats
? 'text-[var(--accent-primary)]'
: ''}"
title="Usage Stats"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zM13 19v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2h2a2 2 0 002-2zM21 19V8a2 2 0 00-2-2h-2a2 2 0 00-2 2v11a2 2 0 002 2h2a2 2 0 002-2z"
/>
</svg>
</button>
<button <button
onclick={configStore.openSidebar} onclick={configStore.openSidebar}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors" class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
@@ -185,6 +252,65 @@
/> />
</svg> </svg>
</button> </button>
<button
onclick={() => openUrl(DONATE_URL)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
title="Support our work"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</button>
<button
onclick={() => (showAbout = true)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
title="About Hikari Desktop"
>
<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 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<button
onclick={() => (showKeyboardShortcuts = true)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
title="Keyboard Shortcuts"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3C10.22 3 8.47 3.23 6.86 3.68A2 2 0 005 5.57V18.43a2 2 0 001.86 1.89C8.47 20.77 10.22 21 12 21s3.53-.23 5.14-.68A2 2 0 0019 18.43V5.57a2 2 0 00-1.86-1.89C15.53 3.23 13.78 3 12 3z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7h.01M12 7h.01M16 7h.01M8 11h.01M12 11h.01M16 11h.01M8 15h8"
/>
</svg>
</button>
<button
onclick={() => (showHelp = true)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
title="Help"
>
<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="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<button <button
onclick={() => openUrl(DISCORD_URL)} onclick={() => openUrl(DISCORD_URL)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors" class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
@@ -199,6 +325,12 @@
{#if appVersion} {#if appVersion}
<span class="text-xs text-gray-600">v{appVersion}</span> <span class="text-xs text-gray-600">v{appVersion}</span>
{/if} {/if}
{#if showStats}
<div class="absolute top-full right-0 mt-2 mr-4 z-50">
<StatsDisplay />
</div>
{/if}
{#if connectionStatus === "connected"} {#if connectionStatus === "connected"}
<button <button
onclick={handleDisconnect} onclick={handleDisconnect}
@@ -217,3 +349,24 @@
{/if} {/if}
</div> </div>
</div> </div>
{#if showStats}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div>
<div class="fixed top-14 right-4 z-50">
<StatsDisplay />
</div>
{/if}
{#if showAbout}
<AboutPanel onClose={() => (showAbout = false)} />
{/if}
{#if showHelp}
<HelpPanel onClose={() => (showHelp = false)} />
{/if}
{#if showKeyboardShortcuts}
<KeyboardShortcutsModal onClose={() => (showKeyboardShortcuts = false)} />
{/if}
+137 -16
View File
@@ -1,23 +1,66 @@
<script lang="ts"> <script lang="ts">
import { claudeStore, type TerminalLine } from "$lib/stores/claude"; import { claudeStore, type TerminalLine } from "$lib/stores/claude";
import { afterUpdate } from "svelte"; import { afterUpdate, tick } from "svelte";
import ConversationTabs from "./ConversationTabs.svelte";
import Markdown from "./Markdown.svelte";
import HighlightedText from "./HighlightedText.svelte";
import { searchState, searchQuery } from "$lib/stores/search";
let terminalElement: HTMLDivElement; let terminalElement: HTMLDivElement;
let shouldAutoScroll = true; let shouldAutoScroll = true;
let lines: TerminalLine[] = []; let lines: TerminalLine[] = [];
let currentSearchQuery = "";
let currentConversationId: string | null = null;
let isRestoringScroll = false;
searchQuery.subscribe((value) => {
currentSearchQuery = value;
});
claudeStore.terminalLines.subscribe((value) => { claudeStore.terminalLines.subscribe((value) => {
lines = value; lines = value;
}); });
claudeStore.activeConversationId.subscribe(async (newId) => {
if (!newId) return;
// Save current conversation's scroll position before switching
if (currentConversationId && currentConversationId !== newId && terminalElement) {
const position = shouldAutoScroll ? -1 : terminalElement.scrollTop;
claudeStore.saveScrollPosition(currentConversationId, position);
}
currentConversationId = newId;
// Restore scroll position for the new conversation after DOM updates
await tick();
if (terminalElement) {
const savedPosition = claudeStore.getScrollPosition(newId);
isRestoringScroll = true;
if (savedPosition === -1) {
// Auto-scroll to bottom
shouldAutoScroll = true;
terminalElement.scrollTop = terminalElement.scrollHeight;
} else {
// Restore to saved position
shouldAutoScroll = false;
terminalElement.scrollTop = savedPosition;
}
// Small delay to prevent the scroll handler from overriding our restore
setTimeout(() => {
isRestoringScroll = false;
}, 50);
}
});
function handleScroll() { function handleScroll() {
if (!terminalElement) return; if (!terminalElement || isRestoringScroll) return;
const { scrollTop, scrollHeight, clientHeight } = terminalElement; const { scrollTop, scrollHeight, clientHeight } = terminalElement;
shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100; shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100;
} }
afterUpdate(() => { afterUpdate(() => {
if (shouldAutoScroll && terminalElement) { if (shouldAutoScroll && terminalElement && !isRestoringScroll) {
terminalElement.scrollTop = terminalElement.scrollHeight; terminalElement.scrollTop = terminalElement.scrollHeight;
} }
}); });
@@ -25,17 +68,17 @@
function getLineClass(type: string): string { function getLineClass(type: string): string {
switch (type) { switch (type) {
case "user": case "user":
return "text-cyan-400"; return "terminal-user";
case "assistant": case "assistant":
return "text-gray-100"; return "terminal-assistant";
case "system": case "system":
return "text-gray-500 italic"; return "terminal-system italic";
case "tool": case "tool":
return "text-purple-400"; return "terminal-tool";
case "error": case "error":
return "text-red-400"; return "terminal-error";
default: default:
return "text-gray-300"; return "terminal-default";
} }
} }
@@ -62,6 +105,23 @@
minute: "2-digit", minute: "2-digit",
}); });
} }
$: {
if (currentSearchQuery && lines.length > 0) {
const escapedQuery = currentSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(escapedQuery, "gi");
let totalMatches = 0;
for (const line of lines) {
const matches = line.content.match(regex);
if (matches) {
totalMatches += matches.length;
}
}
searchState.setMatchCount(totalMatches);
} else {
searchState.setMatchCount(0);
}
}
</script> </script>
<div <div
@@ -75,27 +135,36 @@
<div class="w-3 h-3 rounded-full bg-yellow-500"></div> <div class="w-3 h-3 rounded-full bg-yellow-500"></div>
<div class="w-3 h-3 rounded-full bg-green-500"></div> <div class="w-3 h-3 rounded-full bg-green-500"></div>
</div> </div>
<span class="text-sm text-gray-400 ml-2">Terminal</span> <span class="text-sm terminal-header-text ml-2">Terminal</span>
</div> </div>
<ConversationTabs />
<div <div
bind:this={terminalElement} bind:this={terminalElement}
onscroll={handleScroll} onscroll={handleScroll}
class="terminal-content h-[calc(100%-40px)] 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} {#if lines.length === 0}
<div class="text-gray-500 italic">Waiting for Claude... Type a message below to start!</div> <div class="terminal-waiting italic">
Waiting for Claude... Type a message below to start!
</div>
{:else} {:else}
{#each lines as line (line.id)} {#each lines as line (line.id)}
<div class="terminal-line mb-2 {getLineClass(line.type)}"> <div class="terminal-line mb-2 {getLineClass(line.type)}">
<span class="text-gray-600 text-xs mr-2">{formatTime(line.timestamp)}</span> <span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
{#if getLinePrefix(line.type)} {#if getLinePrefix(line.type)}
<span class="text-gray-500 mr-2">{getLinePrefix(line.type)}</span> <span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
{/if} {/if}
{#if line.toolName} {#if line.toolName}
<span class="text-purple-300 mr-2">[{line.toolName}]</span> <span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if}
{#if line.type === "assistant"}
<Markdown content={line.content} searchQuery={currentSearchQuery} />
{:else}
<HighlightedText content={line.content} searchQuery={currentSearchQuery} />
{/if} {/if}
<span class="whitespace-pre-wrap">{line.content}</span>
</div> </div>
{/each} {/each}
{/if} {/if}
@@ -107,4 +176,56 @@
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--border-color) var(--bg-terminal); scrollbar-color: var(--border-color) var(--bg-terminal);
} }
/* Terminal text colors that adapt to theme */
.terminal-user {
color: var(--terminal-user, #22d3ee);
}
.terminal-assistant {
color: var(--text-primary);
}
.terminal-system {
color: var(--text-secondary);
}
.terminal-tool {
color: var(--terminal-tool, #c084fc);
}
.terminal-error {
color: var(--terminal-error, #f87171);
}
.terminal-default {
color: var(--text-primary);
}
.terminal-timestamp {
color: var(--text-tertiary, #6b7280);
}
.terminal-prefix {
color: var(--text-secondary);
}
.terminal-tool-name {
color: var(--terminal-tool-name, #ddd6fe);
}
.terminal-waiting {
color: var(--text-secondary);
}
.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> </style>
@@ -0,0 +1,92 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { UpdateInfo } from "$lib/types/messages";
import { configStore } from "$lib/stores/config";
let updateInfo = $state<UpdateInfo | null>(null);
let dismissed = $state(false);
export async function checkForUpdates() {
// Check if update checks are enabled
const config = configStore.getConfig();
if (!config.update_checks_enabled) {
return;
}
try {
const info = await invoke<UpdateInfo>("check_for_updates");
if (info.has_update) {
updateInfo = info;
dismissed = false;
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
console.error("Failed to check for updates:", errorMessage);
}
}
function dismiss() {
dismissed = true;
}
async function openRelease() {
if (updateInfo?.release_url) {
await openUrl(updateInfo.release_url);
}
}
</script>
{#if updateInfo && !dismissed}
<div
class="fixed bottom-4 right-4 max-w-sm bg-[var(--bg-tertiary)] border border-[var(--accent-primary)] rounded-lg shadow-lg p-4 z-50"
>
<div class="flex items-start gap-3">
<div class="text-2xl">🎉</div>
<div class="flex-1">
<h3 class="text-[var(--text-primary)] font-semibold mb-1">Update Available!</h3>
<p class="text-[var(--text-secondary)] text-sm mb-2">
A new version of Hikari Desktop is available:
<span class="text-[var(--accent-primary)] font-mono">{updateInfo.latest_version}</span>
</p>
<p class="text-[var(--text-muted)] text-xs mb-3">
Current version: {updateInfo.current_version}
</p>
<div class="flex gap-2">
<button
onclick={openRelease}
class="px-3 py-1.5 bg-[var(--accent-primary)] text-white rounded text-sm hover:brightness-110 transition-all"
>
View Release
</button>
<button
onclick={dismiss}
class="px-3 py-1.5 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded text-sm hover:bg-[var(--bg-primary)] transition-all"
>
Later
</button>
</div>
</div>
<button
onclick={dismiss}
class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Dismiss"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
</div>
</div>
{/if}
+264
View File
@@ -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 = new SvelteSet();
customAnswer = "";
showCustomInput = false;
}
});
claudeStore.pendingQuestion.subscribe((q) => {
question = q;
if (q) {
characterState.setState("permission");
}
});
claudeStore.grantedTools.subscribe((tools) => {
grantedToolsList = Array.from(tools);
});
claudeStore.currentWorkingDirectory.subscribe((dir) => {
workingDirectory = dir;
});
function toggleOption(label: string) {
if (!question) return;
if (question.multi_select) {
if (selectedOptions.has(label)) {
selectedOptions.delete(label);
} else {
selectedOptions.add(label);
}
} else {
selectedOptions.clear();
selectedOptions.add(label);
}
showCustomInput = false;
}
function selectCustom() {
showCustomInput = true;
selectedOptions.clear();
}
async function handleSubmitAndReconnect() {
if (!question) return;
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
let answerText: string;
if (showCustomInput && customAnswer.trim()) {
answerText = customAnswer.trim();
} else if (selectedOptions.size > 0) {
if (question.multi_select) {
answerText = Array.from(selectedOptions).join(", ");
} else {
answerText = Array.from(selectedOptions)[0];
}
} else {
return;
}
const questionText = question.question;
const conversationHistory = claudeStore.getConversationHistory();
claudeStore.addLine("system", `Answer: ${answerText}. Reconnecting with context...`);
claudeStore.clearQuestion();
try {
await invoke("stop_claude", { conversationId });
await new Promise((resolve) => setTimeout(resolve, 500));
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDirectory || "/home/naomi",
allowed_tools: grantedToolsList,
},
});
await new Promise((resolve) => setTimeout(resolve, 1000));
if (conversationHistory) {
const contextMessage = `[CONTEXT RESTORATION]
I just answered your question. Here's our conversation so far:
${conversationHistory}
You asked me: "${questionText}"
My answer: "${answerText}"
Please continue where we left off, taking my answer into account.`;
await invoke("send_prompt", {
conversationId,
message: contextMessage,
});
}
characterState.setTemporaryState("success", 2000);
} catch (error) {
console.error("Failed to reconnect:", error);
claudeStore.addLine("error", `Reconnect failed: ${error}`);
characterState.setTemporaryState("error", 3000);
}
}
function handleDismiss() {
claudeStore.clearQuestion();
claudeStore.addLine("system", "Question dismissed");
characterState.setTemporaryState("idle", 1000);
}
function handleKeydown(event: KeyboardEvent) {
if (!isVisible || !question) return;
if (event.key === "Enter" && !showCustomInput) {
event.preventDefault();
if (selectedOptions.size > 0) {
handleSubmitAndReconnect();
}
} else if (event.key === "Escape") {
event.preventDefault();
handleDismiss();
}
}
function canSubmit(): boolean {
return selectedOptions.size > 0 || (showCustomInput && customAnswer.trim().length > 0);
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isVisible && question}
<div
class="question-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
>
<div
class="question-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl"
>
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
<span class="text-xl">?</span>
</div>
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">
{question.header || "Question"}
</h2>
<p class="text-sm text-[var(--text-secondary)]">Hikari needs your input</p>
</div>
</div>
<div class="mb-4">
<p class="text-[var(--text-primary)]">{question.question}</p>
</div>
<div class="mb-4 space-y-2">
{#each question.options as option (option.label)}
<button
onclick={() => toggleOption(option.label)}
class="w-full text-left px-4 py-3 rounded-lg border transition-colors {selectedOptions.has(
option.label
)
? 'bg-[var(--accent-primary)]/20 border-[var(--accent-primary)] text-[var(--text-primary)]'
: 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50'}"
>
<div class="flex items-start gap-3">
<div
class="mt-0.5 w-5 h-5 rounded-{question.multi_select
? 'sm'
: 'full'} border-2 flex items-center justify-center {selectedOptions.has(
option.label
)
? 'border-[var(--accent-primary)] bg-[var(--accent-primary)]'
: 'border-[var(--text-secondary)]'}"
>
{#if selectedOptions.has(option.label)}
<span class="text-white text-xs">{question.multi_select ? "x" : "x"}</span>
{/if}
</div>
<div class="flex-1">
<div class="font-medium">{option.label}</div>
{#if option.description}
<div class="text-sm text-[var(--text-secondary)] mt-1">{option.description}</div>
{/if}
</div>
</div>
</button>
{/each}
<button
onclick={selectCustom}
class="w-full text-left px-4 py-3 rounded-lg border transition-colors {showCustomInput
? 'bg-[var(--accent-primary)]/20 border-[var(--accent-primary)] text-[var(--text-primary)]'
: 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50'}"
>
<div class="flex items-start gap-3">
<div
class="mt-0.5 w-5 h-5 rounded-full border-2 flex items-center justify-center {showCustomInput
? 'border-[var(--accent-primary)] bg-[var(--accent-primary)]'
: 'border-[var(--text-secondary)]'}"
>
{#if showCustomInput}
<span class="text-white text-xs">x</span>
{/if}
</div>
<div class="flex-1">
<div class="font-medium">Other</div>
<div class="text-sm text-[var(--text-secondary)] mt-1">Provide a custom answer</div>
</div>
</div>
</button>
</div>
{#if showCustomInput}
<div class="mb-4">
<textarea
bind:value={customAnswer}
placeholder="Type your answer here..."
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-secondary)] resize-none focus:outline-none focus:border-[var(--accent-primary)]"
rows="3"
></textarea>
</div>
{/if}
<div class="flex gap-3">
<button
onclick={handleDismiss}
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
>
Dismiss
</button>
<button
onclick={handleSubmitAndReconnect}
disabled={!canSubmit()}
class="flex-1 px-4 py-2 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Answer & Reconnect
</button>
</div>
</div>
</div>
{/if}
+4
View File
@@ -0,0 +1,4 @@
export * from "./types";
export { soundPlayer } from "./soundPlayer";
export { notificationManager } from "./notificationManager";
export { initializeNotificationRules } from "./rules";
@@ -0,0 +1,121 @@
import { soundPlayer } from "./soundPlayer";
import { NotificationType, NOTIFICATION_SOUNDS } from "./types";
import { invoke } from "@tauri-apps/api/core";
import { sendTerminalNotification } from "./terminalNotifier";
class NotificationManager {
async notify(type: NotificationType, message?: string): Promise<void> {
// Always play sound (if enabled)
await soundPlayer.play(type);
const sound = NOTIFICATION_SOUNDS[type];
const title = sound.phrase;
const body = message || this.getDefaultMessage(type);
// Try multiple notification methods in order
const notificationMethods = [
// Method 1: Try Windows PowerShell (best for system tray notifications)
async () => {
console.log("Trying Windows PowerShell notifications...");
await invoke("send_windows_notification", { title, body });
},
// Method 2: Try native Windows toast (for Windows builds)
async () => {
console.log("Trying native Windows toast...");
await invoke("send_windows_toast", { title, body });
},
// Method 3: Try WSL-specific notification (Windows toast via PowerShell - for WSL)
async () => {
console.log("Trying WSL notification...");
await invoke("send_wsl_notification", { title, body });
},
// Method 4: Try native Tauri notifications
async () => {
console.log("Trying Tauri native notifications...");
const { sendNotification, isPermissionGranted, requestPermission } =
await import("@tauri-apps/plugin-notification");
let hasPermission = await isPermissionGranted();
if (!hasPermission) {
const permission = await requestPermission();
hasPermission = permission === "granted";
}
if (hasPermission) {
await sendNotification({ title, body });
} else {
throw new Error("Notification permission denied");
}
},
// Method 5: Try notify-send (for native Linux)
async () => {
console.log("Trying notify-send...");
await invoke("send_notify_send", { title, body });
},
// Skip VBScript and simple message as they create popup dialogs
// Only use them in the debugger for testing
];
// Try each method until one succeeds
for (const method of notificationMethods) {
try {
await method();
console.log("Notification sent successfully");
return; // Success, stop trying other methods
} catch (error) {
console.warn("Notification method failed:", error);
// Continue to next method
}
}
console.error("All notification methods failed, using terminal notification");
// Final fallback: Show in terminal
sendTerminalNotification(type, body);
}
private getDefaultMessage(type: NotificationType): string {
switch (type) {
case NotificationType.SUCCESS:
return "Task completed successfully!";
case NotificationType.ERROR:
return "Something went wrong...";
case NotificationType.PERMISSION:
return "Permission needed to continue";
case NotificationType.CONNECTION:
return "Successfully connected to Claude Code";
case NotificationType.TASK_START:
return "Starting task...";
default:
return "Notification";
}
}
// Helper methods for common notifications
async notifySuccess(message?: string): Promise<void> {
await this.notify(NotificationType.SUCCESS, message);
}
async notifyError(message?: string): Promise<void> {
await this.notify(NotificationType.ERROR, message);
}
async notifyPermission(message?: string): Promise<void> {
await this.notify(NotificationType.PERMISSION, message);
}
async notifyConnection(message?: string): Promise<void> {
await this.notify(NotificationType.CONNECTION, message);
}
async notifyTaskStart(message?: string): Promise<void> {
await this.notify(NotificationType.TASK_START, message);
}
}
// Export singleton instance
export const notificationManager = new NotificationManager();
+103
View File
@@ -0,0 +1,103 @@
import { characterState } from "$lib/stores/character";
import { notificationManager } from "./notificationManager";
import type { CharacterState } from "$lib/types/states";
import type { ConnectionStatus } from "$lib/types/messages";
// Track previous states to detect transitions
let previousCharacterState: CharacterState | null = null;
let previousConnectionStatus: ConnectionStatus | null = null;
let taskStartTime: number | null = null;
let hasNotifiedTaskStart = false;
export function handleCharacterStateChange(newState: CharacterState): void {
// Detect state transitions
if (previousCharacterState === newState) return;
// Task completion: any state -> success
if (newState === "success" && previousCharacterState !== null) {
const taskDuration = taskStartTime ? Date.now() - taskStartTime : 0;
// Only notify for tasks that took more than 2 seconds
if (taskDuration > 2000) {
notificationManager.notifySuccess();
}
taskStartTime = null;
}
// Error occurred
if (newState === "error" && previousCharacterState !== "error") {
notificationManager.notifyError();
}
// Permission needed
if (newState === "permission") {
notificationManager.notifyPermission();
}
// Starting long tasks - only notify once per response
if (
(newState === "coding" || newState === "searching") &&
previousCharacterState !== "coding" &&
previousCharacterState !== "searching" &&
!hasNotifiedTaskStart
) {
taskStartTime = Date.now();
hasNotifiedTaskStart = true;
notificationManager.notifyTaskStart();
}
previousCharacterState = newState;
}
export function handleConnectionStatusChange(newStatus: ConnectionStatus): void {
// Only notify on successful connection after being disconnected
if (
newStatus === "connected" &&
previousConnectionStatus &&
previousConnectionStatus !== "connected"
) {
notificationManager.notifyConnection();
}
previousConnectionStatus = newStatus;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function handleToolExecution(_toolName: string): void {
// For now, we don't notify on every tool execution
// But we could add specific rules here if needed
}
// Reset notification state for a new response
export function handleNewUserMessage(): void {
hasNotifiedTaskStart = false;
}
// Store unsubscribe functions
let unsubscribeCharacterState: (() => void) | null = null;
// Initialize listeners
export function initializeNotificationRules(): void {
// Clean up any existing subscriptions first
cleanupNotificationRules();
// Subscribe to character state changes
unsubscribeCharacterState = characterState.subscribe((state) => {
handleCharacterStateChange(state);
});
// We'll connect to connection status in the next step
}
// Cleanup function to prevent duplicate listeners
export function cleanupNotificationRules(): void {
if (unsubscribeCharacterState) {
unsubscribeCharacterState();
unsubscribeCharacterState = null;
}
// Reset state tracking
previousCharacterState = null;
previousConnectionStatus = null;
taskStartTime = null;
hasNotifiedTaskStart = false;
}
+61
View File
@@ -0,0 +1,61 @@
import { NOTIFICATION_SOUNDS, type NotificationType } from "./types";
class SoundPlayer {
private audioCache: Map<NotificationType, HTMLAudioElement> = new Map();
private enabled: boolean = false; // Start disabled until config loads
private globalVolume: number = 1.0;
constructor() {
// Preload all essential sounds
this.preloadSounds();
}
private preloadSounds(): void {
Object.entries(NOTIFICATION_SOUNDS).forEach(([type, sound]) => {
const audio = new Audio(`/sounds/${sound.filename}`);
audio.preload = "auto";
audio.volume = (sound.volume || 0.7) * this.globalVolume;
this.audioCache.set(type as NotificationType, audio);
});
}
async play(type: NotificationType): Promise<void> {
if (!this.enabled) return;
try {
const audio = this.audioCache.get(type);
if (!audio) {
console.warn(`No audio found for notification type: ${type}`);
return;
}
// Clone the audio to allow overlapping sounds
const audioClone = audio.cloneNode() as HTMLAudioElement;
audioClone.volume = audio.volume;
await audioClone.play();
} catch (error) {
console.error("Failed to play notification sound:", error);
}
}
setEnabled(enabled: boolean): void {
this.enabled = enabled;
}
setGlobalVolume(volume: number): void {
this.globalVolume = Math.max(0, Math.min(1, volume));
// Update all cached audio volumes
this.audioCache.forEach((audio, type) => {
const sound = NOTIFICATION_SOUNDS[type];
audio.volume = (sound.volume || 0.7) * this.globalVolume;
});
}
isEnabled(): boolean {
return this.enabled;
}
}
// Export singleton instance
export const soundPlayer = new SoundPlayer();
+50
View File
@@ -0,0 +1,50 @@
import { claudeStore } from "$lib/stores/claude";
import { NOTIFICATION_SOUNDS, type NotificationType } from "./types";
export function sendTerminalNotification(type: NotificationType, message?: string): void {
const sound = NOTIFICATION_SOUNDS[type];
const title = sound.phrase;
// Create a formatted notification message
const separator = "═".repeat(50);
const timestamp = new Date().toLocaleTimeString();
let emoji = "";
let color = "";
switch (type) {
case "success":
emoji = "✨";
color = "\x1b[32m"; // Green
break;
case "error":
emoji = "❌";
color = "\x1b[31m"; // Red
break;
case "permission":
emoji = "🔐";
color = "\x1b[33m"; // Yellow
break;
case "connection":
emoji = "🔗";
color = "\x1b[36m"; // Cyan
break;
case "task_start":
emoji = "🚀";
color = "\x1b[34m"; // Blue
break;
}
const reset = "\x1b[0m";
// Format the notification
const notification = `
${color}${separator}${reset}
${emoji} ${color}NOTIFICATION${reset} - ${timestamp}
${color}${title}${reset}
${message || ""}
${color}${separator}${reset}`;
// Add to terminal as a special system message
claudeStore.addLine("system", notification);
}
@@ -0,0 +1,42 @@
import { notificationManager } from "./notificationManager";
// Test function to trigger each notification type
export async function testAllNotifications() {
console.log("Testing all notification types...");
// Test success notification
setTimeout(async () => {
console.log("Testing SUCCESS notification");
await notificationManager.notifySuccess("Test task completed!");
}, 1000);
// Test error notification
setTimeout(async () => {
console.log("Testing ERROR notification");
await notificationManager.notifyError("Test error occurred!");
}, 3000);
// Test permission notification
setTimeout(async () => {
console.log("Testing PERMISSION notification");
await notificationManager.notifyPermission("Test permission request!");
}, 5000);
// Test connection notification
setTimeout(async () => {
console.log("Testing CONNECTION notification");
await notificationManager.notifyConnection("Test connection established!");
}, 7000);
// Test task start notification
setTimeout(async () => {
console.log("Testing TASK_START notification");
await notificationManager.notifyTaskStart("Test task starting!");
}, 9000);
}
// Make it available on window for easy testing
if (typeof window !== "undefined") {
(window as unknown as { testNotifications: typeof testAllNotifications }).testNotifications =
testAllNotifications;
}
+55
View File
@@ -0,0 +1,55 @@
export enum NotificationType {
SUCCESS = "success",
ERROR = "error",
PERMISSION = "permission",
CONNECTION = "connection",
TASK_START = "task_start",
ACHIEVEMENT = "achievement",
}
export interface NotificationSound {
type: NotificationType;
filename: string;
phrase: string;
volume?: number;
}
// Essential notification sounds mapping
export const NOTIFICATION_SOUNDS: Record<NotificationType, NotificationSound> = {
[NotificationType.SUCCESS]: {
type: NotificationType.SUCCESS,
filename: "im-done.mp3",
phrase: "I'm done!",
volume: 0.7,
},
[NotificationType.ERROR]: {
type: NotificationType.ERROR,
filename: "oh-no.mp3",
phrase: "Oh no...",
volume: 0.8,
},
[NotificationType.PERMISSION]: {
type: NotificationType.PERMISSION,
filename: "access-please.mp3",
phrase: "Access please!",
volume: 0.9,
},
[NotificationType.CONNECTION]: {
type: NotificationType.CONNECTION,
filename: "connected.mp3",
phrase: "Connected!",
volume: 0.7,
},
[NotificationType.TASK_START]: {
type: NotificationType.TASK_START,
filename: "working-on-it.mp3",
phrase: "Working on it!",
volume: 0.6,
},
[NotificationType.ACHIEVEMENT]: {
type: NotificationType.ACHIEVEMENT,
filename: "achievement.mp3",
phrase: "Achievement Get~!",
volume: 0.8,
},
};
@@ -0,0 +1,16 @@
import { invoke } from "@tauri-apps/api/core";
import { platform } from "@tauri-apps/plugin-os";
export async function sendWSLNotification(title: string, body: string): Promise<void> {
const currentPlatform = await platform();
// Check if we're on Windows (WSL shows as 'windows')
if (currentPlatform === "windows") {
try {
// Use PowerShell to send Windows native notifications
await invoke("send_windows_notification", { title, body });
} catch (error) {
console.error("Failed to send Windows notification:", error);
}
}
}
+19
View File
@@ -0,0 +1,19 @@
// Achievement sound player using the notification system
import { soundPlayer } from "$lib/notifications";
import { NotificationType } from "$lib/notifications/types";
export function playAchievementSound() {
// Use the soundPlayer which respects global notification settings
soundPlayer.play(NotificationType.ACHIEVEMENT);
}
// Test function for development
export function testAchievementSound() {
try {
playAchievementSound();
console.log("Achievement sound played successfully!");
} catch (error) {
console.error("Error playing achievement sound:", error);
}
}
+649
View File
@@ -0,0 +1,649 @@
import { writable, derived } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import type { Achievement, AchievementUnlockedEvent, AchievementId } from "$lib/types/achievements";
import { playAchievementSound } from "$lib/sounds/achievement";
interface AchievementState {
achievements: Record<AchievementId, Achievement>;
totalUnlocked: number;
lastUnlocked: Achievement | null;
}
// Initial achievement definitions
const achievementDefinitions: Record<
AchievementId,
Omit<Achievement, "unlocked" | "unlockedAt">
> = {
// Token milestones
FirstSteps: {
id: "FirstSteps",
name: "First Steps",
description: "Generated your first 1,000 tokens",
icon: "👶",
rarity: "common",
maxProgress: 1000,
},
GrowingStrong: {
id: "GrowingStrong",
name: "Growing Strong",
description: "Reached 10,000 tokens total",
icon: "🌱",
rarity: "common",
maxProgress: 10000,
},
BlossomingCoder: {
id: "BlossomingCoder",
name: "Blossoming Coder",
description: "Generated 100,000 tokens - you're really growing!",
icon: "🌸",
rarity: "rare",
maxProgress: 100000,
},
TokenMaster: {
id: "TokenMaster",
name: "Token Master",
description: "One million tokens! You're unstoppable!",
icon: "👑",
rarity: "legendary",
maxProgress: 1000000,
},
// Code generation
HelloWorld: {
id: "HelloWorld",
name: "Hello, World!",
description: "Generated your first code block",
icon: "👋",
rarity: "common",
maxProgress: 1,
},
CodeWizard: {
id: "CodeWizard",
name: "Code Wizard",
description: "100 code blocks generated",
icon: "🧙‍♀️",
rarity: "rare",
maxProgress: 100,
},
ThousandBlocks: {
id: "ThousandBlocks",
name: "Thousand Blocks",
description: "1,000 code blocks! You're a code machine!",
icon: "🏗️",
rarity: "epic",
maxProgress: 1000,
},
// File operations
FileManipulator: {
id: "FileManipulator",
name: "File Manipulator",
description: "Edited 10 files",
icon: "📝",
rarity: "common",
maxProgress: 10,
},
FileArchitect: {
id: "FileArchitect",
name: "File Architect",
description: "Created or edited 100 files",
icon: "🏛️",
rarity: "rare",
maxProgress: 100,
},
// Conversation milestones
ConversationStarter: {
id: "ConversationStarter",
name: "Conversation Starter",
description: "Exchanged 10 messages",
icon: "💬",
rarity: "common",
maxProgress: 10,
},
ChattyKathy: {
id: "ChattyKathy",
name: "Chatty Kathy",
description: "100 messages exchanged",
icon: "🗣️",
rarity: "common",
maxProgress: 100,
},
Conversationalist: {
id: "Conversationalist",
name: "Master Conversationalist",
description: "1,000 messages! We're really connecting!",
icon: "💖",
rarity: "rare",
maxProgress: 1000,
},
// Tool usage
Toolsmith: {
id: "Toolsmith",
name: "Toolsmith",
description: "Used 5 different tools",
icon: "🔨",
rarity: "common",
maxProgress: 5,
},
ToolMaster: {
id: "ToolMaster",
name: "Tool Master",
description: "Used 10 different tools efficiently",
icon: "🛠️",
rarity: "rare",
maxProgress: 10,
},
// Time-based achievements
EarlyBird: {
id: "EarlyBird",
name: "Early Bird",
description: "Started a session between 5 AM and 7 AM",
icon: "🌅",
rarity: "common",
},
NightOwl: {
id: "NightOwl",
name: "Night Owl",
description: "Coding after midnight",
icon: "🦉",
rarity: "common",
},
AllNighter: {
id: "AllNighter",
name: "All Nighter",
description: "Worked through the night (2 AM - 5 AM)",
icon: "🌙",
rarity: "rare",
},
WeekendWarrior: {
id: "WeekendWarrior",
name: "Weekend Warrior",
description: "Coding on a weekend",
icon: "⚔️",
rarity: "common",
},
DedicatedDeveloper: {
id: "DedicatedDeveloper",
name: "Dedicated Developer",
description: "Coded for 30 days in a row",
icon: "🏆",
rarity: "legendary",
},
// Search and exploration
Explorer: {
id: "Explorer",
name: "Explorer",
description: "Used search tools 50 times",
icon: "🔍",
rarity: "common",
maxProgress: 50,
},
MasterSearcher: {
id: "MasterSearcher",
name: "Master Searcher",
description: "Searched 500 times across files",
icon: "🕵️‍♀️",
rarity: "rare",
maxProgress: 500,
},
// Session achievements
QuickSession: {
id: "QuickSession",
name: "Quick Session",
description: "Completed a productive session in under 5 minutes",
icon: "⚡",
rarity: "common",
},
FocusedWork: {
id: "FocusedWork",
name: "Focused Work",
description: "Worked for 30 minutes straight",
icon: "🎯",
rarity: "common",
},
DeepDive: {
id: "DeepDive",
name: "Deep Dive",
description: "Worked for 2 hours continuously",
icon: "🏊‍♀️",
rarity: "rare",
},
MarathonSession: {
id: "MarathonSession",
name: "Marathon Session",
description: "5+ hour coding session!",
icon: "🏃‍♀️",
rarity: "epic",
},
// Special achievements
FirstMessage: {
id: "FirstMessage",
name: "First Message",
description: "Sent your first message to Hikari",
icon: "✨",
rarity: "common",
maxProgress: 1,
},
FirstTool: {
id: "FirstTool",
name: "First Tool",
description: "Used your first tool",
icon: "🔧",
rarity: "common",
maxProgress: 1,
},
FirstCodeBlock: {
id: "FirstCodeBlock",
name: "First Code",
description: "Generated your first code block",
icon: "📦",
rarity: "common",
maxProgress: 1,
},
FirstFileEdit: {
id: "FirstFileEdit",
name: "First Edit",
description: "Made your first file edit",
icon: "✏️",
rarity: "common",
maxProgress: 1,
},
Polyglot: {
id: "Polyglot",
name: "Polyglot",
description: "Generated code in 5+ languages in one session",
icon: "🌍",
rarity: "rare",
maxProgress: 5,
},
SpeedCoder: {
id: "SpeedCoder",
name: "Speed Coder",
description: "Generated 10 code blocks in 10 minutes",
icon: "🚀",
rarity: "rare",
},
ClaudeConnoisseur: {
id: "ClaudeConnoisseur",
name: "Claude Connoisseur",
description: "Used all available Claude models",
icon: "🎨",
rarity: "epic",
maxProgress: 5, // Adjust based on available models
},
MarathonCoder: {
id: "MarathonCoder",
name: "Marathon Coder",
description: "10,000 tokens in a single session",
icon: "🏃‍♂️",
rarity: "epic",
maxProgress: 10000,
},
// Relationship & Greetings
GoodMorning: {
id: "GoodMorning",
name: "Good Morning!",
description: "Greeted Hikari with a good morning",
icon: "🌅",
rarity: "common",
maxProgress: 1,
},
GoodNight: {
id: "GoodNight",
name: "Sweet Dreams",
description: "Said good night to Hikari",
icon: "🌙",
rarity: "common",
maxProgress: 1,
},
ThankYou: {
id: "ThankYou",
name: "Grateful Heart",
description: "Thanked Hikari for her help",
icon: "🙏",
rarity: "common",
maxProgress: 1,
},
LoveYou: {
id: "LoveYou",
name: "Heartfelt",
description: "Expressed love to Hikari",
icon: "💕",
rarity: "rare",
maxProgress: 1,
},
// Personality & Fun
EmojiUser: {
id: "EmojiUser",
name: "Emoji Master",
description: "Used 20+ emojis in messages",
icon: "😄",
rarity: "common",
maxProgress: 20,
},
CapsLock: {
id: "CapsLock",
name: "CAPS LOCK",
description: "SENT A MESSAGE IN ALL CAPS",
icon: "🔊",
rarity: "common",
maxProgress: 1,
},
QuestionMaster: {
id: "QuestionMaster",
name: "Question Master",
description: "Asked 50 questions",
icon: "❓",
rarity: "common",
maxProgress: 50,
},
PleaseAndThankYou: {
id: "PleaseAndThankYou",
name: "Polite Programmer",
description: "Always says please and thank you",
icon: "🎩",
rarity: "common",
maxProgress: 10,
},
// Git & Development
CommitMaster: {
id: "CommitMaster",
name: "Commit Master",
description: "Made 100 commits through Hikari",
icon: "📝",
rarity: "rare",
maxProgress: 100,
},
PRO: {
id: "PRO",
name: "PRO",
description: "Created 10 pull requests",
icon: "🔀",
rarity: "rare",
maxProgress: 10,
},
Reviewer: {
id: "Reviewer",
name: "Code Reviewer",
description: "Reviewed 10 pull requests",
icon: "👀",
rarity: "rare",
maxProgress: 10,
},
IssueTracker: {
id: "IssueTracker",
name: "Issue Tracker",
description: "Created 25 issues",
icon: "🎯",
rarity: "rare",
maxProgress: 25,
},
GitGuru: {
id: "GitGuru",
name: "Git Guru",
description: "Mastered git operations",
icon: "🌲",
rarity: "epic",
},
// Tool Mastery
BashMaster: {
id: "BashMaster",
name: "Bash Master",
description: "Used bash commands 100 times",
icon: "💻",
rarity: "rare",
maxProgress: 100,
},
FileExplorer: {
id: "FileExplorer",
name: "File Explorer",
description: "Explored files 100 times",
icon: "📂",
rarity: "common",
maxProgress: 100,
},
SearchExpert: {
id: "SearchExpert",
name: "Search Expert",
description: "Mastered advanced search queries",
icon: "🔎",
rarity: "rare",
},
AgentCommander: {
id: "AgentCommander",
name: "Agent Commander",
description: "Used task agents effectively",
icon: "🤖",
rarity: "rare",
},
MCPMaster: {
id: "MCPMaster",
name: "MCP Master",
description: "Mastered MCP tool usage",
icon: "🛠️",
rarity: "epic",
},
};
// Initialize all achievements as locked
const initialAchievements: Record<AchievementId, Achievement> = {} as Record<
AchievementId,
Achievement
>;
for (const [id, def] of Object.entries(achievementDefinitions)) {
initialAchievements[id as AchievementId] = {
...def,
unlocked: false,
progress: 0,
};
}
// Create the main store
function createAchievementsStore() {
const { subscribe, update } = writable<AchievementState>({
achievements: initialAchievements,
totalUnlocked: 0,
lastUnlocked: null,
});
return {
subscribe,
unlockAchievement: (event: AchievementUnlockedEvent, playSound: boolean = true) => {
update((state) => {
const achievement = state.achievements[event.achievement.id];
if (achievement && !achievement.unlocked) {
achievement.unlocked = true;
achievement.unlockedAt = event.achievement.unlocked_at
? new Date(event.achievement.unlocked_at)
: new Date();
state.totalUnlocked++;
state.lastUnlocked = achievement;
// Play achievement sound only for new unlocks, not when loading saved ones
if (playSound) {
try {
playAchievementSound();
} catch (error) {
console.error("Failed to play achievement sound:", error);
}
}
}
return state;
});
},
updateProgress: (id: AchievementId, progress: number) => {
update((state) => {
const achievement = state.achievements[id];
if (achievement) {
achievement.progress = progress;
}
return state;
});
},
reset: () => {
update(() => ({
achievements: initialAchievements,
totalUnlocked: 0,
lastUnlocked: null,
}));
},
};
}
export const achievementsStore = createAchievementsStore();
// Derived stores for different views
export const unlockedAchievements = derived(achievementsStore, ($store) =>
Object.values($store.achievements).filter((a) => a.unlocked)
);
export const lockedAchievements = derived(achievementsStore, ($store) =>
Object.values($store.achievements).filter((a) => !a.unlocked)
);
export const achievementsByRarity = derived(achievementsStore, ($store) => {
const byRarity: Record<string, Achievement[]> = {
common: [],
rare: [],
epic: [],
legendary: [],
};
for (const achievement of Object.values($store.achievements)) {
byRarity[achievement.rarity].push(achievement);
}
return byRarity;
});
export const achievementProgress = derived(achievementsStore, ($store) => ({
unlocked: $store.totalUnlocked,
total: Object.keys($store.achievements).length,
percentage: Math.round(($store.totalUnlocked / Object.keys($store.achievements).length) * 100),
}));
// Initialize achievement listener
export async function initAchievementsListener() {
// Listen for achievement unlocked events
await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
achievementsStore.unlockAchievement(event.payload);
});
// Load saved achievements from persistent storage
try {
const savedAchievements = await invoke<AchievementUnlockedEvent[]>("load_saved_achievements");
// Update the store with saved achievements (don't play sounds)
for (const event of savedAchievements) {
achievementsStore.unlockAchievement(event, false);
}
} catch (error) {
console.error("Failed to load saved achievements:", error);
}
}
// Export achievement categories for the display panel
export const achievementCategories = [
{
name: "Token Milestones",
description: "Track your token generation progress",
ids: ["FirstSteps", "GrowingStrong", "BlossomingCoder", "TokenMaster"] as AchievementId[],
},
{
name: "Code Generation",
description: "Achievements for generating code",
ids: ["HelloWorld", "CodeWizard", "ThousandBlocks"] as AchievementId[],
},
{
name: "File Operations",
description: "Working with files and projects",
ids: ["FileManipulator", "FileArchitect"] as AchievementId[],
},
{
name: "Conversations",
description: "Building our relationship through chat",
ids: ["ConversationStarter", "ChattyKathy", "Conversationalist"] as AchievementId[],
},
{
name: "Tools & Skills",
description: "Mastering different tools",
ids: ["Toolsmith", "ToolMaster"] as AchievementId[],
},
{
name: "Time-Based",
description: "When you code matters too!",
ids: [
"EarlyBird",
"NightOwl",
"AllNighter",
"WeekendWarrior",
"DedicatedDeveloper",
] as AchievementId[],
},
{
name: "Search & Explore",
description: "Finding what you need",
ids: ["Explorer", "MasterSearcher"] as AchievementId[],
},
{
name: "Session Records",
description: "Your coding session achievements",
ids: [
"QuickSession",
"FocusedWork",
"DeepDive",
"MarathonSession",
"MarathonCoder",
] as AchievementId[],
},
{
name: "Relationship & Greetings",
description: "Our special moments together",
ids: ["GoodMorning", "GoodNight", "ThankYou", "LoveYou"] as AchievementId[],
},
{
name: "Personality & Fun",
description: "Express yourself!",
ids: ["EmojiUser", "CapsLock", "QuestionMaster", "PleaseAndThankYou"] as AchievementId[],
},
{
name: "Git & Development",
description: "Version control mastery",
ids: ["CommitMaster", "PRO", "Reviewer", "IssueTracker", "GitGuru"] as AchievementId[],
},
{
name: "Tool Mastery",
description: "Master of all tools",
ids: [
"BashMaster",
"FileExplorer",
"SearchExpert",
"AgentCommander",
"MCPMaster",
] as AchievementId[],
},
{
name: "Special",
description: "Unique accomplishments",
ids: [
"FirstMessage",
"FirstTool",
"FirstCodeBlock",
"FirstFileEdit",
"Polyglot",
"SpeedCoder",
"ClaudeConnoisseur",
] as AchievementId[],
},
];
+97 -110
View File
@@ -1,127 +1,114 @@
import { writable, derived } from "svelte/store"; import { derived } from "svelte/store";
import type { ConnectionStatus, PermissionRequest } from "$lib/types/messages"; import { conversationsStore } from "./conversations";
import type { TerminalLine } from "$lib/types/messages";
import { characterState } from "$lib/stores/character";
import {
setShouldRestoreHistory,
setSavedHistory,
getShouldRestoreHistory,
getSavedHistory,
clearHistoryRestore,
} from "./historyRestore";
export interface TerminalLine { // Re-export TerminalLine type for backwards compatibility
id: string; export type { TerminalLine };
type: "user" | "assistant" | "system" | "tool" | "error";
content: string;
timestamp: Date;
toolName?: string;
}
function createClaudeStore() { // Re-export from conversations store for backwards compatibility
const connectionStatus = writable<ConnectionStatus>("disconnected"); export const claudeStore = {
const sessionId = writable<string | null>(null); // Existing subscriptions
const currentWorkingDirectory = writable<string>(""); connectionStatus: conversationsStore.connectionStatus,
const terminalLines = writable<TerminalLine[]>([]); sessionId: conversationsStore.sessionId,
const pendingPermission = writable<PermissionRequest | null>(null); currentWorkingDirectory: conversationsStore.currentWorkingDirectory,
const isProcessing = writable<boolean>(false); terminalLines: conversationsStore.terminalLines,
const grantedTools = writable<Set<string>>(new Set()); pendingPermission: conversationsStore.pendingPermission,
const pendingRetryMessage = writable<string | null>(null); pendingQuestion: conversationsStore.pendingQuestion,
isProcessing: conversationsStore.isProcessing,
grantedTools: conversationsStore.grantedTools,
pendingRetryMessage: conversationsStore.pendingRetryMessage,
let lineIdCounter = 0; // New conversation-aware subscriptions
conversations: conversationsStore.conversations,
activeConversationId: conversationsStore.activeConversationId,
activeConversation: conversationsStore.activeConversation,
function generateLineId(): string { // Methods
return `line-${Date.now()}-${lineIdCounter++}`; setConnectionStatus: conversationsStore.setConnectionStatus,
} setConnectionStatusForConversation: conversationsStore.setConnectionStatusForConversation,
setCharacterStateForConversation: conversationsStore.setCharacterStateForConversation,
setSessionId: conversationsStore.setSessionId,
setSessionIdForConversation: conversationsStore.setSessionIdForConversation,
setWorkingDirectory: conversationsStore.setWorkingDirectory,
setWorkingDirectoryForConversation: conversationsStore.setWorkingDirectoryForConversation,
setProcessing: conversationsStore.setProcessing,
addLine: conversationsStore.addLine,
addLineToConversation: conversationsStore.addLineToConversation,
updateLine: conversationsStore.updateLine,
appendToLine: conversationsStore.appendToLine,
clearTerminal: conversationsStore.clearTerminal,
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,
setPendingRetryMessage: conversationsStore.setPendingRetryMessage,
return { // Conversation management
connectionStatus: { subscribe: connectionStatus.subscribe }, createConversation: conversationsStore.createConversation,
sessionId: { subscribe: sessionId.subscribe }, deleteConversation: conversationsStore.deleteConversation,
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe }, switchConversation: conversationsStore.switchConversation,
terminalLines: { subscribe: terminalLines.subscribe }, renameConversation: conversationsStore.renameConversation,
pendingPermission: { subscribe: pendingPermission.subscribe }, saveScrollPosition: conversationsStore.saveScrollPosition,
isProcessing: { subscribe: isProcessing.subscribe }, getScrollPosition: conversationsStore.getScrollPosition,
grantedTools: { subscribe: grantedTools.subscribe },
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
setConnectionStatus: (status: ConnectionStatus) => connectionStatus.set(status),
setSessionId: (id: string | null) => sessionId.set(id),
setWorkingDirectory: (dir: string) => currentWorkingDirectory.set(dir),
setProcessing: (processing: boolean) => isProcessing.set(processing),
addLine: (type: TerminalLine["type"], content: string, toolName?: string) => {
const line: TerminalLine = {
id: generateLineId(),
type,
content,
timestamp: new Date(),
toolName,
};
terminalLines.update((lines) => [...lines, line]);
return line.id;
},
updateLine: (id: string, content: string) => {
terminalLines.update((lines) =>
lines.map((line) => (line.id === id ? { ...line, content } : line))
);
},
appendToLine: (id: string, additionalContent: string) => {
terminalLines.update((lines) =>
lines.map((line) =>
line.id === id ? { ...line, content: line.content + additionalContent } : line
)
);
},
clearTerminal: () => terminalLines.set([]),
getConversationHistory: (): string => {
let lines: TerminalLine[] = [];
terminalLines.subscribe((l) => (lines = l))();
// Filter to just user and assistant messages, skip system/tool noise
const relevantLines = lines.filter(
(line) => line.type === "user" || line.type === "assistant"
);
if (relevantLines.length === 0) return "";
return relevantLines
.map((line) => {
const role = line.type === "user" ? "User" : "Assistant";
return `${role}: ${line.content}`;
})
.join("\n\n");
},
requestPermission: (request: PermissionRequest) => pendingPermission.set(request),
clearPermission: () => pendingPermission.set(null),
grantTool: (toolName: string) => {
grantedTools.update((tools) => {
const newTools = new Set(tools);
newTools.add(toolName);
return newTools;
});
},
getGrantedTools: (): string[] => { getGrantedTools: (): string[] => {
let tools: string[] = []; let tools: string[] = [];
grantedTools.subscribe((t) => (tools = Array.from(t)))(); conversationsStore.grantedTools.subscribe((t) => (tools = Array.from(t)))();
return tools; return tools;
}, },
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message), // History restoration methods from main branch
setShouldRestoreHistory: setShouldRestoreHistory,
setSavedConversationHistory: setSavedHistory,
getShouldRestoreHistory: getShouldRestoreHistory,
getSavedConversationHistory: getSavedHistory,
reset: () => { reset: () => {
connectionStatus.set("disconnected"); // Reset only the active conversation
sessionId.set(null); conversationsStore.clearTerminal();
currentWorkingDirectory.set(""); conversationsStore.setSessionId(null);
terminalLines.set([]); conversationsStore.setWorkingDirectory("");
pendingPermission.set(null); conversationsStore.setProcessing(false);
isProcessing.set(false); conversationsStore.revokeAllTools();
grantedTools.set(new Set()); // Also clear history restoration
pendingRetryMessage.set(null); clearHistoryRestore();
}, },
}; };
}
export const claudeStore = createClaudeStore();
export const hasPermissionPending = derived( export const hasPermissionPending = derived(
claudeStore.pendingPermission, claudeStore.activeConversation,
($permission) => $permission !== null ($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)
export const isClaudeProcessing = derived(
[claudeStore.connectionStatus, characterState],
([$connectionStatus, $characterState]) => {
// Must be connected and in one of the processing states
return (
$connectionStatus === "connected" &&
["thinking", "typing", "searching", "coding", "mcp"].includes($characterState)
);
}
); );
+56
View File
@@ -12,6 +12,12 @@ export interface HikariConfig {
theme: Theme; theme: Theme;
greeting_enabled: boolean; greeting_enabled: boolean;
greeting_custom_prompt: string | null; greeting_custom_prompt: string | null;
notifications_enabled: boolean;
notification_volume: number;
always_on_top: boolean;
update_checks_enabled: boolean;
character_panel_width: number | null;
font_size: number;
} }
const defaultConfig: HikariConfig = { const defaultConfig: HikariConfig = {
@@ -23,6 +29,12 @@ const defaultConfig: HikariConfig = {
theme: "dark", theme: "dark",
greeting_enabled: true, greeting_enabled: true,
greeting_custom_prompt: null, 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,
}; };
function createConfigStore() { function createConfigStore() {
@@ -83,6 +95,33 @@ function createConfigStore() {
applyTheme(theme); applyTheme(theme);
}, },
setFontSize: async (size: number) => {
const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
await updateConfig({ font_size: clampedSize });
applyFontSize(clampedSize);
},
increaseFontSize: async () => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2);
await updateConfig({ font_size: newSize });
applyFontSize(newSize);
},
decreaseFontSize: async () => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2);
await updateConfig({ font_size: newSize });
applyFontSize(newSize);
},
resetFontSize: async () => {
await updateConfig({ font_size: DEFAULT_FONT_SIZE });
applyFontSize(DEFAULT_FONT_SIZE);
},
addAutoGrantedTool: async (tool: string) => { addAutoGrantedTool: async (tool: string) => {
let currentConfig: HikariConfig = defaultConfig; let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))(); config.subscribe((c) => (currentConfig = c))();
@@ -113,6 +152,23 @@ export function applyTheme(theme: Theme) {
} }
} }
const MIN_FONT_SIZE = 10;
const MAX_FONT_SIZE = 24;
const DEFAULT_FONT_SIZE = 14;
export function applyFontSize(size: number) {
if (typeof document !== "undefined") {
const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
document.documentElement.style.setProperty("--terminal-font-size", `${clampedSize}px`);
}
}
export function clampFontSize(size: number): number {
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
}
export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
export const configStore = createConfigStore(); export const configStore = createConfigStore();
export const isDarkTheme = derived(configStore.config, ($config) => $config.theme === "dark"); export const isDarkTheme = derived(configStore.config, ($config) => $config.theme === "dark");
+583
View File
@@ -0,0 +1,583 @@
import { writable, derived, get } from "svelte/store";
import type {
TerminalLine,
ConnectionStatus,
PermissionRequest,
UserQuestionEvent,
} from "$lib/types/messages";
import type { CharacterState } from "$lib/types/states";
import { cleanupConversationTracking } from "$lib/tauri";
import { characterState } from "$lib/stores/character";
export interface Conversation {
id: string;
name: string;
terminalLines: TerminalLine[];
sessionId: string | null;
connectionStatus: ConnectionStatus;
workingDirectory: string;
characterState: CharacterState;
isProcessing: boolean;
grantedTools: Set<string>;
pendingPermission: PermissionRequest | null;
pendingQuestion: UserQuestionEvent | null;
scrollPosition: number;
createdAt: Date;
lastActivityAt: Date;
}
function createConversationsStore() {
const conversations = writable<Map<string, Conversation>>(new Map());
const activeConversationId = writable<string | null>(null);
const pendingRetryMessage = writable<string | null>(null);
let conversationCounter = 0;
let lineIdCounter = 0;
function generateConversationId(): string {
return `conv-${Date.now()}-${conversationCounter++}`;
}
function generateLineId(): string {
return `line-${Date.now()}-${lineIdCounter++}`;
}
function createNewConversation(name?: string): Conversation {
const id = generateConversationId();
return {
id,
name: name || `Conversation ${conversationCounter}`,
terminalLines: [],
sessionId: null,
connectionStatus: "disconnected",
workingDirectory: "",
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(),
};
}
// Initialize with first conversation lazily
let initialized = false;
function ensureInitialized() {
if (!initialized) {
initialized = true;
const initialConversation = createNewConversation("Main");
conversations.update((convs) => {
convs.set(initialConversation.id, initialConversation);
return convs;
});
activeConversationId.set(initialConversation.id);
}
}
// Derived store for current conversation
const activeConversation = derived(
[conversations, activeConversationId],
([$conversations, $activeId]) => {
if (!$activeId) return null;
return $conversations.get($activeId) || null;
}
);
// Derived stores for compatibility with existing code
const connectionStatus = derived(
activeConversation,
($conv) => $conv?.connectionStatus || "disconnected"
);
const terminalLines = derived(activeConversation, ($conv) => {
return $conv?.terminalLines || [];
});
const sessionId = derived(activeConversation, ($conv) => $conv?.sessionId || null);
const currentWorkingDirectory = derived(
activeConversation,
($conv) => $conv?.workingDirectory || ""
);
const isProcessing = derived(activeConversation, ($conv) => $conv?.isProcessing || false);
const grantedTools = derived(
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);
return {
// Expose derived stores for compatibility
connectionStatus: { subscribe: connectionStatus.subscribe },
sessionId: { subscribe: sessionId.subscribe },
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 },
// New conversation-specific stores
conversations: { subscribe: conversations.subscribe },
activeConversationId: { subscribe: activeConversationId.subscribe },
activeConversation: { subscribe: activeConversation.subscribe },
// Connection management
setConnectionStatus: (status: ConnectionStatus) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.connectionStatus = status;
conv.lastActivityAt = new Date();
}
return convs;
});
},
setConnectionStatusForConversation: (conversationId: string, status: ConnectionStatus) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.connectionStatus = status;
conv.lastActivityAt = new Date();
}
return convs;
});
},
setCharacterStateForConversation: (conversationId: string, state: CharacterState) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.characterState = state;
// If this is the active conversation, update the global character state
if (conversationId === get(activeConversationId)) {
characterState.setState(state);
}
}
return convs;
});
},
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
createConversation: (name?: string) => {
ensureInitialized();
const newConv = createNewConversation(name);
conversations.update((convs) => {
convs.set(newConv.id, newConv);
return convs;
});
activeConversationId.set(newConv.id);
return newConv.id;
},
deleteConversation: (id: string) => {
ensureInitialized();
const convs = get(conversations);
const activeId = get(activeConversationId);
if (convs.size <= 1) {
// Don't delete the last conversation
return false;
}
// Clean up tracking for this conversation
cleanupConversationTracking(id);
conversations.update((c) => {
c.delete(id);
return c;
});
// If we deleted the active conversation, switch to another
if (activeId === id) {
const remaining = Array.from(get(conversations).keys());
if (remaining.length > 0) {
activeConversationId.set(remaining[0]);
}
}
return true;
},
switchConversation: async (id: string) => {
const convs = get(conversations);
if (!convs.has(id)) return;
const currentId = get(activeConversationId);
const targetConv = convs.get(id);
// If switching to a different conversation
if (currentId !== id) {
activeConversationId.set(id);
// Update the global character state to match the conversation's state
if (targetConv) {
characterState.setState(targetConv.characterState);
}
}
},
renameConversation: (id: string, newName: string) => {
conversations.update((convs) => {
const conv = convs.get(id);
if (conv) {
conv.name = newName;
conv.lastActivityAt = new Date();
}
return convs;
});
},
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();
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.sessionId = id;
conv.lastActivityAt = new Date();
}
return convs;
});
},
setSessionIdForConversation: (conversationId: string, id: string | null) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.sessionId = id;
conv.lastActivityAt = new Date();
}
return convs;
});
},
setWorkingDirectory: (dir: string) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.workingDirectory = dir;
conv.lastActivityAt = new Date();
}
return convs;
});
},
setWorkingDirectoryForConversation: (conversationId: string, dir: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.workingDirectory = dir;
conv.lastActivityAt = new Date();
}
return convs;
});
},
setProcessing: (processing: boolean) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.isProcessing = processing;
conv.lastActivityAt = new Date();
}
return convs;
});
},
addLine: (type: TerminalLine["type"], content: string, toolName?: string) => {
ensureInitialized();
const activeId = get(activeConversationId);
if (!activeId) return "";
const line: TerminalLine = {
id: generateLineId(),
type,
content,
timestamp: new Date(),
toolName,
};
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.terminalLines.push(line);
conv.lastActivityAt = new Date();
}
return convs;
});
return line.id;
},
addLineToConversation: (
conversationId: string,
type: TerminalLine["type"],
content: string,
toolName?: string
) => {
ensureInitialized();
const line: TerminalLine = {
id: generateLineId(),
type,
content,
timestamp: new Date(),
toolName,
};
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.terminalLines.push(line);
conv.lastActivityAt = new Date();
}
return convs;
});
return line.id;
},
updateLine: (id: string, content: string) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
const line = conv.terminalLines.find((l) => l.id === id);
if (line) {
line.content = content;
conv.lastActivityAt = new Date();
}
}
return convs;
});
},
appendToLine: (id: string, additionalContent: string) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
const line = conv.terminalLines.find((l) => l.id === id);
if (line) {
line.content += additionalContent;
conv.lastActivityAt = new Date();
}
}
return convs;
});
},
clearTerminal: () => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.terminalLines = [];
conv.lastActivityAt = new Date();
}
return convs;
});
},
getConversationHistory: (): string => {
const activeId = get(activeConversationId);
if (!activeId) return "";
const convs = get(conversations);
const conv = convs.get(activeId);
if (!conv) return "";
const relevantLines = conv.terminalLines.filter(
(line) => line.type === "user" || line.type === "assistant"
);
if (relevantLines.length === 0) return "";
return relevantLines
.map((line) => {
const role = line.type === "user" ? "User" : "Assistant";
return `${role}: ${line.content}`;
})
.join("\n\n");
},
grantTool: (toolName: string) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.grantedTools.add(toolName);
conv.lastActivityAt = new Date();
}
return convs;
});
},
revokeAllTools: () => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.grantedTools.clear();
conv.lastActivityAt = new Date();
}
return convs;
});
},
isToolGranted: (toolName: string): boolean => {
const activeId = get(activeConversationId);
if (!activeId) return false;
const convs = get(conversations);
const conv = convs.get(activeId);
return conv?.grantedTools.has(toolName) || false;
},
// Add initialization helper
initialize: () => {
ensureInitialized();
},
};
}
export const conversationsStore = createConversationsStore();
// Initialize immediately
conversationsStore.initialize();
+24
View File
@@ -0,0 +1,24 @@
// Separate module for history restoration to ensure persistence across reconnects
let shouldRestore = false;
let savedHistory: string | null = null;
export function setShouldRestoreHistory(should: boolean) {
shouldRestore = should;
}
export function setSavedHistory(history: string | null) {
savedHistory = history;
}
export function getShouldRestoreHistory(): boolean {
return shouldRestore;
}
export function getSavedHistory(): string | null {
return savedHistory;
}
export function clearHistoryRestore() {
shouldRestore = false;
savedHistory = null;
}
+19
View File
@@ -0,0 +1,19 @@
import { writable } from "svelte/store";
// Default to chat mode
const messageModeStore = writable<string>("chat");
export const messageMode = {
subscribe: messageModeStore.subscribe,
set: (mode: string) => {
messageModeStore.set(mode);
},
reset: () => messageModeStore.set("chat"),
};
// Helper to get current mode
export function getCurrentMode(): string {
let currentMode = "chat";
messageMode.subscribe((mode) => (currentMode = mode))();
return currentMode;
}
+23
View File
@@ -0,0 +1,23 @@
import { configStore } from "./config";
import { soundPlayer } from "$lib/notifications";
let unsubscribe: (() => void) | null = null;
// Initialize notification settings sync - call this once on app startup
export function initNotificationSync() {
// Prevent duplicate subscriptions
if (unsubscribe) return;
unsubscribe = configStore.config.subscribe(($config) => {
soundPlayer.setEnabled($config.notifications_enabled);
soundPlayer.setGlobalVolume($config.notification_volume);
});
}
// Cleanup function if needed
export function cleanupNotificationSync() {
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
}
+63
View File
@@ -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);
+130
View File
@@ -0,0 +1,130 @@
import { writable, derived } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
export interface UsageStats {
total_input_tokens: number;
total_output_tokens: number;
total_cost_usd: number;
session_input_tokens: number;
session_output_tokens: number;
session_cost_usd: number;
model: string | null;
// New fields
messages_exchanged: number;
session_messages_exchanged: number;
code_blocks_generated: number;
session_code_blocks_generated: number;
files_edited: number;
session_files_edited: number;
files_created: number;
session_files_created: number;
tools_usage: Record<string, number>;
session_tools_usage: Record<string, number>;
session_duration_seconds: number;
}
// Main stats store
export const stats = writable<UsageStats>({
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,
});
// Derived store for formatted display values
export const formattedStats = derived(stats, ($stats) => {
const formatNumber = (num: number) => num.toLocaleString();
const formatCost = (cost: number) => `$${cost.toFixed(4)}`;
const formatDuration = (seconds: number) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${secs}s`;
} else if (minutes > 0) {
return `${minutes}m ${secs}s`;
} else {
return `${secs}s`;
}
};
return {
totalTokens: formatNumber($stats.total_input_tokens + $stats.total_output_tokens),
totalInputTokens: formatNumber($stats.total_input_tokens),
totalOutputTokens: formatNumber($stats.total_output_tokens),
totalCost: formatCost($stats.total_cost_usd),
sessionTokens: formatNumber($stats.session_input_tokens + $stats.session_output_tokens),
sessionInputTokens: formatNumber($stats.session_input_tokens),
sessionOutputTokens: formatNumber($stats.session_output_tokens),
sessionCost: formatCost($stats.session_cost_usd),
model: $stats.model || "No model selected",
// New formatted fields
messagesTotal: formatNumber($stats.messages_exchanged),
messagesSession: formatNumber($stats.session_messages_exchanged),
codeBlocksTotal: formatNumber($stats.code_blocks_generated),
codeBlocksSession: formatNumber($stats.session_code_blocks_generated),
filesEditedTotal: formatNumber($stats.files_edited),
filesEditedSession: formatNumber($stats.session_files_edited),
filesCreatedTotal: formatNumber($stats.files_created),
filesCreatedSession: formatNumber($stats.session_files_created),
sessionDuration: formatDuration($stats.session_duration_seconds),
toolsUsage: $stats.tools_usage,
sessionToolsUsage: $stats.session_tools_usage,
};
});
// Note: Cost calculation is now done in the Rust backend
// Initialize stats listener
export async function initStatsListener() {
// Listen for stats updates from the backend
await listen("claude:stats", (event) => {
const payload = event.payload as { stats: UsageStats };
const { stats: newStats } = payload;
// The backend already tracks all totals - just set the stats directly
stats.set(newStats);
});
// Load initial stats from backend
try {
const initialStats = await invoke<UsageStats>("get_usage_stats");
stats.set(initialStats);
} catch (error) {
console.error("Failed to load initial stats:", error);
}
}
// Reset session stats (call when starting new session)
export function resetSessionStats() {
stats.update((current) => ({
...current,
session_input_tokens: 0,
session_output_tokens: 0,
session_cost_usd: 0,
session_messages_exchanged: 0,
session_code_blocks_generated: 0,
session_files_edited: 0,
session_files_created: 0,
session_tools_usage: {},
session_duration_seconds: 0,
}));
}
+241 -26
View File
@@ -1,14 +1,36 @@
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character"; import { characterState } from "$lib/stores/character";
import { configStore } from "$lib/stores/config"; import { configStore } from "$lib/stores/config";
import type { ConnectionStatus, PermissionPromptEvent } from "$lib/types/messages"; import { initStatsListener, resetSessionStats } from "$lib/stores/stats";
import { initAchievementsListener } from "$lib/stores/achievements";
import type {
ConnectionStatus,
PermissionPromptEvent,
UserQuestionEvent,
} from "$lib/types/messages";
import type { CharacterState } from "$lib/types/states"; import type { CharacterState } from "$lib/types/states";
import {
initializeNotificationRules,
cleanupNotificationRules,
handleConnectionStatusChange,
handleNewUserMessage,
} from "$lib/notifications/rules";
interface StateChangePayload { interface StateChangePayload {
state: CharacterState; state: CharacterState;
tool_name: string | null; tool_name: string | null;
conversation_id?: string;
}
const connectedConversations = new Set<string>();
let unlisteners: Array<() => void> = [];
let skipNextGreeting = false;
export function setSkipNextGreeting(skip: boolean) {
skipNextGreeting = skip;
} }
function getTimeOfDay(): string { function getTimeOfDay(): string {
@@ -30,7 +52,13 @@ function generateGreetingPrompt(): string {
return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`; return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`;
} }
async function sendGreeting() { async function sendGreeting(conversationId: string) {
// Check if we should skip this greeting
if (skipNextGreeting) {
skipNextGreeting = false; // Reset the flag
return;
}
const config = configStore.getConfig(); const config = configStore.getConfig();
if (!config.greeting_enabled) { if (!config.greeting_enabled) {
@@ -42,11 +70,17 @@ async function sendGreeting() {
// Don't show the system prompt in the UI - just trigger Claude to respond // Don't show the system prompt in the UI - just trigger Claude to respond
characterState.setState("thinking"); characterState.setState("thinking");
// Reset notification state for greeting
handleNewUserMessage();
try { try {
await invoke("send_prompt", { message: greetingPrompt }); await invoke("send_prompt", {
conversationId,
message: greetingPrompt,
});
} catch (error) { } catch (error) {
console.error("Failed to send greeting:", error); console.error("Failed to send greeting:", error);
claudeStore.addLine("error", `Failed to send greeting: ${error}`); claudeStore.addLineToConversation(conversationId, "error", `Failed to send greeting: ${error}`);
characterState.setTemporaryState("error", 3000); characterState.setTemporaryState("error", 3000);
} }
} }
@@ -55,29 +89,113 @@ interface OutputPayload {
line_type: string; line_type: string;
content: string; content: string;
tool_name: string | null; tool_name: string | null;
conversation_id?: string;
}
interface ConnectionPayload {
status: ConnectionStatus;
conversation_id?: string;
}
interface SessionPayload {
session_id: string;
conversation_id?: string;
}
interface WorkingDirectoryPayload {
directory: string;
conversation_id?: string;
}
export function cleanupConversationTracking(conversationId: string) {
connectedConversations.delete(conversationId);
} }
export async function initializeTauriListeners() { export async function initializeTauriListeners() {
await listen<string>("claude:connection", async (event) => { // Cleanup any existing listeners first
const status = event.payload as ConnectionStatus; cleanupTauriListeners();
// Initialize notification rules
initializeNotificationRules();
// Initialize stats listener
await initStatsListener();
// Initialize achievements listener
await initAchievementsListener();
const connectionUnlisten = await listen<ConnectionPayload>("claude:connection", async (event) => {
const { status, conversation_id } = event.payload;
// Update connection status for the specific conversation
if (conversation_id) {
claudeStore.setConnectionStatusForConversation(conversation_id, status);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.setConnectionStatus(status); claudeStore.setConnectionStatus(status);
}
// Handle notification for connection status
handleConnectionStatusChange(status);
if (status === "connected") { if (status === "connected") {
claudeStore.addLine("system", "Connected to Claude Code"); // Get the actual conversation ID (fallback to active if not provided)
characterState.setState("idle"); const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
// Send greeting when connection is established
await sendGreeting(); if (targetConversationId) {
// Add system message to the correct conversation
claudeStore.addLineToConversation(
targetConversationId,
"system",
"Connected to Claude Code"
);
// Update character state for this conversation
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
// Check if this specific conversation has connected before
if (!connectedConversations.has(targetConversationId)) {
connectedConversations.add(targetConversationId);
resetSessionStats(); // Reset session stats on new connection
await sendGreeting(targetConversationId);
}
}
} else if (status === "disconnected") { } else if (status === "disconnected") {
claudeStore.addLine("system", "Disconnected from Claude Code"); const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
characterState.setState("idle");
// Only remove from connected set if we're not about to reconnect
if (!skipNextGreeting && targetConversationId) {
connectedConversations.delete(targetConversationId);
}
// Don't add system message if we're about to reconnect
if (!skipNextGreeting && targetConversationId) {
claudeStore.addLineToConversation(
targetConversationId,
"system",
"Disconnected from Claude Code"
);
}
// Update character state for this conversation
if (targetConversationId) {
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
}
} else if (status === "error") { } else if (status === "error") {
claudeStore.addLine("error", "Connection error"); const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (targetConversationId) {
connectedConversations.delete(targetConversationId);
claudeStore.addLineToConversation(targetConversationId, "error", "Connection error");
}
characterState.setTemporaryState("error", 3000); characterState.setTemporaryState("error", 3000);
} }
}); });
unlisteners.push(connectionUnlisten);
await listen<StateChangePayload>("claude:state", (event) => { const stateUnlisten = await listen<StateChangePayload>("claude:state", (event) => {
const { state } = event.payload; const { state, conversation_id } = event.payload;
const stateMap: Record<string, CharacterState> = { const stateMap: Record<string, CharacterState> = {
idle: "idle", idle: "idle",
@@ -93,37 +211,106 @@ export async function initializeTauriListeners() {
const mappedState = stateMap[state.toLowerCase()] || "idle"; const mappedState = stateMap[state.toLowerCase()] || "idle";
// Always update the conversation's state
if (conversation_id) {
claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
} else {
// Fallback to active conversation if no conversation_id
const activeConversationId = get(claudeStore.activeConversationId);
if (activeConversationId) {
claudeStore.setCharacterStateForConversation(activeConversationId, mappedState);
}
}
// Only update global character state for active conversation
const activeConversationId = get(claudeStore.activeConversationId);
if (!conversation_id || conversation_id === activeConversationId) {
if (mappedState === "success" || mappedState === "error") { if (mappedState === "success" || mappedState === "error") {
characterState.setTemporaryState(mappedState, 3000); characterState.setTemporaryState(mappedState, 3000);
} else { } else {
characterState.setState(mappedState); characterState.setState(mappedState);
} }
}
}); });
unlisteners.push(stateUnlisten);
await listen<OutputPayload>("claude:output", (event) => { const outputUnlisten = await listen<OutputPayload>("claude:output", (event) => {
const { line_type, content, tool_name } = event.payload; const { line_type, content, tool_name, conversation_id } = event.payload;
// Always store the output to the correct conversation
if (conversation_id) {
claudeStore.addLineToConversation(
conversation_id,
line_type as "user" | "assistant" | "system" | "tool" | "error",
content,
tool_name || undefined
);
} else {
// Fallback to active conversation if no conversation_id provided
claudeStore.addLine( claudeStore.addLine(
line_type as "user" | "assistant" | "system" | "tool" | "error", line_type as "user" | "assistant" | "system" | "tool" | "error",
content, content,
tool_name || undefined tool_name || undefined
); );
}
}); });
unlisteners.push(outputUnlisten);
await listen<string>("claude:stream", () => { const streamUnlisten = await listen<string>("claude:stream", () => {
// no-op // no-op
}); });
unlisteners.push(streamUnlisten);
await listen<string>("claude:session", (event) => { const sessionUnlisten = await listen<SessionPayload>("claude:session", (event) => {
claudeStore.setSessionId(event.payload); const { session_id, conversation_id } = event.payload;
claudeStore.addLine("system", `Session: ${event.payload.substring(0, 8)}...`);
// Store session ID for the correct conversation
if (conversation_id) {
claudeStore.setSessionIdForConversation(conversation_id, session_id);
claudeStore.addLineToConversation(
conversation_id,
"system",
`Session: ${session_id.substring(0, 8)}...`
);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.setSessionId(session_id);
claudeStore.addLine("system", `Session: ${session_id.substring(0, 8)}...`);
}
}); });
unlisteners.push(sessionUnlisten);
await listen<string>("claude:cwd", (event) => { const cwdUnlisten = await listen<WorkingDirectoryPayload>("claude:cwd", (event) => {
claudeStore.setWorkingDirectory(event.payload); const { directory, conversation_id } = event.payload;
// Store working directory for the correct conversation
if (conversation_id) {
claudeStore.setWorkingDirectoryForConversation(conversation_id, directory);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.setWorkingDirectory(directory);
}
}); });
unlisteners.push(cwdUnlisten);
await listen<PermissionPromptEvent>("claude:permission", (event) => { const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
const { id, tool_name, tool_input, description } = event.payload; const { id, tool_name, tool_input, description, conversation_id } = event.payload;
// 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({ claudeStore.requestPermission({
id, id,
tool: tool_name, tool: tool_name,
@@ -131,7 +318,35 @@ export async function initializeTauriListeners() {
input: tool_input, input: tool_input,
}); });
claudeStore.addLine("system", `Permission requested for: ${tool_name}`); claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
}
}); });
unlisteners.push(permissionUnlisten);
console.log("Tauri event listeners initialized"); 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() {
// Cleanup all event listeners
unlisteners.forEach((unlisten) => unlisten());
unlisteners = [];
// Cleanup notification rules
cleanupNotificationRules();
} }
+93
View File
@@ -0,0 +1,93 @@
export interface AchievementUnlockedEvent {
achievement: {
id: AchievementId;
name: string;
description: string;
icon: string;
unlocked_at: string | null;
};
}
export type AchievementId =
// Token Milestones
| "FirstSteps" // 1,000 tokens
| "GrowingStrong" // 10,000 tokens
| "BlossomingCoder" // 100,000 tokens
| "TokenMaster" // 1,000,000 tokens
// Code Generation
| "HelloWorld" // First code block
| "CodeWizard" // 100 code blocks
| "ThousandBlocks" // 1,000 code blocks
// File Operations
| "FileManipulator" // 10 files edited
| "FileArchitect" // 100 files edited
// Conversation milestones
| "ConversationStarter" // 10 messages
| "ChattyKathy" // 100 messages
| "Conversationalist" // 1,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
// Special achievements
| "FirstMessage" // First message sent
| "FirstTool" // First tool used
| "FirstCodeBlock" // First code generated
| "FirstFileEdit" // First file edit
| "Polyglot" // 5+ languages in one session
| "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
// Personality & Fun
| "EmojiUser" // Used 20+ emojis
| "CapsLock" // ALL CAPS MESSAGE
| "QuestionMaster" // Asked 50 questions
| "PleaseAndThankYou" // Polite user
// Git & Development
| "CommitMaster" // 100 commits
| "PRO" // Created 10 PRs
| "Reviewer" // Reviewed 10 PRs
| "IssueTracker" // Created 25 issues
| "GitGuru" // Used git commands
// Tool Mastery
| "BashMaster" // Used bash 100 times
| "FileExplorer" // Searched files 100 times
| "SearchExpert" // Advanced searches
| "AgentCommander" // Used task agents
| "MCPMaster"; // Used MCP tools
export interface Achievement {
id: AchievementId;
name: string;
description: string;
icon: string;
rarity: "common" | "rare" | "epic" | "legendary";
unlocked: boolean;
unlockedAt?: Date;
progress?: number;
maxProgress?: number;
}
export interface AchievementCategory {
name: string;
description: string;
achievements: Achievement[];
}
+69
View File
@@ -0,0 +1,69 @@
export interface MessageMode {
id: string;
name: string;
description: string;
prefix?: string;
icon: string;
}
export const MESSAGE_MODES: MessageMode[] = [
{
id: "chat",
name: "Chat",
description: "Normal conversation mode",
icon: "💬",
},
{
id: "architect",
name: "Architect",
description: "High-level design and architecture planning",
prefix: "[Architect Mode] ",
icon: "🏗️",
},
{
id: "code",
name: "Code",
description: "Focused on writing and editing code",
prefix: "[Code Mode] ",
icon: "💻",
},
{
id: "debug",
name: "Debug",
description: "Help with debugging and troubleshooting",
prefix: "[Debug Mode] ",
icon: "🐛",
},
{
id: "ask",
name: "Ask",
description: "Technical questions and explanations",
prefix: "[Ask Mode] ",
icon: "❓",
},
{
id: "review",
name: "Review",
description: "Code review and feedback",
prefix: "[Review Mode] ",
icon: "👀",
},
];
export function getMessageMode(id: string): MessageMode | undefined {
return MESSAGE_MODES.find((mode) => mode.id === id);
}
export function formatMessageWithMode(message: string, modeId: string): string {
const mode = getMessageMode(modeId);
if (!mode || !mode.prefix) {
return message;
}
// Don't double-prefix if the message already starts with the prefix
if (message.startsWith(mode.prefix)) {
return message;
}
return mode.prefix + message;
}
+31
View File
@@ -1,3 +1,11 @@
export interface TerminalLine {
id: string;
type: "user" | "assistant" | "system" | "tool" | "error";
content: string;
timestamp: Date;
toolName?: string;
}
export interface SystemInitMessage { export interface SystemInitMessage {
type: "system"; type: "system";
subtype: "init"; subtype: "init";
@@ -115,6 +123,29 @@ export interface PermissionPromptEvent {
tool_name: string; tool_name: string;
tool_input: Record<string, unknown>; tool_input: Record<string, unknown>;
description: string; description: string;
conversation_id?: string;
}
export interface QuestionOption {
label: string;
description?: string;
}
export interface UserQuestionEvent {
id: string;
question: string;
header?: string;
options: QuestionOption[];
multi_select: boolean;
conversation_id?: string;
} }
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"; export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
export interface UpdateInfo {
current_version: string;
latest_version: string;
has_update: boolean;
release_url: string;
release_notes?: string;
}
+193 -8
View File
@@ -1,44 +1,225 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount, onDestroy } from "svelte";
import { initializeTauriListeners } from "$lib/tauri"; import { invoke } from "@tauri-apps/api/core";
import { configStore, applyTheme } from "$lib/stores/config"; import { get } from "svelte/store";
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
import { configStore, applyTheme, applyFontSize } 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 } from "@tauri-apps/api/window";
import "$lib/notifications/testNotifications";
import Terminal from "$lib/components/Terminal.svelte"; import Terminal from "$lib/components/Terminal.svelte";
import InputBar from "$lib/components/InputBar.svelte"; import InputBar from "$lib/components/InputBar.svelte";
import StatusBar from "$lib/components/StatusBar.svelte"; import StatusBar from "$lib/components/StatusBar.svelte";
import AnimeGirl from "$lib/components/AnimeGirl.svelte"; import AnimeGirl from "$lib/components/AnimeGirl.svelte";
import PermissionModal from "$lib/components/PermissionModal.svelte"; import PermissionModal from "$lib/components/PermissionModal.svelte";
import UserQuestionModal from "$lib/components/UserQuestionModal.svelte";
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte"; import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
import AchievementNotification from "$lib/components/AchievementNotification.svelte";
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
import UpdateNotification from "$lib/components/UpdateNotification.svelte";
let initialized = false;
let updateNotification: UpdateNotification;
let achievementPanelOpen = $state(false);
// Resizable panel state
let panelWidth = $state(320); // Default width in pixels
let isResizing = $state(false);
const MIN_PANEL_WIDTH = 200;
const MAX_PANEL_WIDTH = 600;
function startResize(event: MouseEvent) {
isResizing = true;
event.preventDefault();
document.addEventListener("mousemove", handleResize);
document.addEventListener("mouseup", stopResize);
}
function handleResize(event: MouseEvent) {
if (!isResizing) return;
const newWidth = event.clientX;
panelWidth = Math.max(MIN_PANEL_WIDTH, Math.min(MAX_PANEL_WIDTH, newWidth));
}
function stopResize() {
if (isResizing) {
isResizing = false;
document.removeEventListener("mousemove", handleResize);
document.removeEventListener("mouseup", stopResize);
// Save the panel width to config
configStore.updateConfig({ character_panel_width: panelWidth });
}
}
// Global keyboard shortcuts
function handleGlobalKeydown(event: KeyboardEvent) {
// Don't trigger shortcuts when typing in inputs (except for specific ones)
const target = event.target as HTMLElement;
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA";
// Escape closes panels (always works)
if (event.key === "Escape") {
// Check if any panels are open and close them
if (achievementPanelOpen) {
achievementPanelOpen = false;
event.preventDefault();
return;
}
// ConfigSidebar handles its own escape via store
if (get(configStore.isSidebarOpen)) {
configStore.closeSidebar();
event.preventDefault();
return;
}
}
// Skip other shortcuts if user is typing in an input
if (isInputFocused) return;
// Ctrl+L - Clear terminal
if (event.ctrlKey && event.key === "l") {
event.preventDefault();
claudeStore.clearTerminal();
return;
}
// Ctrl+, - Open settings
if (event.ctrlKey && event.key === ",") {
event.preventDefault();
configStore.openSidebar();
return;
}
// Ctrl+C - Interrupt (only when processing)
if (event.ctrlKey && event.key === "c") {
if (get(isClaudeProcessing)) {
event.preventDefault();
handleInterrupt();
return;
}
}
// Ctrl++ or Ctrl+= - Increase font size
if (event.ctrlKey && (event.key === "+" || event.key === "=")) {
event.preventDefault();
configStore.increaseFontSize();
return;
}
// Ctrl+- - Decrease font size
if (event.ctrlKey && event.key === "-") {
event.preventDefault();
configStore.decreaseFontSize();
return;
}
// Ctrl+0 - Reset font size
if (event.ctrlKey && event.key === "0") {
event.preventDefault();
configStore.resetFontSize();
return;
}
}
async function handleInterrupt() {
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted");
} catch (error) {
console.error("Failed to interrupt:", error);
}
}
onMount(async () => { onMount(async () => {
if (!initialized) {
initialized = true;
// Initialize conversations store first to ensure activeConversationId is set
conversationsStore.initialize();
await initializeTauriListeners(); await initializeTauriListeners();
await configStore.loadConfig(); await configStore.loadConfig();
// Apply saved theme on startup // Apply saved settings on startup
const config = configStore.getConfig(); const config = configStore.getConfig();
applyTheme(config.theme); applyTheme(config.theme);
applyFontSize(config.font_size);
// Apply always-on-top setting
if (config.always_on_top) {
const window = getCurrentWindow();
await window.setAlwaysOnTop(true);
}
// Load saved panel width
if (config.character_panel_width) {
panelWidth = config.character_panel_width;
}
// Initialize notification settings sync
initNotificationSync();
// Add global keyboard shortcut listener
window.addEventListener("keydown", handleGlobalKeydown);
// Check for updates on startup
if (config.update_checks_enabled) {
updateNotification?.checkForUpdates();
}
}
});
onDestroy(() => {
if (initialized) {
cleanupTauriListeners();
cleanupNotificationSync();
window.removeEventListener("keydown", handleGlobalKeydown);
initialized = false;
}
}); });
</script> </script>
<div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"> <div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden">
<StatusBar /> <StatusBar onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)} />
<main class="flex-1 flex overflow-hidden"> <main class="flex-1 flex overflow-hidden">
<!-- Left panel: Character display --> <!-- Left panel: Character display -->
<div <div
class="character-panel w-1/3 flex flex-col items-center justify-center border-r border-[var(--border-color)] bg-[var(--bg-secondary)]/50" class="character-panel 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 /> <AnimeGirl />
</div> </div>
<!-- Resize handle -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="resize-handle w-1 cursor-col-resize bg-[var(--border-color)] hover:bg-[var(--accent-primary)] transition-colors flex-shrink-0"
class:bg-[var(--accent-primary)]={isResizing}
onmousedown={startResize}
></div>
<!-- Right panel: Terminal and input --> <!-- Right panel: Terminal and input -->
<div class="terminal-panel flex-1 flex flex-col"> <div class="terminal-panel flex-1 flex flex-col min-w-0">
<Terminal /> <Terminal />
<InputBar /> <InputBar />
</div> </div>
</main> </main>
<PermissionModal /> <PermissionModal />
<UserQuestionModal />
<ConfigSidebar /> <ConfigSidebar />
<AchievementNotification />
<AchievementsPanel
bind:isOpen={achievementPanelOpen}
onClose={() => (achievementPanelOpen = false)}
/>
<UpdateNotification bind:this={updateNotification} />
</div> </div>
<style> <style>
@@ -53,7 +234,11 @@
} }
.character-panel { .character-panel {
min-width: 320px;
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%); background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%);
} }
.resize-handle:hover,
.resize-handle:active {
width: 4px;
}
</style> </style>
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts">
import { testAchievementSound } from "$lib/sounds/achievement";
import { invoke } from "@tauri-apps/api/core";
async function testSound() {
testAchievementSound();
}
async function triggerAchievement() {
// This will trigger an achievement that hasn't been unlocked yet
try {
await invoke("check_achievements", {
eventType: "message_sent",
data: {},
});
} catch (error) {
console.error("Failed to trigger achievement:", error);
}
}
</script>
<div class="container mx-auto p-8">
<h1 class="text-2xl font-bold mb-6">Achievement Sound Test</h1>
<div class="space-y-4">
<button
onclick={testSound}
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Test Achievement Sound
</button>
<button
onclick={triggerAchievement}
class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
>
Trigger Test Achievement
</button>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-4">
Click the first button to test just the sound effect.<br />
Click the second button to trigger a real achievement (if any are available to unlock).
</p>
</div>
</div>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
<!doctype html>
<html>
<head>
<title>Notification Sound Test</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 600px;
margin: 50px auto;
padding: 20px;
background: #1a1a1a;
color: #fff;
}
.test-button {
padding: 10px 20px;
margin: 10px;
background: #0066ff;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
}
.test-button:hover {
background: #0052cc;
}
.info {
margin-top: 20px;
padding: 15px;
background: #333;
border-radius: 5px;
font-size: 14px;
line-height: 1.6;
}
.sound-item {
margin: 10px 0;
padding: 10px;
background: #2a2a2a;
border-radius: 3px;
}
</style>
</head>
<body>
<h1>🎵 Hikari Notification Test Page</h1>
<div class="info">
<h2>Required Sound Files:</h2>
<p>Please generate these TTS sounds and place them in <code>/static/sounds/</code>:</p>
<div class="sound-item">
<strong>im-done.mp3</strong> - "I'm done!" (cheerful, accomplished)
</div>
<div class="sound-item">
<strong>oh-no.mp3</strong> - "Oh no..." (concerned, but not panicked)
</div>
<div class="sound-item">
<strong>access-please.mp3</strong> - "Access please!" (polite, questioning)
</div>
<div class="sound-item"><strong>connected.mp3</strong> - "Connected!" (happy, energetic)</div>
<div class="sound-item">
<strong>working-on-it.mp3</strong> - "Working on it!" (determined, focused)
</div>
</div>
<h2>Test Sounds:</h2>
<p>Click to test each notification sound (once files are in place):</p>
<button class="test-button" onclick="playSound('im-done.mp3')">Success - "I'm done!"</button>
<button class="test-button" onclick="playSound('oh-no.mp3')">Error - "Oh no..."</button>
<button class="test-button" onclick="playSound('access-please.mp3')">
Permission - "Access please!"
</button>
<button class="test-button" onclick="playSound('connected.mp3')">
Connection - "Connected!"
</button>
<button class="test-button" onclick="playSound('working-on-it.mp3')">
Task Start - "Working on it!"
</button>
<script>
function playSound(filename) {
const audio = new Audio("/sounds/" + filename);
audio.volume = 0.7;
audio.play().catch((err) => {
alert(
"Sound file not found: " + filename + "\n\nPlease add the sound file to /static/sounds/"
);
console.error("Audio playback failed:", err);
});
}
</script>
</body>
</html>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 690 KiB

After

Width:  |  Height:  |  Size: 688 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 KiB

After

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 680 KiB

After

Width:  |  Height:  |  Size: 676 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 KiB

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 KiB

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 KiB

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 778 KiB

After

Width:  |  Height:  |  Size: 775 KiB