Compare commits
17 Commits
51c8ff8a9f
..
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
bbeff7ae2e
|
|||
| 3f30997f0e | |||
| 06810537a9 | |||
| 94991796be | |||
| 947e56ef41 | |||
| 9fe4e8a48a | |||
|
bc596867d4
|
|||
| e877f4aaf2 | |||
| 377f81d978 | |||
| d83697e5cf | |||
| 2d3adcab1c | |||
| 70fcaa8650 | |||
| a8f98406e1 | |||
| 0065bb4afc | |||
| ac84366716 | |||
| 2220c26c5e | |||
| c241544743 |
@@ -7,4 +7,5 @@
|
|||||||
*.png binary
|
*.png binary
|
||||||
*.jpg binary
|
*.jpg binary
|
||||||
*.icons binary
|
*.icons binary
|
||||||
*.ico binary
|
*.ico binary
|
||||||
|
*.icns binary
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Linux dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libayatana-appindicator3-dev
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
@@ -80,11 +90,11 @@ jobs:
|
|||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
libwebkit2gtk-4.1-dev \
|
libwebkit2gtk-4.1-dev \
|
||||||
libappindicator3-dev \
|
|
||||||
librsvg2-dev \
|
librsvg2-dev \
|
||||||
patchelf \
|
patchelf \
|
||||||
libgtk-3-dev \
|
libgtk-3-dev \
|
||||||
libayatana-appindicator3-dev
|
libayatana-appindicator3-dev \
|
||||||
|
xdg-utils
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
@@ -131,14 +141,14 @@ jobs:
|
|||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
libwebkit2gtk-4.1-dev \
|
libwebkit2gtk-4.1-dev \
|
||||||
libappindicator3-dev \
|
|
||||||
librsvg2-dev \
|
librsvg2-dev \
|
||||||
patchelf \
|
patchelf \
|
||||||
libgtk-3-dev \
|
libgtk-3-dev \
|
||||||
libayatana-appindicator3-dev \
|
libayatana-appindicator3-dev \
|
||||||
clang \
|
clang \
|
||||||
lld \
|
lld \
|
||||||
llvm
|
llvm \
|
||||||
|
nsis
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
@@ -160,7 +170,9 @@ jobs:
|
|||||||
targets: x86_64-pc-windows-msvc
|
targets: x86_64-pc-windows-msvc
|
||||||
|
|
||||||
- name: Install cargo-xwin
|
- name: Install cargo-xwin
|
||||||
run: cargo install cargo-xwin
|
run: |
|
||||||
|
curl -fsSL https://github.com/rust-cross/cargo-xwin/releases/download/v0.20.2/cargo-xwin-v0.20.2.x86_64-unknown-linux-musl.tar.gz | tar xz
|
||||||
|
sudo mv cargo-xwin /usr/local/bin/
|
||||||
|
|
||||||
- name: Cache Rust dependencies
|
- name: Cache Rust dependencies
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
|
|||||||
@@ -2,5 +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-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
|||||||
@@ -1,182 +1 @@
|
|||||||
# Hikari Desktop
|
tem
|
||||||
|
|
||||||
A Linux desktop application that wraps Claude Code with an anime girl character that reacts to Claude's activities in real-time.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Visual character that reflects Claude's current state (thinking, typing, searching, coding, etc.)
|
|
||||||
- Terminal-style output display
|
|
||||||
- Permission prompts with approve/deny interface
|
|
||||||
- Real-time state detection from Claude Code's NDJSON stream
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### 1. Install Claude Code
|
|
||||||
|
|
||||||
Hikari Desktop requires Claude Code to be installed and authenticated:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -g @anthropic-ai/claude-code
|
|
||||||
claude # Follow the prompts to authenticate
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Install Runtime Dependencies
|
|
||||||
|
|
||||||
**Debian/Ubuntu:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt install libwebkit2gtk-4.1-0 libgtk-3-0 libayatana-appindicator3-1 xdg-utils
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fedora:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo dnf install webkit2gtk4.1 gtk3 libappindicator-gtk3 xdg-utils
|
|
||||||
```
|
|
||||||
|
|
||||||
**Arch Linux:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo pacman -S webkit2gtk-4.1 gtk3 libappindicator-gtk3 xdg-utils
|
|
||||||
```
|
|
||||||
|
|
||||||
| Package | Purpose |
|
|
||||||
| --------------- | -------------------------------------------- |
|
|
||||||
| webkit2gtk-4.1 | WebView rendering (app UI) |
|
|
||||||
| gtk3 | Window management and native widgets |
|
|
||||||
| libappindicator | System tray support |
|
|
||||||
| xdg-utils | Opening URLs/files with default applications |
|
|
||||||
|
|
||||||
### 3. Install Hikari Desktop
|
|
||||||
|
|
||||||
Download the latest release for your platform.
|
|
||||||
|
|
||||||
#### Linux
|
|
||||||
|
|
||||||
**AppImage** (any distro):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chmod +x hikari-desktop_*.AppImage
|
|
||||||
./hikari-desktop_*.AppImage
|
|
||||||
```
|
|
||||||
|
|
||||||
**Debian/Ubuntu:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo dpkg -i hikari-desktop_*.deb
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fedora:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo rpm -i hikari-desktop-*.rpm
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Windows
|
|
||||||
|
|
||||||
The Windows build requires WSL (Windows Subsystem for Linux) with Claude Code installed inside WSL.
|
|
||||||
|
|
||||||
1. Install WSL if you haven't already: https://learn.microsoft.com/en-us/windows/wsl/install
|
|
||||||
2. Install Claude Code inside your WSL distribution (see step 1 above)
|
|
||||||
3. Run the Windows installer (`.exe` or `.msi`)
|
|
||||||
|
|
||||||
## Character States
|
|
||||||
|
|
||||||
| State | Trigger |
|
|
||||||
| ---------- | ------------------------------- |
|
|
||||||
| Idle | Waiting for user input |
|
|
||||||
| Thinking | Processing/API call in progress |
|
|
||||||
| Typing | Streaming text output |
|
|
||||||
| Searching | Using Read/Glob/Grep tools |
|
|
||||||
| Coding | Using Edit/Write tools |
|
|
||||||
| MCP | Running MCP tool calls |
|
|
||||||
| Permission | Permission prompt needed |
|
|
||||||
| Success | Task completed |
|
|
||||||
| Error | Error occurred |
|
|
||||||
|
|
||||||
## Building from Source
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js and pnpm
|
|
||||||
- Rust toolchain
|
|
||||||
|
|
||||||
#### Windows Cross-Compilation (from Linux/WSL)
|
|
||||||
|
|
||||||
To build Windows binaries from Linux, install the following:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt install nsis lld llvm clang
|
|
||||||
```
|
|
||||||
|
|
||||||
You will also need `cargo-xwin`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo install cargo-xwin
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# Development mode
|
|
||||||
pnpm run dev
|
|
||||||
|
|
||||||
# Build for Linux (AppImage, deb, rpm)
|
|
||||||
pnpm build:linux
|
|
||||||
|
|
||||||
# Build for Windows (exe/msi/nsis)
|
|
||||||
pnpm build:windows
|
|
||||||
|
|
||||||
# Build all platforms
|
|
||||||
pnpm build:all
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Linux (Tauri App)
|
|
||||||
├── Svelte Frontend
|
|
||||||
│ ├── AnimeGirl (sprites + animations)
|
|
||||||
│ ├── Terminal (output display)
|
|
||||||
│ ├── InputBar (user input)
|
|
||||||
│ └── PermissionModal (approve/deny)
|
|
||||||
└── Rust Backend
|
|
||||||
├── Process Manager (spawn & communicate)
|
|
||||||
└── State Parser (NDJSON → character state)
|
|
||||||
│
|
|
||||||
│ stdin/stdout (NDJSON stream)
|
|
||||||
▼
|
|
||||||
claude -p --output-format stream-json --input-format stream-json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Tauri 2.x** - Desktop framework with Rust backend
|
|
||||||
- **Svelte 5** - Reactive frontend with runes
|
|
||||||
- **Tailwind CSS** - Styling
|
|
||||||
- **Tokio** - Async runtime for process management
|
|
||||||
|
|
||||||
## Feedback and Bugs
|
|
||||||
|
|
||||||
If you have feedback or a bug report, please feel free to open a ticket request in our [Discord](https://chat.nhcarrigan.com)!
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
If you would like to contribute to the project, you may create a Pull Request containing your proposed changes and we will review it as soon as we are able! Please review our [contributing guidelines](CONTRIBUTING.md) first.
|
|
||||||
|
|
||||||
## Code of Conduct
|
|
||||||
|
|
||||||
Before interacting with our community, please read our [Code of Conduct](CODE_OF_CONDUCT.md).
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This software is licensed under our [global software license](https://docs.nhcarrigan.com/#/license).
|
|
||||||
|
|
||||||
Copyright held by Naomi Carrigan.
|
|
||||||
|
|
||||||
## Contact
|
|
||||||
|
|
||||||
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`.
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,8 +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-shell": "^2.3.4"
|
"@tauri-apps/plugin-os": "^2",
|
||||||
|
"@tauri-apps/plugin-shell": "^2.3.4",
|
||||||
|
"@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",
|
||||||
|
|||||||
@@ -14,12 +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':
|
||||||
|
specifier: ^2
|
||||||
|
version: 2.4.2
|
||||||
|
highlight.js:
|
||||||
|
specifier: ^11.11.1
|
||||||
|
version: 11.11.1
|
||||||
|
marked:
|
||||||
|
specifier: ^17.0.1
|
||||||
|
version: 17.0.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.39.2
|
specifier: ^9.39.2
|
||||||
@@ -723,12 +738,21 @@ 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==}
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-store@2.4.2':
|
||||||
|
resolution: {integrity: sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==}
|
||||||
|
|
||||||
'@testing-library/dom@10.4.1':
|
'@testing-library/dom@10.4.1':
|
||||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1167,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}
|
||||||
@@ -1354,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==}
|
||||||
|
|
||||||
@@ -2257,14 +2290,26 @@ 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
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-store@2.4.2':
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.9.1
|
||||||
|
|
||||||
'@testing-library/dom@10.4.1':
|
'@testing-library/dom@10.4.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.28.6
|
'@babel/code-frame': 7.28.6
|
||||||
@@ -2750,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
|
||||||
@@ -2919,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: {}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -22,4 +22,19 @@ serde_json = "1"
|
|||||||
tokio = { version = "1", features = ["full"] }
|
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-notification = "2"
|
||||||
|
tauri-plugin-os = "2"
|
||||||
|
tauri-plugin-http = "2"
|
||||||
|
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",
|
||||||
|
] }
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 878 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -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(¤t_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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -1,44 +1,300 @@
|
|||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, State};
|
||||||
|
use tauri_plugin_http::reqwest;
|
||||||
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
use crate::wsl_bridge::SharedBridge;
|
use crate::achievements::{get_achievement_info, load_achievements, AchievementUnlockedEvent};
|
||||||
|
use crate::bridge_manager::SharedBridgeManager;
|
||||||
|
use crate::config::{ClaudeStartOptions, HikariConfig};
|
||||||
|
use crate::stats::UsageStats;
|
||||||
|
|
||||||
|
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,
|
||||||
working_dir: String,
|
options: ClaudeStartOptions,
|
||||||
allowed_tools: Option<Vec<String>>,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut bridge = bridge.lock();
|
let mut manager = bridge_manager.lock();
|
||||||
bridge.start(app, &working_dir, allowed_tools.unwrap_or_default())
|
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]
|
||||||
pub async fn select_wsl_directory() -> Result<String, String> {
|
pub async fn select_wsl_directory() -> Result<String, String> {
|
||||||
Ok("/home".to_string())
|
Ok("/home".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_config(app: AppHandle) -> Result<HikariConfig, String> {
|
||||||
|
let store = app.store("hikari-config.json").map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
match store.get(CONFIG_STORE_KEY) {
|
||||||
|
Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()),
|
||||||
|
None => Ok(HikariConfig::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), String> {
|
||||||
|
let store = app.store("hikari-config.json").map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let value = serde_json::to_value(&config).map_err(|e| e.to_string())?;
|
||||||
|
store.set(CONFIG_STORE_KEY, value);
|
||||||
|
store.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ClaudeStartOptions {
|
||||||
|
#[serde(default)]
|
||||||
|
pub working_dir: String,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_instructions: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub mcp_servers_json: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub allowed_tools: Vec<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub skip_greeting: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub resume_session_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HikariConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_instructions: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub mcp_servers_json: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_granted_tools: Vec<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub theme: Theme,
|
||||||
|
|
||||||
|
#[serde(default = "default_greeting_enabled")]
|
||||||
|
pub greeting_enabled: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
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 {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
model: None,
|
||||||
|
api_key: None,
|
||||||
|
custom_instructions: None,
|
||||||
|
mcp_servers_json: None,
|
||||||
|
auto_granted_tools: Vec::new(),
|
||||||
|
theme: Theme::default(),
|
||||||
|
greeting_enabled: true,
|
||||||
|
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 {
|
||||||
|
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)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Theme {
|
||||||
|
#[default]
|
||||||
|
Dark,
|
||||||
|
Light,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_config() {
|
||||||
|
let config = HikariConfig::default();
|
||||||
|
assert!(config.model.is_none());
|
||||||
|
assert!(config.api_key.is_none());
|
||||||
|
assert!(config.custom_instructions.is_none());
|
||||||
|
assert!(config.mcp_servers_json.is_none());
|
||||||
|
assert!(config.auto_granted_tools.is_empty());
|
||||||
|
assert_eq!(config.theme, Theme::Dark);
|
||||||
|
assert!(config.greeting_enabled);
|
||||||
|
assert!(config.greeting_custom_prompt.is_none());
|
||||||
|
assert!(!config.always_on_top);
|
||||||
|
assert!(config.update_checks_enabled);
|
||||||
|
assert!(config.character_panel_width.is_none());
|
||||||
|
assert_eq!(config.font_size, 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_serialization() {
|
||||||
|
let config = HikariConfig {
|
||||||
|
model: Some("claude-sonnet-4-20250514".to_string()),
|
||||||
|
api_key: None,
|
||||||
|
custom_instructions: Some("Be helpful".to_string()),
|
||||||
|
mcp_servers_json: None,
|
||||||
|
auto_granted_tools: vec!["Read".to_string(), "Glob".to_string()],
|
||||||
|
theme: Theme::Light,
|
||||||
|
greeting_enabled: true,
|
||||||
|
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 deserialized: HikariConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(deserialized.model, config.model);
|
||||||
|
assert_eq!(deserialized.custom_instructions, config.custom_instructions);
|
||||||
|
assert_eq!(deserialized.auto_granted_tools, config.auto_granted_tools);
|
||||||
|
assert_eq!(deserialized.theme, Theme::Light);
|
||||||
|
assert!(deserialized.greeting_enabled);
|
||||||
|
assert_eq!(
|
||||||
|
deserialized.greeting_custom_prompt,
|
||||||
|
Some("Hello!".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_theme_serialization() {
|
||||||
|
let dark = Theme::Dark;
|
||||||
|
let light = Theme::Light;
|
||||||
|
|
||||||
|
assert_eq!(serde_json::to_string(&dark).unwrap(), "\"dark\"");
|
||||||
|
assert_eq!(serde_json::to_string(&light).unwrap(), "\"light\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,63 @@
|
|||||||
|
mod achievements;
|
||||||
|
mod bridge_manager;
|
||||||
mod commands;
|
mod commands;
|
||||||
|
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())
|
||||||
.manage(bridge)
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
|
.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,
|
||||||
|
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");
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
}
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
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;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
|
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::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent};
|
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
|
||||||
|
use crate::config::ClaudeStartOptions;
|
||||||
|
use crate::stats::{StatsUpdateEvent, UsageStats};
|
||||||
|
use crate::types::{
|
||||||
|
CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, OutputEvent,
|
||||||
|
PermissionPromptEvent, QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent,
|
||||||
|
WorkingDirectoryEvent,
|
||||||
|
};
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
|
||||||
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
|
const 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"];
|
||||||
@@ -69,6 +77,9 @@ pub struct WslBridge {
|
|||||||
stdin: Option<ChildStdin>,
|
stdin: Option<ChildStdin>,
|
||||||
working_directory: String,
|
working_directory: String,
|
||||||
session_id: Option<String>,
|
session_id: Option<String>,
|
||||||
|
mcp_config_file: Option<NamedTempFile>,
|
||||||
|
stats: Arc<RwLock<UsageStats>>,
|
||||||
|
conversation_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WslBridge {
|
impl WslBridge {
|
||||||
@@ -78,45 +89,141 @@ impl WslBridge {
|
|||||||
stdin: None,
|
stdin: None,
|
||||||
working_directory: String::new(),
|
working_directory: String::new(),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
|
mcp_config_file: None,
|
||||||
|
stats: Arc::new(RwLock::new(UsageStats::new())),
|
||||||
|
conversation_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start(&mut self, app: AppHandle, working_dir: &str, allowed_tools: Vec<String>) -> Result<(), String> {
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
|
||||||
if self.process.is_some() {
|
if self.process.is_some() {
|
||||||
return Err("Process already running".to_string());
|
return Err("Process already running".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
self.working_directory = working_dir.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;
|
||||||
|
});
|
||||||
|
|
||||||
emit_connection_status(&app, ConnectionStatus::Connecting);
|
let working_dir = &options.working_dir;
|
||||||
|
self.working_directory = working_dir.clone();
|
||||||
|
|
||||||
|
emit_connection_status(
|
||||||
|
&app,
|
||||||
|
ConnectionStatus::Connecting,
|
||||||
|
self.conversation_id.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create temp file for MCP config if provided
|
||||||
|
let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json {
|
||||||
|
if !mcp_json.trim().is_empty() {
|
||||||
|
// Validate JSON before writing
|
||||||
|
serde_json::from_str::<serde_json::Value>(mcp_json)
|
||||||
|
.map_err(|e| format!("Invalid MCP servers JSON: {}", e))?;
|
||||||
|
|
||||||
|
let mut temp_file = NamedTempFile::new()
|
||||||
|
.map_err(|e| format!("Failed to create temp file for MCP config: {}", e))?;
|
||||||
|
temp_file
|
||||||
|
.write_all(mcp_json.as_bytes())
|
||||||
|
.map_err(|e| format!("Failed to write MCP config: {}", e))?;
|
||||||
|
temp_file
|
||||||
|
.flush()
|
||||||
|
.map_err(|e| format!("Failed to flush MCP config: {}", e))?;
|
||||||
|
|
||||||
|
let path = temp_file.path().to_string_lossy().to_string();
|
||||||
|
self.mcp_config_file = Some(temp_file);
|
||||||
|
Some(path)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Detect if we're running inside WSL or on Windows
|
// Detect if we're running inside WSL or on Windows
|
||||||
let is_wsl = detect_wsl();
|
let is_wsl = detect_wsl();
|
||||||
eprintln!("[DEBUG] is_wsl: {}", is_wsl);
|
eprintln!("[DEBUG] is_wsl: {}", is_wsl);
|
||||||
eprintln!("[DEBUG] allowed_tools: {:?}", allowed_tools);
|
eprintln!("[DEBUG] options: {:?}", options);
|
||||||
|
|
||||||
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",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Add model if specified
|
||||||
|
if let Some(ref model) = options.model {
|
||||||
|
if !model.is_empty() {
|
||||||
|
cmd.args(["--model", model]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add allowed tools if any
|
// Add allowed tools if any
|
||||||
for tool in &allowed_tools {
|
for tool in &options.allowed_tools {
|
||||||
cmd.args(["--allowedTools", tool]);
|
cmd.args(["--allowedTools", tool]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add custom instructions as system prompt if specified
|
||||||
|
if let Some(ref instructions) = options.custom_instructions {
|
||||||
|
if !instructions.is_empty() {
|
||||||
|
cmd.args(["--system-prompt", instructions]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add MCP config if provided
|
||||||
|
if let Some(ref mcp_path) = mcp_config_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
|
||||||
|
if let Some(ref api_key) = options.api_key {
|
||||||
|
if !api_key.is_empty() {
|
||||||
|
cmd.env("ANTHROPIC_API_KEY", api_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cmd
|
cmd
|
||||||
} else {
|
} else {
|
||||||
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
|
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
|
||||||
@@ -124,16 +231,52 @@ 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 '{}' && claude --output-format stream-json --input-format stream-json --verbose",
|
|
||||||
working_dir
|
// Set API key as environment variable if specified
|
||||||
|
if let Some(ref api_key) = options.api_key {
|
||||||
|
if !api_key.is_empty() {
|
||||||
|
claude_cmd.push_str(&format!("ANTHROPIC_API_KEY='{}' ", api_key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
claude_cmd.push_str(
|
||||||
|
"claude --output-format stream-json --input-format stream-json --verbose",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Add model if specified
|
||||||
|
if let Some(ref model) = options.model {
|
||||||
|
if !model.is_empty() {
|
||||||
|
claude_cmd.push_str(&format!(" --model '{}'", model));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add allowed tools if any
|
// Add allowed tools if any
|
||||||
for tool in &allowed_tools {
|
for tool in &options.allowed_tools {
|
||||||
claude_cmd.push_str(&format!(" --allowedTools '{}'", tool));
|
claude_cmd.push_str(&format!(" --allowedTools '{}'", tool));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add custom instructions as system prompt if specified
|
||||||
|
if let Some(ref instructions) = options.custom_instructions {
|
||||||
|
if !instructions.is_empty() {
|
||||||
|
// Escape single quotes in instructions
|
||||||
|
let escaped = instructions.replace('\'', "'\\''");
|
||||||
|
claude_cmd.push_str(&format!(" --system-prompt '{}'", escaped));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add MCP config if provided
|
||||||
|
if let Some(ref mcp_path) = mcp_config_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]);
|
||||||
|
|
||||||
@@ -161,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(())
|
||||||
}
|
}
|
||||||
@@ -200,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();
|
||||||
@@ -212,7 +438,16 @@ impl WslBridge {
|
|||||||
}
|
}
|
||||||
self.stdin = None;
|
self.stdin = None;
|
||||||
self.session_id = None;
|
self.session_id = None;
|
||||||
emit_connection_status(app, ConnectionStatus::Disconnected);
|
self.mcp_config_file = None; // Temp file is automatically deleted when dropped
|
||||||
|
|
||||||
|
// 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 {
|
||||||
@@ -222,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 {
|
||||||
@@ -230,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,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(
|
||||||
line_type: "error".to_string(),
|
"claude:output",
|
||||||
content: line,
|
OutputEvent {
|
||||||
tool_name: None,
|
line_type: "error".to_string(),
|
||||||
});
|
content: line,
|
||||||
|
tool_name: None,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -269,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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,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(
|
||||||
line_type: "tool".to_string(),
|
"claude:output",
|
||||||
content: desc,
|
OutputEvent {
|
||||||
tool_name: Some(name.clone()),
|
line_type: "tool".to_string(),
|
||||||
});
|
content: desc,
|
||||||
|
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
|
||||||
line_type: "assistant".to_string(),
|
let code_blocks = text.matches("```").count() / 2;
|
||||||
content: text.clone(),
|
for _ in 0..code_blocks {
|
||||||
tool_name: None,
|
stats.write().increment_code_blocks();
|
||||||
});
|
}
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:output",
|
||||||
|
OutputEvent {
|
||||||
|
line_type: "assistant".to_string(),
|
||||||
|
content: text.clone(),
|
||||||
|
tool_name: None,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ContentBlock::Thinking { thinking } => {
|
ContentBlock::Thinking { thinking } => {
|
||||||
state = CharacterState::Thinking;
|
state = CharacterState::Thinking;
|
||||||
let _ = app.emit("claude:output", OutputEvent {
|
let _ = app.emit(
|
||||||
line_type: "system".to_string(),
|
"claude:output",
|
||||||
content: format!("[Thinking] {}", thinking),
|
OutputEvent {
|
||||||
tool_name: None,
|
line_type: "system".to_string(),
|
||||||
});
|
content: format!("[Thinking] {}", thinking),
|
||||||
|
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 } => {
|
||||||
@@ -340,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 {
|
||||||
@@ -351,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(
|
||||||
line_type: "error".to_string(),
|
"claude:output",
|
||||||
content: text.clone(),
|
OutputEvent {
|
||||||
tool_name: None,
|
line_type: "error".to_string(),
|
||||||
});
|
content: text.clone(),
|
||||||
|
tool_name: None,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for permission denials and emit prompts for each
|
// 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" {
|
||||||
id: denial.tool_use_id.clone(),
|
if let Some(questions) = denial
|
||||||
tool_name: denial.tool_name.clone(),
|
.tool_input
|
||||||
tool_input: denial.tool_input.clone(),
|
.get("questions")
|
||||||
description,
|
.and_then(|q| q.as_array())
|
||||||
});
|
{
|
||||||
|
// For now, handle the first question (most common case)
|
||||||
|
if let Some(first_question) = questions.first() {
|
||||||
|
let question_text = first_question
|
||||||
|
.get("question")
|
||||||
|
.and_then(|q| q.as_str())
|
||||||
|
.unwrap_or("Claude has a question for you")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let header = first_question
|
||||||
|
.get("header")
|
||||||
|
.and_then(|h| h.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let multi_select = first_question
|
||||||
|
.get("multiSelect")
|
||||||
|
.and_then(|m| m.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let options: Vec<QuestionOption> = first_question
|
||||||
|
.get("options")
|
||||||
|
.and_then(|opts| opts.as_array())
|
||||||
|
.map(|opts| {
|
||||||
|
opts.iter()
|
||||||
|
.filter_map(|opt| {
|
||||||
|
let label =
|
||||||
|
opt.get("label").and_then(|l| l.as_str())?;
|
||||||
|
let description = opt
|
||||||
|
.get("description")
|
||||||
|
.and_then(|d| d.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
Some(QuestionOption {
|
||||||
|
label: label.to_string(),
|
||||||
|
description,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:question",
|
||||||
|
UserQuestionEvent {
|
||||||
|
id: denial.tool_use_id.clone(),
|
||||||
|
question: question_text,
|
||||||
|
header,
|
||||||
|
options,
|
||||||
|
multi_select,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
has_regular_denials = true;
|
||||||
|
let description =
|
||||||
|
format_tool_description(&denial.tool_name, &denial.tool_input);
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:permission",
|
||||||
|
PermissionPromptEvent {
|
||||||
|
id: denial.tool_use_id.clone(),
|
||||||
|
tool_name: denial.tool_name.clone(),
|
||||||
|
tool_input: denial.tool_input.clone(),
|
||||||
|
description,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show permission state if there were denials
|
// 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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,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)]
|
||||||
@@ -482,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]
|
||||||
@@ -506,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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -1,14 +1,64 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root,
|
||||||
|
[data-theme="dark"] {
|
||||||
--bg-primary: #1a1a2e;
|
--bg-primary: #1a1a2e;
|
||||||
--bg-secondary: #16213e;
|
--bg-secondary: #16213e;
|
||||||
--bg-terminal: #0f0f1a;
|
--bg-terminal: #0f0f1a;
|
||||||
|
--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"] {
|
||||||
|
--bg-primary: #f8f9fa;
|
||||||
|
--bg-secondary: #ffffff;
|
||||||
|
--bg-terminal: #f1f3f4;
|
||||||
|
--bg-hover: #e8e8e8;
|
||||||
|
--bg-code: #f5f5f5;
|
||||||
|
--accent-primary: #e94560;
|
||||||
|
--accent-secondary: #ff6b9d;
|
||||||
|
--text-primary: #1a1a2e;
|
||||||
|
--text-secondary: #5a5a7a;
|
||||||
|
--text-tertiary: #9ca3af;
|
||||||
|
--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,
|
||||||
|
|||||||
@@ -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("/");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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 { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
|
||||||
|
let config: HikariConfig = $state({
|
||||||
|
model: null,
|
||||||
|
api_key: null,
|
||||||
|
custom_instructions: null,
|
||||||
|
mcp_servers_json: null,
|
||||||
|
auto_granted_tools: [],
|
||||||
|
theme: "dark",
|
||||||
|
greeting_enabled: true,
|
||||||
|
greeting_custom_prompt: null,
|
||||||
|
notifications_enabled: true,
|
||||||
|
notification_volume: 0.7,
|
||||||
|
always_on_top: false,
|
||||||
|
update_checks_enabled: true,
|
||||||
|
character_panel_width: null,
|
||||||
|
font_size: 14,
|
||||||
|
});
|
||||||
|
|
||||||
|
let isOpen = $state(false);
|
||||||
|
let isSaving = $state(false);
|
||||||
|
let saveError: string | null = $state(null);
|
||||||
|
let newToolName = $state("");
|
||||||
|
let showApiKey = $state(false);
|
||||||
|
let grantedTools: string[] = $state([]);
|
||||||
|
|
||||||
|
configStore.config.subscribe((c) => {
|
||||||
|
config = { ...c };
|
||||||
|
});
|
||||||
|
|
||||||
|
configStore.isSidebarOpen.subscribe((open) => {
|
||||||
|
isOpen = open;
|
||||||
|
});
|
||||||
|
|
||||||
|
configStore.saveError.subscribe((error) => {
|
||||||
|
saveError = error;
|
||||||
|
});
|
||||||
|
|
||||||
|
claudeStore.grantedTools.subscribe((tools) => {
|
||||||
|
grantedTools = Array.from(tools);
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableModels = [
|
||||||
|
{ value: "", label: "Default (from ~/.claude)" },
|
||||||
|
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
|
||||||
|
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const commonTools = [
|
||||||
|
"Read",
|
||||||
|
"Write",
|
||||||
|
"Edit",
|
||||||
|
"Bash",
|
||||||
|
"Glob",
|
||||||
|
"Grep",
|
||||||
|
"WebFetch",
|
||||||
|
"WebSearch",
|
||||||
|
"Task",
|
||||||
|
];
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
isSaving = true;
|
||||||
|
saveError = null;
|
||||||
|
try {
|
||||||
|
await configStore.saveConfig(config);
|
||||||
|
configStore.closeSidebar();
|
||||||
|
} catch {
|
||||||
|
// Error is handled by the store
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleThemeChange(theme: Theme) {
|
||||||
|
config.theme = theme;
|
||||||
|
await configStore.setTheme(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTool(tool: string) {
|
||||||
|
if (config.auto_granted_tools.includes(tool)) {
|
||||||
|
config.auto_granted_tools = config.auto_granted_tools.filter((t) => t !== tool);
|
||||||
|
} else {
|
||||||
|
config.auto_granted_tools = [...config.auto_granted_tools, tool];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCustomTool() {
|
||||||
|
if (newToolName.trim() && !config.auto_granted_tools.includes(newToolName.trim())) {
|
||||||
|
config.auto_granted_tools = [...config.auto_granted_tools, newToolName.trim()];
|
||||||
|
newToolName = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTool(tool: string) {
|
||||||
|
config.auto_granted_tools = config.auto_granted_tools.filter((t) => t !== tool);
|
||||||
|
}
|
||||||
|
|
||||||
|
function importFromSession() {
|
||||||
|
config.auto_granted_tools = [...new Set([...config.auto_granted_tools, ...grantedTools])];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAlwaysOnTopChange(enabled: boolean) {
|
||||||
|
config.always_on_top = enabled;
|
||||||
|
const window = getCurrentWindow();
|
||||||
|
await window.setAlwaysOnTop(enabled);
|
||||||
|
await configStore.updateConfig({ always_on_top: enabled });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Backdrop -->
|
||||||
|
{#if isOpen}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/50 z-40 transition-opacity"
|
||||||
|
onclick={configStore.closeSidebar}
|
||||||
|
onkeydown={(e) => e.key === "Escape" && configStore.closeSidebar()}
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label="Close sidebar"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside
|
||||||
|
class="fixed right-0 top-0 h-full w-96 bg-[var(--bg-secondary)] border-l border-[var(--border-color)] z-50 transform transition-transform duration-300 ease-in-out overflow-y-auto {isOpen
|
||||||
|
? 'translate-x-0'
|
||||||
|
: 'translate-x-full'}"
|
||||||
|
>
|
||||||
|
<div class="p-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Settings</h2>
|
||||||
|
<button
|
||||||
|
onclick={configStore.closeSidebar}
|
||||||
|
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
aria-label="Close settings"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if saveError}
|
||||||
|
<div class="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400 text-sm">
|
||||||
|
{saveError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Agent Settings Section -->
|
||||||
|
<section class="mb-6">
|
||||||
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
|
Agent Settings
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Model Selection -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="model" class="block text-sm text-[var(--text-secondary)] mb-1">Model</label>
|
||||||
|
<select
|
||||||
|
id="model"
|
||||||
|
bind:value={config.model}
|
||||||
|
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
>
|
||||||
|
{#each availableModels as model (model.value)}
|
||||||
|
<option value={model.value}>{model.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Key -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="api-key" class="block text-sm text-[var(--text-secondary)] mb-1">
|
||||||
|
API Key <span class="text-[var(--text-tertiary)]">(optional override)</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
id="api-key"
|
||||||
|
type={showApiKey ? "text" : "password"}
|
||||||
|
bind:value={config.api_key}
|
||||||
|
placeholder="Falls back to ~/.claude settings"
|
||||||
|
class="w-full px-3 py-2 pr-10 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showApiKey = !showApiKey)}
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||||
|
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||||
|
>
|
||||||
|
{#if showApiKey}
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Instructions -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="instructions" class="block text-sm text-[var(--text-secondary)] mb-1"
|
||||||
|
>Custom Instructions</label
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
id="instructions"
|
||||||
|
bind:value={config.custom_instructions}
|
||||||
|
rows="4"
|
||||||
|
placeholder="Additional instructions for the agent..."
|
||||||
|
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Greeting Section -->
|
||||||
|
<section class="mb-6">
|
||||||
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
|
Greeting
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Enable/Disable Toggle -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.greeting_enabled}
|
||||||
|
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Send greeting on connect</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
Automatically greet you when a session starts with time-based messages
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Greeting Prompt -->
|
||||||
|
{#if config.greeting_enabled}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="greeting-prompt" class="block text-sm text-[var(--text-secondary)] mb-1">
|
||||||
|
Custom Greeting Prompt <span class="text-[var(--text-tertiary)]">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="greeting-prompt"
|
||||||
|
bind:value={config.greeting_custom_prompt}
|
||||||
|
rows="3"
|
||||||
|
placeholder="Leave empty for time-based greetings, or customize how you'd like to be greeted..."
|
||||||
|
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- MCP Servers Section -->
|
||||||
|
<section class="mb-6">
|
||||||
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
|
MCP Servers
|
||||||
|
</h3>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="mcp-config" class="block text-sm text-[var(--text-secondary)] mb-1">
|
||||||
|
Server Configuration <span class="text-[var(--text-tertiary)]">(JSON)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="mcp-config"
|
||||||
|
bind:value={config.mcp_servers_json}
|
||||||
|
rows="6"
|
||||||
|
placeholder={`{\n "servers": {\n "example": {\n "command": "npx",\n "args": ["-y", "@example/mcp-server"]\n }\n }\n}`}
|
||||||
|
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] font-mono text-sm focus:outline-none focus:border-[var(--accent-primary)] resize-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Auto-Granted Tools Section -->
|
||||||
|
<section class="mb-6">
|
||||||
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
|
Auto-Granted Tools
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mb-3">
|
||||||
|
These tools will be pre-approved for every session (no permission prompts).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Common tools checkboxes -->
|
||||||
|
<div class="grid grid-cols-2 gap-2 mb-3">
|
||||||
|
{#each commonTools as tool (tool)}
|
||||||
|
<label class="flex items-center gap-2 text-sm text-[var(--text-primary)] cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.auto_granted_tools.includes(tool)}
|
||||||
|
onchange={() => toggleTool(tool)}
|
||||||
|
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
{tool}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Currently granted tools (with import) -->
|
||||||
|
{#if grantedTools.length > 0}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)]">Session-granted tools:</span>
|
||||||
|
<button
|
||||||
|
onclick={importFromSession}
|
||||||
|
class="text-xs text-[var(--accent-primary)] hover:text-[var(--accent-secondary)] transition-colors"
|
||||||
|
>
|
||||||
|
Import all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each grantedTools as tool (tool)}
|
||||||
|
<span
|
||||||
|
class="px-2 py-0.5 text-xs bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] rounded"
|
||||||
|
>
|
||||||
|
{tool}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Custom tools list -->
|
||||||
|
{#if config.auto_granted_tools.filter((t) => !commonTools.includes(t)).length > 0}
|
||||||
|
<div class="mb-3">
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)] block mb-2">Custom tools:</span>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each config.auto_granted_tools.filter((t) => !commonTools.includes(t)) as tool (tool)}
|
||||||
|
<span
|
||||||
|
class="px-2 py-0.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{tool}
|
||||||
|
<button
|
||||||
|
onclick={() => removeTool(tool)}
|
||||||
|
class="text-[var(--text-tertiary)] hover:text-red-400"
|
||||||
|
aria-label="Remove {tool}"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Add custom tool -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newToolName}
|
||||||
|
placeholder="Add custom tool..."
|
||||||
|
onkeydown={(e) => e.key === "Enter" && addCustomTool()}
|
||||||
|
class="flex-1 px-3 py-1.5 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onclick={addCustomTool}
|
||||||
|
disabled={!newToolName.trim()}
|
||||||
|
class="px-3 py-1.5 text-sm bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)] text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Appearance Section -->
|
||||||
|
<section class="mb-6">
|
||||||
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
|
Appearance
|
||||||
|
</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">
|
||||||
|
<button
|
||||||
|
onclick={() => handleThemeChange("dark")}
|
||||||
|
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'dark'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
>
|
||||||
|
Dark
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleThemeChange("light")}
|
||||||
|
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'light'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
>
|
||||||
|
Light
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Font Size -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="font-size" class="block text-sm text-[var(--text-secondary)] mb-2">
|
||||||
|
Terminal Font Size
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
id="font-size"
|
||||||
|
type="range"
|
||||||
|
bind:value={config.font_size}
|
||||||
|
oninput={() => applyFontSize(config.font_size)}
|
||||||
|
min={MIN_FONT_SIZE}
|
||||||
|
max={MAX_FONT_SIZE}
|
||||||
|
step="1"
|
||||||
|
class="flex-1 h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-300 w-12 text-right">{config.font_size}px</span>
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
config.font_size = DEFAULT_FONT_SIZE;
|
||||||
|
applyFontSize(DEFAULT_FONT_SIZE);
|
||||||
|
}}
|
||||||
|
class="px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:border-[var(--accent-primary)] text-[var(--text-secondary)] transition-colors"
|
||||||
|
title="Reset to default (14px)"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||||
|
Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Window Section -->
|
||||||
|
<section class="mb-6">
|
||||||
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
|
Window
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Always on Top Toggle -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.always_on_top}
|
||||||
|
onchange={(e) => handleAlwaysOnTopChange(e.currentTarget.checked)}
|
||||||
|
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Always on top</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
Keep the window above other windows
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Update Checks Toggle -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.update_checks_enabled}
|
||||||
|
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Check for updates on startup</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
Notify when a new version is available
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Save Button -->
|
||||||
|
<div class="sticky bottom-0 pt-4 pb-2 bg-[var(--bg-secondary)]">
|
||||||
|
<button
|
||||||
|
onclick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
class="w-full py-3 bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)] text-white font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving..." : "Save Settings"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,41 +235,255 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
<textarea
|
<MessageModeSelector />
|
||||||
bind:value={inputValue}
|
|
||||||
onkeydown={handleKeyDown}
|
|
||||||
placeholder={isConnected ? "Ask Hikari anything..." : "Connect to Claude first..."}
|
|
||||||
disabled={!isConnected || isSubmitting}
|
|
||||||
rows={1}
|
|
||||||
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
|
|
||||||
focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]
|
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
|
||||||
transition-all duration-200"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div class="input-row">
|
||||||
type="submit"
|
<div class="textarea-wrapper">
|
||||||
disabled={!isConnected || isSubmitting || !inputValue.trim()}
|
<SlashCommandMenu
|
||||||
class="px-6 py-3 bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)]
|
commands={matchingCommands}
|
||||||
text-white font-medium rounded-lg
|
selectedIndex={selectedCommandIndex}
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
onSelect={selectCommand}
|
||||||
transition-all duration-200 transform hover:scale-105 active:scale-95"
|
/>
|
||||||
>
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
{#if isSubmitting}
|
<div class="resize-handle" onmousedown={handleResizeStart} title="Drag to resize"></div>
|
||||||
<span class="inline-block animate-spin">⏳</span>
|
<textarea
|
||||||
{:else}
|
bind:value={inputValue}
|
||||||
Send
|
onkeydown={handleKeyDown}
|
||||||
{/if}
|
oninput={handleInputChange}
|
||||||
</button>
|
placeholder={isConnected
|
||||||
|
? "Ask Hikari anything... (type / for commands)"
|
||||||
|
: "Connect to Claude first..."}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
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)]
|
||||||
|
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)]
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
></textarea>
|
||||||
|
</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
|
||||||
|
type="submit"
|
||||||
|
disabled={!isConnected || isSubmitting || !inputValue.trim()}
|
||||||
|
class="send-button bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)]
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{#if isSubmitting}
|
||||||
|
<span class="inline-block animate-spin">⏳</span>
|
||||||
|
{:else}
|
||||||
|
Send
|
||||||
|
{/if}
|
||||||
|
</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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -1,13 +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 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("");
|
||||||
@@ -15,6 +29,27 @@
|
|||||||
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({
|
||||||
|
model: null,
|
||||||
|
api_key: null,
|
||||||
|
custom_instructions: null,
|
||||||
|
mcp_servers_json: null,
|
||||||
|
auto_granted_tools: [],
|
||||||
|
theme: "dark",
|
||||||
|
greeting_enabled: true,
|
||||||
|
greeting_custom_prompt: null,
|
||||||
|
notifications_enabled: true,
|
||||||
|
notification_volume: 0.5,
|
||||||
|
always_on_top: false,
|
||||||
|
update_checks_enabled: true,
|
||||||
|
character_panel_width: null,
|
||||||
|
font_size: 14,
|
||||||
|
});
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
appVersion = await getVersion();
|
appVersion = await getVersion();
|
||||||
@@ -33,6 +68,10 @@
|
|||||||
grantedToolsList = Array.from(tools);
|
grantedToolsList = Array.from(tools);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
configStore.config.subscribe((config) => {
|
||||||
|
currentConfig = config;
|
||||||
|
});
|
||||||
|
|
||||||
async function handleBrowse() {
|
async function handleBrowse() {
|
||||||
try {
|
try {
|
||||||
const selected = await open({
|
const selected = await open({
|
||||||
@@ -54,11 +93,26 @@
|
|||||||
|
|
||||||
const targetDir = selectedDirectory || "/home/naomi";
|
const targetDir = selectedDirectory || "/home/naomi";
|
||||||
|
|
||||||
|
// Combine session-granted tools with config auto-granted tools
|
||||||
|
const allAllowedTools = [
|
||||||
|
...new Set([...grantedToolsList, ...currentConfig.auto_granted_tools]),
|
||||||
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Pass granted tools to Claude so they're pre-approved
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) {
|
||||||
|
throw new Error("No active conversation");
|
||||||
|
}
|
||||||
await invoke("start_claude", {
|
await invoke("start_claude", {
|
||||||
workingDir: targetDir,
|
conversationId,
|
||||||
allowedTools: grantedToolsList.length > 0 ? grantedToolsList : null,
|
options: {
|
||||||
|
working_dir: targetDir,
|
||||||
|
model: currentConfig.model || null,
|
||||||
|
api_key: currentConfig.api_key || null,
|
||||||
|
custom_instructions: currentConfig.custom_instructions || null,
|
||||||
|
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
||||||
|
allowed_tools: allAllowedTools,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start Claude:", error);
|
console.error("Failed to start Claude:", error);
|
||||||
@@ -68,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);
|
||||||
}
|
}
|
||||||
@@ -99,6 +157,10 @@
|
|||||||
return "Disconnected";
|
return "Disconnected";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleAchievements() {
|
||||||
|
onToggleAchievements();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -140,6 +202,115 @@
|
|||||||
</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
|
||||||
|
onclick={configStore.openSidebar}
|
||||||
|
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</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"
|
||||||
@@ -154,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}
|
||||||
@@ -172,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}
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./types";
|
||||||
|
export { soundPlayer } from "./soundPlayer";
|
||||||
|
export { notificationManager } from "./notificationManager";
|
||||||
|
export { initializeNotificationRules } from "./rules";
|
||||||