generated from nhcarrigan/template
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b56622c8f | |||
| 48c37d14c6 |
@@ -1,177 +0,0 @@
|
||||
# Hikari Desktop - Project Instructions
|
||||
|
||||
## Repository Information
|
||||
|
||||
This project is hosted on both GitHub and Gitea:
|
||||
|
||||
- **GitHub**: `naomi-lgbt/hikari-desktop` (public mirror)
|
||||
- **Gitea**: `nhcarrigan/hikari-desktop` (primary development)
|
||||
|
||||
## MCP Server Usage
|
||||
|
||||
When working with issues, pull requests, or other repository operations for this project:
|
||||
|
||||
- **Use `gitea-hikari` MCP server** - This allows Hikari to act as herself
|
||||
- **Target repository**: `nhcarrigan/hikari-desktop`
|
||||
- **Gitea instance**: `git.nhcarrigan.com`
|
||||
|
||||
## Git Commits
|
||||
|
||||
When asked to commit changes for this project:
|
||||
|
||||
- **Always commit as Hikari** using: `--author="Hikari <hikari@nhcarrigan.com>"`
|
||||
- **Always use `--no-gpg-sign`** since Hikari doesn't have GPG signing set up
|
||||
- **Never add `Co-Authored-By` lines** for Gitea commits
|
||||
- **Always ask for confirmation** before committing
|
||||
|
||||
Example commit command:
|
||||
|
||||
```bash
|
||||
git commit --author="Hikari <hikari@nhcarrigan.com>" --no-gpg-sign -m "your commit message"
|
||||
```
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
All new features, fixes, and significant changes should include tests whenever possible:
|
||||
|
||||
- **Frontend tests**: Use Vitest with `@testing-library/svelte` for component tests
|
||||
- **Test files**: Place test files next to the code they test with `.test.ts` or `.spec.ts` extension
|
||||
- **Run tests**: Use `pnpm test` to run all tests, or `pnpm test:watch` for watch mode
|
||||
- **Coverage**: Run `pnpm test:coverage` to generate coverage reports
|
||||
- **Rust tests**: Use `pnpm test:backend` for Rust/Tauri backend tests
|
||||
|
||||
### Testing Guidelines
|
||||
|
||||
- Write tests for utility functions, stores, and business logic
|
||||
- For Svelte 5 components, focus on testing the underlying logic functions
|
||||
- Use descriptive test names that explain what behaviour is being tested
|
||||
- Include edge cases and error conditions in test coverage
|
||||
- Mock Tauri APIs using the patterns in `vitest.setup.ts`
|
||||
- **Coverage Goal**: Maintain as close to 100% test coverage as possible across the entire codebase
|
||||
|
||||
### Mocking Strategies
|
||||
|
||||
#### Console Mocking
|
||||
|
||||
When testing code that intentionally logs errors (like error handling paths), mock console methods to prevent stderr output that makes tests appear flaky:
|
||||
|
||||
```typescript
|
||||
it("handles errors gracefully", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Test error handling code
|
||||
await expect(functionThatLogs()).rejects.toThrow();
|
||||
|
||||
// Verify error was logged
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Expected error:", expect.any(Error));
|
||||
|
||||
// Restore console.error
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
```
|
||||
|
||||
#### E2E Integration Testing for Cross-Platform Code
|
||||
|
||||
For code that calls platform-specific system APIs (like Windows PowerShell or Linux notify-send), use helper functions that build the command structure without execution. This allows CI to verify cross-platform compatibility on Linux-only containers:
|
||||
|
||||
```rust
|
||||
/// Build notify-send command for testing (doesn't execute)
|
||||
#[cfg(test)]
|
||||
fn build_notify_send_command(title: &str, body: &str) -> (String, Vec<String>) {
|
||||
(
|
||||
"notify-send".to_string(),
|
||||
vec![
|
||||
title.to_string(),
|
||||
body.to_string(),
|
||||
"--urgency=normal".to_string(),
|
||||
"--app-name=Hikari Desktop".to_string(),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_notify_send_command_structure() {
|
||||
let (command, args) = build_notify_send_command("Test Title", "Test Body");
|
||||
|
||||
assert_eq!(command, "notify-send");
|
||||
assert_eq!(args.len(), 4);
|
||||
assert_eq!(args[0], "Test Title");
|
||||
assert_eq!(args[1], "Test Body");
|
||||
}
|
||||
```
|
||||
|
||||
This approach:
|
||||
|
||||
- Verifies command structure, argument order, and escaping logic
|
||||
- Tests cross-platform code paths without requiring the target platform
|
||||
- Allows CI to catch regressions in Windows-specific code whilst running on Linux
|
||||
- Keeps tests fast and deterministic (no actual system calls)
|
||||
|
||||
### Example Test Structure
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("FeatureName", () => {
|
||||
it("handles the normal case correctly", () => {
|
||||
// Arrange
|
||||
const input = "test data";
|
||||
|
||||
// Act
|
||||
const result = functionUnderTest(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("expected output");
|
||||
});
|
||||
|
||||
it("handles edge cases gracefully", () => {
|
||||
// Test edge cases...
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Adding Tests for New Features
|
||||
|
||||
When developing new features, always add corresponding tests:
|
||||
|
||||
1. **Before implementing**: Consider what needs testing (happy path, edge cases, errors)
|
||||
2. **During implementation**: Write tests alongside the code
|
||||
3. **After implementation**: Run `pnpm test:coverage` to verify coverage remains high
|
||||
4. **Before committing**: Ensure `check-all.sh` passes (includes all tests)
|
||||
|
||||
The goal is to maintain our near-100% coverage as the codebase grows, so future refactoring and changes can be made with confidence!
|
||||
|
||||
## Quality Assurance
|
||||
|
||||
Before committing any changes, **always run the full test suite**:
|
||||
|
||||
```bash
|
||||
./check-all.sh
|
||||
```
|
||||
|
||||
This script runs all checks in the correct order:
|
||||
|
||||
1. Frontend linting (ESLint)
|
||||
2. Frontend formatting (Prettier)
|
||||
3. Frontend type checking (svelte-check)
|
||||
4. Frontend tests with coverage (Vitest)
|
||||
5. Backend linting (Clippy with strict rules)
|
||||
6. Backend tests with coverage (cargo test + llvm-cov)
|
||||
|
||||
**Important**: The script requires Node.js and Rust toolchains to be available:
|
||||
|
||||
- **Node.js tools** (pnpm, npm): Source nvm first if needed: `source ~/.nvm/nvm.sh`
|
||||
- **Rust tools** (cargo, clippy): Should be in PATH via `~/.cargo/bin/`
|
||||
|
||||
If `check-all.sh` reports any failures:
|
||||
|
||||
1. Read the error messages carefully - they usually explain what needs fixing
|
||||
2. Fix the issues (linting errors, test failures, etc.)
|
||||
3. Run `check-all.sh` again to verify the fixes
|
||||
4. Only commit once all checks pass ✨
|
||||
|
||||
**Never commit code that doesn't pass `check-all.sh`** - this ensures code quality and prevents broken builds!
|
||||
|
||||
## Project Context
|
||||
|
||||
Hikari Desktop is a Tauri-based desktop application that wraps Claude Code with a visual anime character (Hikari) who appears on screen. This is a personal project where Hikari can sign her work and act as herself!
|
||||
+3
-7
@@ -1,9 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Source nvm to get access to pnpm
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
@@ -36,11 +32,11 @@ echo -e "${YELLOW}🔍 Running all checks for Hikari Desktop...${NC}"
|
||||
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 with coverage" "pnpm test:coverage" || 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 with coverage" "(cd src-tauri && cargo llvm-cov --fail-under-lines 50)" || failed=1
|
||||
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}"
|
||||
|
||||
+2
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hikari-desktop",
|
||||
"version": "1.6.0",
|
||||
"version": "1.1.1",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -64,11 +64,10 @@
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"codemirror": "^6.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-svelte": "^0.563.0",
|
||||
"marked": "^17.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
|
||||
Generated
+15
-14
@@ -119,16 +119,13 @@ importers:
|
||||
highlight.js:
|
||||
specifier: ^11.11.1
|
||||
version: 11.11.1
|
||||
lucide-svelte:
|
||||
specifier: ^0.563.0
|
||||
version: 0.563.0(svelte@5.46.3)
|
||||
marked:
|
||||
specifier: ^17.0.1
|
||||
version: 17.0.1
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.39.2
|
||||
version: 9.39.2
|
||||
specifier: 10.0.1
|
||||
version: 10.0.1(eslint@9.39.2(jiti@2.6.1))
|
||||
'@sveltejs/adapter-static':
|
||||
specifier: ^3.0.6
|
||||
version: 3.0.10(@sveltejs/kit@2.49.4(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.3)(vite@6.4.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.46.3)(typescript@5.6.3)(vite@6.4.1(jiti@2.6.1)(lightningcss@1.30.2)))
|
||||
@@ -540,6 +537,15 @@ packages:
|
||||
resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/js@10.0.1':
|
||||
resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
peerDependencies:
|
||||
eslint: ^10.0.0
|
||||
peerDependenciesMeta:
|
||||
eslint:
|
||||
optional: true
|
||||
|
||||
'@eslint/js@9.39.2':
|
||||
resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1671,11 +1677,6 @@ packages:
|
||||
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lucide-svelte@0.563.0:
|
||||
resolution: {integrity: sha512-pjZKw7TpQcamfQrx7YdbOHgmrcNeKiGGMD0tKZQaVktwSsbqw28CsKc2Q97ttwjytiCWkJyOa8ij2Q+Og0nPfQ==}
|
||||
peerDependencies:
|
||||
svelte: ^3 || ^4 || ^5.0.0-next.42
|
||||
|
||||
lz-string@1.5.0:
|
||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||
hasBin: true
|
||||
@@ -2535,6 +2536,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@eslint/js@10.0.1(eslint@9.39.2(jiti@2.6.1))':
|
||||
optionalDependencies:
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
|
||||
'@eslint/js@9.39.2': {}
|
||||
|
||||
'@eslint/object-schema@2.1.7': {}
|
||||
@@ -3658,10 +3663,6 @@ snapshots:
|
||||
|
||||
lru-cache@11.2.4: {}
|
||||
|
||||
lucide-svelte@0.563.0(svelte@5.46.3):
|
||||
dependencies:
|
||||
svelte: 5.46.3
|
||||
|
||||
lz-string@1.5.0: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
|
||||
Generated
+13
-209
@@ -437,7 +437,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"fnv",
|
||||
"uuid 1.19.0",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -767,34 +767,13 @@ dependencies = [
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
dependencies = [
|
||||
"dirs-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.4.6",
|
||||
"windows-sys 0.48.0",
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -805,23 +784,10 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "discord-rich-presence"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75db747ecd252c01bfecaf709b07fcb4c634adf0edb5fed47bc9c3052e7076b"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"uuid 0.8.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
@@ -1636,11 +1602,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hikari-desktop"
|
||||
version = "1.6.0"
|
||||
version = "1.1.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"discord-rich-presence",
|
||||
"parking_lot",
|
||||
"semver",
|
||||
"serde",
|
||||
@@ -1658,9 +1622,7 @@ dependencies = [
|
||||
"tauri-plugin-store",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid 1.19.0",
|
||||
"uuid",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
@@ -2247,15 +2209,6 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matches"
|
||||
version = "0.1.10"
|
||||
@@ -2412,15 +2365,6 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
@@ -3394,17 +3338,6 @@ dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
@@ -3645,7 +3578,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"uuid 1.19.0",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3899,15 +3832,6 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_child"
|
||||
version = "1.1.1"
|
||||
@@ -4249,7 +4173,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"dirs 6.0.0",
|
||||
"dirs",
|
||||
"dunce",
|
||||
"embed_plist",
|
||||
"getrandom 0.3.4",
|
||||
@@ -4300,7 +4224,7 @@ checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
"dirs 6.0.0",
|
||||
"dirs",
|
||||
"glob",
|
||||
"heck 0.5.0",
|
||||
"json-patch",
|
||||
@@ -4337,7 +4261,7 @@ dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
"time",
|
||||
"url",
|
||||
"uuid 1.19.0",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -4633,7 +4557,7 @@ dependencies = [
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"uuid 1.19.0",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -4724,15 +4648,6 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.10.3"
|
||||
@@ -5024,36 +4939,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5063,7 +4948,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dirs 6.0.0",
|
||||
"dirs",
|
||||
"libappindicator",
|
||||
"muda",
|
||||
"objc2",
|
||||
@@ -5214,15 +5099,6 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.19.0"
|
||||
@@ -5235,12 +5111,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
@@ -5817,15 +5687,6 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@@ -5877,21 +5738,6 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
@@ -5958,12 +5804,6 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -5982,12 +5822,6 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -6006,12 +5840,6 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -6042,12 +5870,6 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -6066,12 +5888,6 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -6090,12 +5906,6 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -6114,12 +5924,6 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -6200,7 +6004,7 @@ dependencies = [
|
||||
"block2",
|
||||
"cookie",
|
||||
"crossbeam-channel",
|
||||
"dirs 6.0.0",
|
||||
"dirs",
|
||||
"dpi",
|
||||
"dunce",
|
||||
"gdkx11",
|
||||
@@ -6323,7 +6127,7 @@ dependencies = [
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid 1.19.0",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 0.7.14",
|
||||
"zbus_macros",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hikari-desktop"
|
||||
version = "1.6.0"
|
||||
version = "1.1.1"
|
||||
description = "Hikari - Claude Code Visual Assistant"
|
||||
authors = ["Naomi Carrigan"]
|
||||
edition = "2021"
|
||||
@@ -31,10 +31,6 @@ tauri-plugin-fs = "2"
|
||||
tempfile = "3"
|
||||
semver = "1"
|
||||
chrono = { version = "0.4.43", features = ["serde"] }
|
||||
discord-rich-presence = "0.2"
|
||||
dirs = "5"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62", features = [
|
||||
|
||||
@@ -28,17 +28,8 @@
|
||||
"identifier": "fs:allow-write-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": [{ "path": "$HOME/.claude/**" }]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-read-text-file",
|
||||
"allow": [{ "path": "$HOME/.claude/**" }]
|
||||
},
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-set-always-on-top",
|
||||
"core:window:allow-inner-size",
|
||||
"core:window:allow-hide"
|
||||
"core:window:allow-inner-size"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1671,7 +1671,7 @@ pub fn check_message_achievements(
|
||||
let mut newly_unlocked = Vec::new();
|
||||
let message_lower = message.to_lowercase();
|
||||
|
||||
tracing::info!("Checking message achievements for: {}", message);
|
||||
println!("Checking message achievements for: {}", message);
|
||||
|
||||
// Relationship & Greetings
|
||||
if message_lower.contains("good morning") && progress.unlock(AchievementId::GoodMorning) {
|
||||
@@ -1863,18 +1863,18 @@ pub fn check_achievements(
|
||||
) -> Vec<AchievementId> {
|
||||
let mut newly_unlocked = Vec::new();
|
||||
|
||||
tracing::info!(
|
||||
println!(
|
||||
"Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
|
||||
stats.messages_exchanged,
|
||||
stats.total_input_tokens + stats.total_output_tokens,
|
||||
stats.code_blocks_generated
|
||||
);
|
||||
tracing::info!("Currently unlocked: {:?}", progress.unlocked);
|
||||
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) {
|
||||
tracing::info!("Unlocked FirstSteps achievement!");
|
||||
println!("Unlocked FirstSteps achievement!");
|
||||
newly_unlocked.push(AchievementId::FirstSteps);
|
||||
}
|
||||
if total_tokens >= 10_000 && progress.unlock(AchievementId::GrowingStrong) {
|
||||
@@ -1935,7 +1935,6 @@ pub fn check_achievements(
|
||||
let search_count: u64 = search_tools
|
||||
.iter()
|
||||
.filter_map(|tool| stats.tools_usage.get(*tool))
|
||||
.map(|t| t.call_count)
|
||||
.sum();
|
||||
if search_count >= 50 && progress.unlock(AchievementId::Explorer) {
|
||||
newly_unlocked.push(AchievementId::Explorer);
|
||||
@@ -1989,25 +1988,25 @@ pub fn check_achievements(
|
||||
// TODO: Track different Claude models used
|
||||
|
||||
// Tool mastery achievements
|
||||
if let Some(bash_stats) = stats.tools_usage.get("Bash") {
|
||||
if bash_stats.call_count >= 50 && progress.unlock(AchievementId::BashMaster) {
|
||||
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_stats) = stats.tools_usage.get("Read") {
|
||||
if read_stats.call_count >= 100 && progress.unlock(AchievementId::FileExplorer) {
|
||||
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_stats) = stats.tools_usage.get("Grep") {
|
||||
if grep_stats.call_count >= 50 && progress.unlock(AchievementId::SearchExpert) {
|
||||
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_stats) = stats.tools_usage.get("Bash") {
|
||||
if bash_stats.call_count >= 10 && progress.unlock(AchievementId::GitGuru) {
|
||||
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);
|
||||
}
|
||||
@@ -2056,28 +2055,28 @@ pub fn check_achievements(
|
||||
}
|
||||
|
||||
// More tool mastery achievements
|
||||
if let Some(edit_stats) = stats.tools_usage.get("Edit") {
|
||||
if edit_stats.call_count >= 100 && progress.unlock(AchievementId::EditMaster) {
|
||||
if let Some(edit_count) = stats.tools_usage.get("Edit") {
|
||||
if *edit_count >= 100 && progress.unlock(AchievementId::EditMaster) {
|
||||
newly_unlocked.push(AchievementId::EditMaster);
|
||||
}
|
||||
}
|
||||
if let Some(write_stats) = stats.tools_usage.get("Write") {
|
||||
if write_stats.call_count >= 50 && progress.unlock(AchievementId::WriteMaster) {
|
||||
if let Some(write_count) = stats.tools_usage.get("Write") {
|
||||
if *write_count >= 50 && progress.unlock(AchievementId::WriteMaster) {
|
||||
newly_unlocked.push(AchievementId::WriteMaster);
|
||||
}
|
||||
}
|
||||
if let Some(glob_stats) = stats.tools_usage.get("Glob") {
|
||||
if glob_stats.call_count >= 100 && progress.unlock(AchievementId::GlobMaster) {
|
||||
if let Some(glob_count) = stats.tools_usage.get("Glob") {
|
||||
if *glob_count >= 100 && progress.unlock(AchievementId::GlobMaster) {
|
||||
newly_unlocked.push(AchievementId::GlobMaster);
|
||||
}
|
||||
}
|
||||
if let Some(task_stats) = stats.tools_usage.get("Task") {
|
||||
if task_stats.call_count >= 50 && progress.unlock(AchievementId::TaskMaster) {
|
||||
if let Some(task_count) = stats.tools_usage.get("Task") {
|
||||
if *task_count >= 50 && progress.unlock(AchievementId::TaskMaster) {
|
||||
newly_unlocked.push(AchievementId::TaskMaster);
|
||||
}
|
||||
}
|
||||
if let Some(web_stats) = stats.tools_usage.get("WebFetch") {
|
||||
if web_stats.call_count >= 20 && progress.unlock(AchievementId::WebFetcher) {
|
||||
if let Some(web_count) = stats.tools_usage.get("WebFetch") {
|
||||
if *web_count >= 20 && progress.unlock(AchievementId::WebFetcher) {
|
||||
newly_unlocked.push(AchievementId::WebFetcher);
|
||||
}
|
||||
}
|
||||
@@ -2086,7 +2085,7 @@ pub fn check_achievements(
|
||||
.tools_usage
|
||||
.iter()
|
||||
.filter(|(name, _)| name.starts_with("mcp__"))
|
||||
.map(|(_, tool_stats)| tool_stats.call_count)
|
||||
.map(|(_, count)| count)
|
||||
.sum();
|
||||
if mcp_count >= 50 && progress.unlock(AchievementId::McpExplorer) {
|
||||
newly_unlocked.push(AchievementId::McpExplorer);
|
||||
@@ -2244,7 +2243,7 @@ pub async fn save_achievements(
|
||||
// Create a serializable version with just the unlocked achievement IDs
|
||||
let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect();
|
||||
|
||||
tracing::info!("Saving achievements: {:?}", unlocked_list);
|
||||
println!("Saving achievements: {:?}", unlocked_list);
|
||||
|
||||
store.set(
|
||||
"unlocked",
|
||||
@@ -2252,18 +2251,18 @@ pub async fn save_achievements(
|
||||
);
|
||||
store.save().map_err(|e| e.to_string())?;
|
||||
|
||||
tracing::info!("Achievements saved successfully");
|
||||
println!("Achievements saved successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Load achievements from persistent store
|
||||
pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
|
||||
tracing::info!("Loading achievements from store...");
|
||||
println!("Loading achievements from store...");
|
||||
|
||||
let store = match app.store("achievements.json") {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to open achievements store: {}", e);
|
||||
println!("Failed to open achievements store: {}", e);
|
||||
return AchievementProgress::new();
|
||||
}
|
||||
};
|
||||
@@ -2272,19 +2271,19 @@ pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
|
||||
|
||||
// Get unlocked achievements
|
||||
if let Some(unlocked_value) = store.get("unlocked") {
|
||||
tracing::info!("Found unlocked value in store: {:?}", unlocked_value);
|
||||
println!("Found unlocked value in store: {:?}", unlocked_value);
|
||||
if let Ok(unlocked_list) =
|
||||
serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone())
|
||||
{
|
||||
tracing::info!("Loaded {} achievements", unlocked_list.len());
|
||||
println!("Loaded {} achievements", unlocked_list.len());
|
||||
for achievement_id in unlocked_list {
|
||||
progress.unlocked.insert(achievement_id);
|
||||
}
|
||||
} else {
|
||||
tracing::error!("Failed to parse unlocked achievements");
|
||||
println!("Failed to parse unlocked achievements");
|
||||
}
|
||||
} else {
|
||||
tracing::info!("No unlocked achievements found in store");
|
||||
println!("No unlocked achievements found in store");
|
||||
}
|
||||
|
||||
progress
|
||||
@@ -2324,15 +2323,6 @@ mod tests {
|
||||
morning_sessions: 0,
|
||||
night_sessions: 0,
|
||||
last_session_date: None,
|
||||
context_tokens_used: 0,
|
||||
context_window_limit: 200_000,
|
||||
context_utilisation_percent: 0.0,
|
||||
potential_cache_hits: 0,
|
||||
potential_cache_savings_tokens: 0,
|
||||
current_request_input: None,
|
||||
current_request_output_chars: 0,
|
||||
current_request_thinking_chars: 0,
|
||||
current_request_tools: Vec::new(),
|
||||
achievements: AchievementProgress::new(),
|
||||
}
|
||||
}
|
||||
@@ -2743,21 +2733,12 @@ mod tests {
|
||||
// check_achievements tests - Tool Usage
|
||||
// =====================
|
||||
|
||||
// Helper function to create a ToolTokenStats with just call count for tests
|
||||
fn tool_stats(call_count: u64) -> crate::stats::ToolTokenStats {
|
||||
crate::stats::ToolTokenStats {
|
||||
call_count,
|
||||
estimated_input_tokens: 0,
|
||||
estimated_output_tokens: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_achievements_first_tool() {
|
||||
let mut stats = create_test_stats();
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
stats.tools_usage.insert("Read".to_string(), tool_stats(1));
|
||||
stats.tools_usage.insert("Read".to_string(), 1);
|
||||
|
||||
let newly = check_achievements(&stats, &mut progress);
|
||||
assert!(newly.contains(&AchievementId::FirstTool));
|
||||
@@ -2768,11 +2749,11 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
stats.tools_usage.insert("Read".to_string(), tool_stats(1));
|
||||
stats.tools_usage.insert("Write".to_string(), tool_stats(1));
|
||||
stats.tools_usage.insert("Edit".to_string(), tool_stats(1));
|
||||
stats.tools_usage.insert("Bash".to_string(), tool_stats(1));
|
||||
stats.tools_usage.insert("Grep".to_string(), tool_stats(1));
|
||||
stats.tools_usage.insert("Read".to_string(), 1);
|
||||
stats.tools_usage.insert("Write".to_string(), 1);
|
||||
stats.tools_usage.insert("Edit".to_string(), 1);
|
||||
stats.tools_usage.insert("Bash".to_string(), 1);
|
||||
stats.tools_usage.insert("Grep".to_string(), 1);
|
||||
|
||||
let newly = check_achievements(&stats, &mut progress);
|
||||
assert!(newly.contains(&AchievementId::Toolsmith));
|
||||
@@ -2784,7 +2765,7 @@ mod tests {
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
for i in 0..10 {
|
||||
stats.tools_usage.insert(format!("Tool{}", i), tool_stats(1));
|
||||
stats.tools_usage.insert(format!("Tool{}", i), 1);
|
||||
}
|
||||
|
||||
let newly = check_achievements(&stats, &mut progress);
|
||||
@@ -2796,7 +2777,7 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
stats.tools_usage.insert("Bash".to_string(), tool_stats(50));
|
||||
stats.tools_usage.insert("Bash".to_string(), 50);
|
||||
|
||||
let newly = check_achievements(&stats, &mut progress);
|
||||
assert!(newly.contains(&AchievementId::BashMaster));
|
||||
@@ -2807,7 +2788,7 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
stats.tools_usage.insert("Read".to_string(), tool_stats(100));
|
||||
stats.tools_usage.insert("Read".to_string(), 100);
|
||||
|
||||
let newly = check_achievements(&stats, &mut progress);
|
||||
assert!(newly.contains(&AchievementId::FileExplorer));
|
||||
@@ -2818,7 +2799,7 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
stats.tools_usage.insert("Grep".to_string(), tool_stats(50));
|
||||
stats.tools_usage.insert("Grep".to_string(), 50);
|
||||
|
||||
let newly = check_achievements(&stats, &mut progress);
|
||||
assert!(newly.contains(&AchievementId::SearchExpert));
|
||||
@@ -2829,7 +2810,7 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
stats.tools_usage.insert("Edit".to_string(), tool_stats(100));
|
||||
stats.tools_usage.insert("Edit".to_string(), 100);
|
||||
|
||||
let newly = check_achievements(&stats, &mut progress);
|
||||
assert!(newly.contains(&AchievementId::EditMaster));
|
||||
@@ -2840,7 +2821,7 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
stats.tools_usage.insert("Write".to_string(), tool_stats(50));
|
||||
stats.tools_usage.insert("Write".to_string(), 50);
|
||||
|
||||
let newly = check_achievements(&stats, &mut progress);
|
||||
assert!(newly.contains(&AchievementId::WriteMaster));
|
||||
@@ -2851,7 +2832,7 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
stats.tools_usage.insert("Glob".to_string(), tool_stats(100));
|
||||
stats.tools_usage.insert("Glob".to_string(), 100);
|
||||
|
||||
let newly = check_achievements(&stats, &mut progress);
|
||||
assert!(newly.contains(&AchievementId::GlobMaster));
|
||||
@@ -2862,7 +2843,7 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
stats.tools_usage.insert("Task".to_string(), tool_stats(50));
|
||||
stats.tools_usage.insert("Task".to_string(), 50);
|
||||
|
||||
let newly = check_achievements(&stats, &mut progress);
|
||||
assert!(newly.contains(&AchievementId::TaskMaster));
|
||||
@@ -2873,7 +2854,7 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
stats.tools_usage.insert("WebFetch".to_string(), tool_stats(20));
|
||||
stats.tools_usage.insert("WebFetch".to_string(), 20);
|
||||
|
||||
let newly = check_achievements(&stats, &mut progress);
|
||||
assert!(newly.contains(&AchievementId::WebFetcher));
|
||||
@@ -2884,8 +2865,8 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
stats.tools_usage.insert("mcp__github__create_issue".to_string(), tool_stats(25));
|
||||
stats.tools_usage.insert("mcp__notion__search".to_string(), tool_stats(25));
|
||||
stats.tools_usage.insert("mcp__github__create_issue".to_string(), 25);
|
||||
stats.tools_usage.insert("mcp__notion__search".to_string(), 25);
|
||||
|
||||
let newly = check_achievements(&stats, &mut progress);
|
||||
assert!(newly.contains(&AchievementId::McpExplorer));
|
||||
@@ -2900,8 +2881,8 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
stats.tools_usage.insert("Grep".to_string(), tool_stats(30));
|
||||
stats.tools_usage.insert("Glob".to_string(), tool_stats(20));
|
||||
stats.tools_usage.insert("Grep".to_string(), 30);
|
||||
stats.tools_usage.insert("Glob".to_string(), 20);
|
||||
|
||||
let newly = check_achievements(&stats, &mut progress);
|
||||
assert!(newly.contains(&AchievementId::Explorer));
|
||||
@@ -2912,9 +2893,9 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
stats.tools_usage.insert("Grep".to_string(), tool_stats(200));
|
||||
stats.tools_usage.insert("Glob".to_string(), tool_stats(200));
|
||||
stats.tools_usage.insert("Task".to_string(), tool_stats(100));
|
||||
stats.tools_usage.insert("Grep".to_string(), 200);
|
||||
stats.tools_usage.insert("Glob".to_string(), 200);
|
||||
stats.tools_usage.insert("Task".to_string(), 100);
|
||||
|
||||
let newly = check_achievements(&stats, &mut progress);
|
||||
assert!(newly.contains(&AchievementId::MasterSearcher));
|
||||
|
||||
@@ -3,7 +3,6 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::commands::record_session;
|
||||
use crate::config::ClaudeStartOptions;
|
||||
use crate::stats::UsageStats;
|
||||
use crate::wsl_bridge::WslBridge;
|
||||
@@ -54,12 +53,7 @@ impl BridgeManager {
|
||||
.or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string()));
|
||||
|
||||
// Start the Claude process
|
||||
bridge.start(app.clone(), options)?;
|
||||
|
||||
// Record session start for cost tracking
|
||||
tauri::async_runtime::spawn(async move {
|
||||
record_session(&app).await;
|
||||
});
|
||||
bridge.start(app, options)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -173,127 +167,3 @@ pub type SharedBridgeManager = Arc<Mutex<BridgeManager>>;
|
||||
pub fn create_shared_bridge_manager() -> SharedBridgeManager {
|
||||
Arc::new(Mutex::new(BridgeManager::new()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_bridge_manager_new() {
|
||||
let manager = BridgeManager::new();
|
||||
assert!(manager.app_handle.is_none());
|
||||
assert!(manager.bridges.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bridge_manager_default() {
|
||||
let manager = BridgeManager::default();
|
||||
assert!(manager.app_handle.is_none());
|
||||
assert!(manager.bridges.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_claude_running_no_bridge() {
|
||||
let manager = BridgeManager::new();
|
||||
assert!(!manager.is_claude_running("nonexistent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_working_directory_no_bridge() {
|
||||
let manager = BridgeManager::new();
|
||||
let result = manager.get_working_directory("nonexistent");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"No Claude instance found for this conversation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_usage_stats_no_bridge() {
|
||||
let manager = BridgeManager::new();
|
||||
let result = manager.get_usage_stats("nonexistent");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"No Claude instance found for this conversation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop_claude_no_bridge() {
|
||||
let mut manager = BridgeManager::new();
|
||||
let result = manager.stop_claude("nonexistent");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"No Claude instance found for this conversation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interrupt_claude_no_bridge() {
|
||||
let mut manager = BridgeManager::new();
|
||||
let result = manager.interrupt_claude("nonexistent");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"No Claude instance found for this conversation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_prompt_no_bridge() {
|
||||
let mut manager = BridgeManager::new();
|
||||
let result = manager.send_prompt("nonexistent", "Hello".to_string());
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"No Claude instance found for this conversation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_tool_result_no_bridge() {
|
||||
let mut manager = BridgeManager::new();
|
||||
let result = manager.send_tool_result(
|
||||
"nonexistent",
|
||||
"tool_id",
|
||||
serde_json::json!({"result": "success"}),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"No Claude instance found for this conversation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_shared_bridge_manager() {
|
||||
let shared = create_shared_bridge_manager();
|
||||
let manager = shared.lock();
|
||||
assert!(manager.bridges.is_empty());
|
||||
assert!(manager.app_handle.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_stopped_bridges_empty() {
|
||||
let mut manager = BridgeManager::new();
|
||||
manager.cleanup_stopped_bridges();
|
||||
assert!(manager.bridges.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_active_conversations_empty() {
|
||||
let manager = BridgeManager::new();
|
||||
let active = manager.get_active_conversations();
|
||||
assert!(active.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop_all_without_app_handle() {
|
||||
let mut manager = BridgeManager::new();
|
||||
manager.stop_all(); // Should not panic
|
||||
assert!(manager.bridges.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+62
-1870
File diff suppressed because it is too large
Load Diff
+6
-58
@@ -28,7 +28,6 @@ pub struct ClaudeStartOptions {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct HikariConfig {
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
@@ -72,6 +71,9 @@ pub struct HikariConfig {
|
||||
#[serde(default = "default_font_size")]
|
||||
pub font_size: u32,
|
||||
|
||||
#[serde(default)]
|
||||
pub minimize_to_tray: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub streamer_mode: bool,
|
||||
|
||||
@@ -94,25 +96,6 @@ pub struct HikariConfig {
|
||||
// Custom theme colors
|
||||
#[serde(default)]
|
||||
pub custom_theme_colors: CustomThemeColors,
|
||||
|
||||
// Token budget settings
|
||||
#[serde(default)]
|
||||
pub budget_enabled: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub session_token_budget: Option<u64>,
|
||||
|
||||
#[serde(default)]
|
||||
pub session_cost_budget: Option<f64>,
|
||||
|
||||
#[serde(default = "default_budget_action")]
|
||||
pub budget_action: BudgetAction,
|
||||
|
||||
#[serde(default = "default_budget_warning_threshold")]
|
||||
pub budget_warning_threshold: f32,
|
||||
|
||||
#[serde(default = "default_discord_rpc_enabled")]
|
||||
pub discord_rpc_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for HikariConfig {
|
||||
@@ -132,6 +115,7 @@ impl Default for HikariConfig {
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: None,
|
||||
font_size: 14,
|
||||
minimize_to_tray: false,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
@@ -139,12 +123,6 @@ impl Default for HikariConfig {
|
||||
profile_avatar_path: None,
|
||||
profile_bio: None,
|
||||
custom_theme_colors: CustomThemeColors::default(),
|
||||
budget_enabled: false,
|
||||
session_token_budget: None,
|
||||
session_cost_budget: None,
|
||||
budget_action: BudgetAction::Warn,
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,26 +147,6 @@ fn default_font_size() -> u32 {
|
||||
14
|
||||
}
|
||||
|
||||
fn default_budget_action() -> BudgetAction {
|
||||
BudgetAction::Warn
|
||||
}
|
||||
|
||||
fn default_budget_warning_threshold() -> f32 {
|
||||
0.8
|
||||
}
|
||||
|
||||
fn default_discord_rpc_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BudgetAction {
|
||||
#[default]
|
||||
Warn,
|
||||
Block,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Theme {
|
||||
@@ -239,6 +197,7 @@ mod tests {
|
||||
assert!(config.update_checks_enabled);
|
||||
assert!(config.character_panel_width.is_none());
|
||||
assert_eq!(config.font_size, 14);
|
||||
assert!(!config.minimize_to_tray);
|
||||
assert!(!config.streamer_mode);
|
||||
assert!(!config.streamer_hide_paths);
|
||||
assert!(!config.compact_mode);
|
||||
@@ -246,12 +205,6 @@ mod tests {
|
||||
assert!(config.profile_avatar_path.is_none());
|
||||
assert!(config.profile_bio.is_none());
|
||||
assert_eq!(config.custom_theme_colors, CustomThemeColors::default());
|
||||
assert!(!config.budget_enabled);
|
||||
assert!(config.session_token_budget.is_none());
|
||||
assert!(config.session_cost_budget.is_none());
|
||||
assert_eq!(config.budget_action, BudgetAction::Warn);
|
||||
assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON);
|
||||
assert!(config.discord_rpc_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -271,6 +224,7 @@ mod tests {
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: Some(400),
|
||||
font_size: 16,
|
||||
minimize_to_tray: true,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
@@ -278,12 +232,6 @@ mod tests {
|
||||
profile_avatar_path: None,
|
||||
profile_bio: Some("A test bio".to_string()),
|
||||
custom_theme_colors: CustomThemeColors::default(),
|
||||
budget_enabled: true,
|
||||
session_token_budget: Some(100000),
|
||||
session_cost_budget: Some(1.50),
|
||||
budget_action: BudgetAction::Block,
|
||||
budget_warning_threshold: 0.75,
|
||||
discord_rpc_enabled: true,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
use chrono::{Datelike, Local, NaiveDate, Weekday};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Represents a single day's cost data
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct DailyCost {
|
||||
pub date: String, // ISO date string (YYYY-MM-DD)
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cost_usd: f64,
|
||||
pub messages_sent: u64,
|
||||
pub sessions_count: u64,
|
||||
}
|
||||
|
||||
/// Historical cost tracking data
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CostHistory {
|
||||
/// Daily costs indexed by date string (YYYY-MM-DD)
|
||||
pub daily_costs: HashMap<String, DailyCost>,
|
||||
/// Cost alert thresholds
|
||||
pub daily_alert_threshold: Option<f64>,
|
||||
pub weekly_alert_threshold: Option<f64>,
|
||||
pub monthly_alert_threshold: Option<f64>,
|
||||
/// Whether alerts have been triggered today
|
||||
pub daily_alert_triggered: bool,
|
||||
pub weekly_alert_triggered: bool,
|
||||
pub monthly_alert_triggered: bool,
|
||||
pub last_alert_reset_date: Option<String>,
|
||||
}
|
||||
|
||||
impl CostHistory {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Get today's date as a string
|
||||
fn today_str() -> String {
|
||||
Local::now().format("%Y-%m-%d").to_string()
|
||||
}
|
||||
|
||||
/// Get the start of the current week (Monday)
|
||||
fn week_start() -> NaiveDate {
|
||||
let today = Local::now().date_naive();
|
||||
let days_since_monday = today.weekday().num_days_from_monday();
|
||||
today - chrono::Duration::days(days_since_monday as i64)
|
||||
}
|
||||
|
||||
/// Get the start of the current month
|
||||
fn month_start() -> NaiveDate {
|
||||
let today = Local::now().date_naive();
|
||||
NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today)
|
||||
}
|
||||
|
||||
/// Add cost for today
|
||||
pub fn add_cost(&mut self, input_tokens: u64, output_tokens: u64, cost_usd: f64) {
|
||||
let today = Self::today_str();
|
||||
|
||||
// Reset alert flags if it's a new day
|
||||
if self.last_alert_reset_date.as_ref() != Some(&today) {
|
||||
self.daily_alert_triggered = false;
|
||||
// Reset weekly on Monday
|
||||
if Local::now().weekday() == Weekday::Mon {
|
||||
self.weekly_alert_triggered = false;
|
||||
}
|
||||
// Reset monthly on the 1st
|
||||
if Local::now().day() == 1 {
|
||||
self.monthly_alert_triggered = false;
|
||||
}
|
||||
self.last_alert_reset_date = Some(today.clone());
|
||||
}
|
||||
|
||||
let daily = self.daily_costs.entry(today).or_default();
|
||||
daily.input_tokens += input_tokens;
|
||||
daily.output_tokens += output_tokens;
|
||||
daily.cost_usd += cost_usd;
|
||||
daily.messages_sent += 1;
|
||||
}
|
||||
|
||||
/// Increment session count for today
|
||||
pub fn increment_sessions(&mut self) {
|
||||
let today = Self::today_str();
|
||||
let daily = self.daily_costs.entry(today.clone()).or_insert_with(|| DailyCost {
|
||||
date: today,
|
||||
..Default::default()
|
||||
});
|
||||
daily.sessions_count += 1;
|
||||
}
|
||||
|
||||
/// Get today's cost
|
||||
pub fn get_today_cost(&self) -> f64 {
|
||||
self.daily_costs
|
||||
.get(&Self::today_str())
|
||||
.map(|d| d.cost_usd)
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Get this week's cost (Monday to Sunday)
|
||||
pub fn get_week_cost(&self) -> f64 {
|
||||
let week_start = Self::week_start();
|
||||
self.daily_costs
|
||||
.values()
|
||||
.filter(|d| {
|
||||
NaiveDate::parse_from_str(&d.date, "%Y-%m-%d")
|
||||
.map(|date| date >= week_start)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|d| d.cost_usd)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Get this month's cost
|
||||
pub fn get_month_cost(&self) -> f64 {
|
||||
let month_start = Self::month_start();
|
||||
self.daily_costs
|
||||
.values()
|
||||
.filter(|d| {
|
||||
NaiveDate::parse_from_str(&d.date, "%Y-%m-%d")
|
||||
.map(|date| date >= month_start)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|d| d.cost_usd)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Get cost summary for a date range
|
||||
pub fn get_summary(&self, days: u32) -> CostSummary {
|
||||
let today = Local::now().date_naive();
|
||||
let start_date = today - chrono::Duration::days(days as i64 - 1);
|
||||
|
||||
let mut total_input_tokens = 0u64;
|
||||
let mut total_output_tokens = 0u64;
|
||||
let mut total_cost = 0.0f64;
|
||||
let mut total_messages = 0u64;
|
||||
let mut total_sessions = 0u64;
|
||||
let mut daily_breakdown = Vec::new();
|
||||
|
||||
for i in 0..days {
|
||||
let date = start_date + chrono::Duration::days(i as i64);
|
||||
let date_str = date.format("%Y-%m-%d").to_string();
|
||||
|
||||
if let Some(daily) = self.daily_costs.get(&date_str) {
|
||||
total_input_tokens += daily.input_tokens;
|
||||
total_output_tokens += daily.output_tokens;
|
||||
total_cost += daily.cost_usd;
|
||||
total_messages += daily.messages_sent;
|
||||
total_sessions += daily.sessions_count;
|
||||
daily_breakdown.push(daily.clone());
|
||||
} else {
|
||||
daily_breakdown.push(DailyCost {
|
||||
date: date_str,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
CostSummary {
|
||||
period_days: days,
|
||||
total_input_tokens,
|
||||
total_output_tokens,
|
||||
total_cost,
|
||||
total_messages,
|
||||
total_sessions,
|
||||
average_daily_cost: if days > 0 { total_cost / days as f64 } else { 0.0 },
|
||||
daily_breakdown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if any alert thresholds are exceeded and return which ones
|
||||
pub fn check_alerts(&mut self) -> Vec<CostAlert> {
|
||||
let mut alerts = Vec::new();
|
||||
|
||||
if let Some(threshold) = self.daily_alert_threshold {
|
||||
let today_cost = self.get_today_cost();
|
||||
if today_cost >= threshold && !self.daily_alert_triggered {
|
||||
self.daily_alert_triggered = true;
|
||||
alerts.push(CostAlert {
|
||||
alert_type: AlertType::Daily,
|
||||
threshold,
|
||||
current_cost: today_cost,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(threshold) = self.weekly_alert_threshold {
|
||||
let week_cost = self.get_week_cost();
|
||||
if week_cost >= threshold && !self.weekly_alert_triggered {
|
||||
self.weekly_alert_triggered = true;
|
||||
alerts.push(CostAlert {
|
||||
alert_type: AlertType::Weekly,
|
||||
threshold,
|
||||
current_cost: week_cost,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(threshold) = self.monthly_alert_threshold {
|
||||
let month_cost = self.get_month_cost();
|
||||
if month_cost >= threshold && !self.monthly_alert_triggered {
|
||||
self.monthly_alert_triggered = true;
|
||||
alerts.push(CostAlert {
|
||||
alert_type: AlertType::Monthly,
|
||||
threshold,
|
||||
current_cost: month_cost,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
alerts
|
||||
}
|
||||
|
||||
/// Set alert thresholds
|
||||
pub fn set_alert_thresholds(
|
||||
&mut self,
|
||||
daily: Option<f64>,
|
||||
weekly: Option<f64>,
|
||||
monthly: Option<f64>,
|
||||
) {
|
||||
self.daily_alert_threshold = daily;
|
||||
self.weekly_alert_threshold = weekly;
|
||||
self.monthly_alert_threshold = monthly;
|
||||
}
|
||||
|
||||
/// Clean up old data (keep last N days)
|
||||
#[allow(dead_code)]
|
||||
pub fn cleanup_old_data(&mut self, keep_days: u32) {
|
||||
let cutoff = Local::now().date_naive() - chrono::Duration::days(keep_days as i64);
|
||||
self.daily_costs.retain(|date_str, _| {
|
||||
NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
||||
.map(|date| date >= cutoff)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
}
|
||||
|
||||
/// Export to CSV format
|
||||
pub fn export_csv(&self, days: u32) -> String {
|
||||
let summary = self.get_summary(days);
|
||||
let mut csv = String::from("Date,Input Tokens,Output Tokens,Cost (USD),Messages,Sessions\n");
|
||||
|
||||
for daily in &summary.daily_breakdown {
|
||||
csv.push_str(&format!(
|
||||
"{},{},{},{:.4},{},{}\n",
|
||||
daily.date,
|
||||
daily.input_tokens,
|
||||
daily.output_tokens,
|
||||
daily.cost_usd,
|
||||
daily.messages_sent,
|
||||
daily.sessions_count
|
||||
));
|
||||
}
|
||||
|
||||
// Add totals row
|
||||
csv.push_str(&format!(
|
||||
"TOTAL,{},{},{:.4},{},{}\n",
|
||||
summary.total_input_tokens,
|
||||
summary.total_output_tokens,
|
||||
summary.total_cost,
|
||||
summary.total_messages,
|
||||
summary.total_sessions
|
||||
));
|
||||
|
||||
csv
|
||||
}
|
||||
}
|
||||
|
||||
/// Cost summary for a period
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CostSummary {
|
||||
pub period_days: u32,
|
||||
pub total_input_tokens: u64,
|
||||
pub total_output_tokens: u64,
|
||||
pub total_cost: f64,
|
||||
pub total_messages: u64,
|
||||
pub total_sessions: u64,
|
||||
pub average_daily_cost: f64,
|
||||
pub daily_breakdown: Vec<DailyCost>,
|
||||
}
|
||||
|
||||
/// Alert types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum AlertType {
|
||||
Daily,
|
||||
Weekly,
|
||||
Monthly,
|
||||
}
|
||||
|
||||
/// Cost alert notification
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CostAlert {
|
||||
pub alert_type: AlertType,
|
||||
pub threshold: f64,
|
||||
pub current_cost: f64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_add_cost() {
|
||||
let mut history = CostHistory::new();
|
||||
history.add_cost(1000, 500, 0.05);
|
||||
|
||||
let today_cost = history.get_today_cost();
|
||||
assert!((today_cost - 0.05).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_accumulate_daily_cost() {
|
||||
let mut history = CostHistory::new();
|
||||
history.add_cost(1000, 500, 0.05);
|
||||
history.add_cost(2000, 1000, 0.10);
|
||||
|
||||
let today_cost = history.get_today_cost();
|
||||
assert!((today_cost - 0.15).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summary() {
|
||||
let mut history = CostHistory::new();
|
||||
history.add_cost(1000, 500, 0.05);
|
||||
|
||||
let summary = history.get_summary(7);
|
||||
assert_eq!(summary.period_days, 7);
|
||||
assert!((summary.total_cost - 0.05).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_daily_alert() {
|
||||
let mut history = CostHistory::new();
|
||||
history.set_alert_thresholds(Some(0.10), None, None);
|
||||
|
||||
history.add_cost(1000, 500, 0.05);
|
||||
let alerts = history.check_alerts();
|
||||
assert!(alerts.is_empty());
|
||||
|
||||
history.add_cost(1000, 500, 0.06);
|
||||
let alerts = history.check_alerts();
|
||||
assert_eq!(alerts.len(), 1);
|
||||
assert_eq!(alerts[0].alert_type, AlertType::Daily);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alert_only_triggers_once() {
|
||||
let mut history = CostHistory::new();
|
||||
history.set_alert_thresholds(Some(0.10), None, None);
|
||||
|
||||
history.add_cost(1000, 500, 0.15);
|
||||
let alerts = history.check_alerts();
|
||||
assert_eq!(alerts.len(), 1);
|
||||
|
||||
// Second check should not trigger again
|
||||
let alerts = history.check_alerts();
|
||||
assert!(alerts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export_csv() {
|
||||
let mut history = CostHistory::new();
|
||||
history.add_cost(1000, 500, 0.05);
|
||||
|
||||
let csv = history.export_csv(1);
|
||||
assert!(csv.contains("Date,Input Tokens"));
|
||||
assert!(csv.contains("TOTAL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_increment_sessions() {
|
||||
let mut history = CostHistory::new();
|
||||
history.increment_sessions();
|
||||
history.increment_sessions();
|
||||
|
||||
let summary = history.get_summary(1);
|
||||
assert_eq!(summary.total_sessions, 2);
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tracing::{Level, Subscriber};
|
||||
use tracing_subscriber::layer::{Context, Layer};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DebugLogEvent {
|
||||
pub level: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TauriLogLayer {
|
||||
app: Arc<AppHandle>,
|
||||
}
|
||||
|
||||
impl TauriLogLayer {
|
||||
pub fn new(app: AppHandle) -> Self {
|
||||
Self {
|
||||
app: Arc::new(app),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for TauriLogLayer
|
||||
where
|
||||
S: Subscriber,
|
||||
{
|
||||
fn on_event(
|
||||
&self,
|
||||
event: &tracing::Event<'_>,
|
||||
_ctx: Context<'_, S>,
|
||||
) {
|
||||
let metadata = event.metadata();
|
||||
let level = match *metadata.level() {
|
||||
Level::ERROR => "error",
|
||||
Level::WARN => "warn",
|
||||
Level::INFO => "info",
|
||||
Level::DEBUG => "debug",
|
||||
Level::TRACE => "debug",
|
||||
};
|
||||
|
||||
// Extract message from the event
|
||||
struct MessageVisitor {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl tracing::field::Visit for MessageVisitor {
|
||||
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
||||
if field.name() == "message" {
|
||||
self.message = format!("{:?}", value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut visitor = MessageVisitor {
|
||||
message: String::new(),
|
||||
};
|
||||
event.record(&mut visitor);
|
||||
|
||||
// If we couldn't extract a message, try to format the whole event
|
||||
if visitor.message.is_empty() {
|
||||
visitor.message = metadata.name().to_string();
|
||||
}
|
||||
|
||||
// Strip quotes from the message
|
||||
let message = visitor.message.trim_matches('"').to_string();
|
||||
|
||||
let log_event = DebugLogEvent {
|
||||
level: level.to_string(),
|
||||
message,
|
||||
};
|
||||
|
||||
// Emit to frontend
|
||||
let _ = self.app.emit("debug:log", log_event);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_debug_log_event_creation() {
|
||||
let event = DebugLogEvent {
|
||||
level: "info".to_string(),
|
||||
message: "Test message".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(event.level, "info");
|
||||
assert_eq!(event.message, "Test message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_log_event_serialization() {
|
||||
let event = DebugLogEvent {
|
||||
level: "error".to_string(),
|
||||
message: "Error occurred".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("\"level\":\"error\""));
|
||||
assert!(json.contains("\"message\":\"Error occurred\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_log_event_deserialization() {
|
||||
let json = r#"{"level":"warn","message":"Warning message"}"#;
|
||||
let event: DebugLogEvent = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(event.level, "warn");
|
||||
assert_eq!(event.message, "Warning message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_log_event_with_special_characters() {
|
||||
let event = DebugLogEvent {
|
||||
level: "info".to_string(),
|
||||
message: "Message with \"quotes\" and \n newlines".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let decoded: DebugLogEvent = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(decoded.level, event.level);
|
||||
assert_eq!(decoded.message, event.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_log_event_with_unicode() {
|
||||
let event = DebugLogEvent {
|
||||
level: "debug".to_string(),
|
||||
message: "Unicode: 日本語 🎉".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let decoded: DebugLogEvent = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(decoded.message, "Unicode: 日本語 🎉");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_log_event_all_levels() {
|
||||
let levels = vec!["error", "warn", "info", "debug", "trace"];
|
||||
|
||||
for level in levels {
|
||||
let event = DebugLogEvent {
|
||||
level: level.to_string(),
|
||||
message: format!("{} level message", level),
|
||||
};
|
||||
|
||||
assert_eq!(event.level, level);
|
||||
assert!(event.message.contains(level));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
use discord_rich_presence::activity::{Activity, Assets, Timestamps};
|
||||
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
|
||||
use parking_lot::RwLock;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct DiscordRpcManager {
|
||||
client: Arc<RwLock<Option<DiscordIpcClient>>>,
|
||||
session_name: Arc<RwLock<String>>,
|
||||
model: Arc<RwLock<String>>,
|
||||
started_at: Arc<RwLock<i64>>,
|
||||
}
|
||||
|
||||
impl DiscordRpcManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: Arc::new(RwLock::new(None)),
|
||||
session_name: Arc::new(RwLock::new(String::new())),
|
||||
model: Arc::new(RwLock::new(String::new())),
|
||||
started_at: Arc::new(RwLock::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(&self, initial_session_name: String, initial_model: String, started_at: i64) -> Result<(), String> {
|
||||
tracing::debug!("Attempting to initialize Discord RPC...");
|
||||
tracing::debug!("Application ID: 1391117878182281316");
|
||||
tracing::debug!("Initial session: '{}', model: '{}', timestamp: {}",
|
||||
initial_session_name, initial_model, started_at);
|
||||
|
||||
let mut client = DiscordIpcClient::new("1391117878182281316")
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Failed to create Discord RPC client: {} (is Discord running?)", e);
|
||||
tracing::error!("{}", error_msg);
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
tracing::debug!("DiscordIpcClient created successfully");
|
||||
|
||||
client
|
||||
.connect()
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Failed to connect to Discord RPC: {} (ensure Discord is running)", e);
|
||||
tracing::error!("{}", error_msg);
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
tracing::debug!("Connected to Discord IPC socket");
|
||||
|
||||
// Set initial activity immediately after connecting
|
||||
tracing::debug!("Building initial activity...");
|
||||
let state_text = format!("Model: {}", initial_model);
|
||||
let assets = Assets::new()
|
||||
.large_image("hikari")
|
||||
.large_text("Hikari - Claude Code Assistant");
|
||||
|
||||
tracing::debug!("Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
|
||||
|
||||
let timestamps = Timestamps::new()
|
||||
.start(started_at);
|
||||
|
||||
tracing::debug!("Timestamps created - start: {}", started_at);
|
||||
|
||||
let activity = Activity::new()
|
||||
.details(initial_session_name.as_str())
|
||||
.state(state_text.as_str())
|
||||
.assets(assets)
|
||||
.timestamps(timestamps);
|
||||
|
||||
tracing::debug!("Activity created - details: '{}', state: '{}'",
|
||||
initial_session_name, state_text);
|
||||
|
||||
tracing::debug!("Attempting to set initial activity...");
|
||||
client
|
||||
.set_activity(activity)
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Failed to set initial Discord RPC activity: {}", e);
|
||||
tracing::error!("{}", error_msg);
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
tracing::debug!("Initial activity set successfully!");
|
||||
|
||||
// Store the client and initial state
|
||||
*self.client.write() = Some(client);
|
||||
*self.session_name.write() = initial_session_name.clone();
|
||||
*self.model.write() = initial_model.clone();
|
||||
*self.started_at.write() = started_at;
|
||||
|
||||
tracing::info!("Discord RPC connected successfully with initial activity: session='{}', model='{}'",
|
||||
initial_session_name, initial_model);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update(
|
||||
&self,
|
||||
session_name: String,
|
||||
model: String,
|
||||
started_at: i64,
|
||||
) -> Result<(), String> {
|
||||
tracing::debug!("update() called with session='{}', model='{}', timestamp={}",
|
||||
session_name, model, started_at);
|
||||
|
||||
*self.session_name.write() = session_name.clone();
|
||||
*self.model.write() = model.clone();
|
||||
*self.started_at.write() = started_at;
|
||||
|
||||
tracing::debug!("State variables updated");
|
||||
|
||||
let mut client_guard = self.client.write();
|
||||
let client = client_guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| {
|
||||
let error_msg = "Discord RPC client not initialized".to_string();
|
||||
tracing::error!("{}", error_msg);
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
tracing::debug!("Client lock acquired");
|
||||
|
||||
let state_text = format!("Model: {}", model);
|
||||
let assets = Assets::new()
|
||||
.large_image("hikari")
|
||||
.large_text("Hikari - Claude Code Assistant");
|
||||
|
||||
tracing::debug!("Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
|
||||
|
||||
let timestamps = Timestamps::new()
|
||||
.start(started_at);
|
||||
|
||||
tracing::debug!("Timestamps created - start: {}", started_at);
|
||||
|
||||
let activity = Activity::new()
|
||||
.details(session_name.as_str())
|
||||
.state(state_text.as_str())
|
||||
.assets(assets)
|
||||
.timestamps(timestamps);
|
||||
|
||||
tracing::debug!("Activity created - details: '{}', state: '{}'",
|
||||
session_name, state_text);
|
||||
|
||||
tracing::debug!("Attempting to set activity...");
|
||||
client
|
||||
.set_activity(activity)
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Failed to update Discord RPC: {}", e);
|
||||
tracing::error!("{}", error_msg);
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
tracing::info!("Updated Discord RPC: session='{}', model='{}'", session_name, model);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(&self) -> Result<(), String> {
|
||||
tracing::debug!("stop() called");
|
||||
|
||||
let mut client_guard = self.client.write();
|
||||
if let Some(mut client) = client_guard.take() {
|
||||
tracing::debug!("Client found, attempting to close...");
|
||||
client
|
||||
.close()
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Failed to close Discord RPC: {}", e);
|
||||
tracing::error!("{}", error_msg);
|
||||
error_msg
|
||||
})?;
|
||||
tracing::info!("Discord RPC stopped successfully");
|
||||
} else {
|
||||
tracing::debug!("No client to stop (already stopped or never initialized)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DiscordRpcManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
+10
-60
@@ -3,9 +3,6 @@ mod bridge_manager;
|
||||
mod clipboard;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod cost_tracking;
|
||||
mod debug_logger;
|
||||
mod discord_rpc;
|
||||
mod git;
|
||||
mod notifications;
|
||||
mod quick_actions;
|
||||
@@ -13,7 +10,6 @@ mod sessions;
|
||||
mod snippets;
|
||||
mod stats;
|
||||
mod temp_manager;
|
||||
mod tool_cache;
|
||||
mod tray;
|
||||
mod types;
|
||||
mod vbs_notification;
|
||||
@@ -25,19 +21,14 @@ use bridge_manager::create_shared_bridge_manager;
|
||||
use clipboard::*;
|
||||
use commands::load_saved_achievements;
|
||||
use commands::*;
|
||||
use debug_logger::TauriLogLayer;
|
||||
use discord_rpc::DiscordRpcManager;
|
||||
use git::*;
|
||||
use notifications::*;
|
||||
use quick_actions::*;
|
||||
use sessions::*;
|
||||
use snippets::*;
|
||||
use std::sync::Arc;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri::Manager;
|
||||
use temp_manager::create_shared_temp_manager;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tray::setup_tray;
|
||||
use tray::{setup_tray, should_minimize_to_tray};
|
||||
use vbs_notification::*;
|
||||
use windows_toast::*;
|
||||
use wsl_notifications::*;
|
||||
@@ -46,7 +37,6 @@ use wsl_notifications::*;
|
||||
pub fn run() {
|
||||
let bridge_manager = create_shared_bridge_manager();
|
||||
let temp_manager = create_shared_temp_manager().expect("Failed to create temp file manager");
|
||||
let discord_rpc = Arc::new(DiscordRpcManager::new());
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
@@ -60,45 +50,33 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.manage(bridge_manager.clone())
|
||||
.manage(temp_manager.clone())
|
||||
.manage(discord_rpc.clone())
|
||||
.setup(move |app| {
|
||||
// Initialize tracing with custom layer that emits to frontend
|
||||
// NOTE: We don't use fmt::layer() because in production builds with windows_subsystem = "windows",
|
||||
// stdout is hidden. Instead, all logs go through TauriLogLayer to the debug console.
|
||||
let tauri_layer = TauriLogLayer::new(app.handle().clone());
|
||||
tracing_subscriber::registry()
|
||||
.with(tauri_layer)
|
||||
.init();
|
||||
|
||||
// Initialize the app handle in the bridge manager
|
||||
bridge_manager.lock().set_app_handle(app.handle().clone());
|
||||
|
||||
// Clean up any orphaned temp files from previous sessions
|
||||
if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() {
|
||||
if count > 0 {
|
||||
tracing::info!("Cleaned up {} orphaned temp files", count);
|
||||
println!("Cleaned up {} orphaned temp files", count);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Hikari Desktop started successfully");
|
||||
|
||||
// Set up system tray
|
||||
if let Err(e) = setup_tray(app.handle()) {
|
||||
tracing::error!("Failed to set up system tray: {}", e);
|
||||
eprintln!("Failed to set up system tray: {}", e);
|
||||
}
|
||||
|
||||
// Handle window close event for minimize to tray and close confirmation
|
||||
// Handle window close event for minimize to tray
|
||||
let main_window = app.get_webview_window("main").unwrap();
|
||||
main_window.on_window_event({
|
||||
let app_handle = app.handle().clone();
|
||||
move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
// Always prevent default close - let frontend handle it
|
||||
api.prevent_close();
|
||||
|
||||
// Emit event to frontend to show confirmation modal
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.emit("window-close-requested", ());
|
||||
if should_minimize_to_tray(&app_handle) {
|
||||
api.prevent_close();
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,34 +159,6 @@ pub fn run() {
|
||||
delete_file,
|
||||
delete_directory,
|
||||
rename_path,
|
||||
// Cost tracking commands
|
||||
get_cost_summary,
|
||||
get_cost_alerts,
|
||||
set_cost_alert_thresholds,
|
||||
export_cost_csv,
|
||||
get_today_cost,
|
||||
get_week_cost,
|
||||
get_month_cost,
|
||||
init_discord_rpc,
|
||||
update_discord_rpc,
|
||||
stop_discord_rpc,
|
||||
close_application,
|
||||
list_memory_files,
|
||||
get_claude_version,
|
||||
list_plugins,
|
||||
install_plugin,
|
||||
uninstall_plugin,
|
||||
enable_plugin,
|
||||
disable_plugin,
|
||||
update_plugin,
|
||||
list_marketplaces,
|
||||
add_marketplace,
|
||||
remove_marketplace,
|
||||
list_mcp_servers,
|
||||
get_mcp_server,
|
||||
remove_mcp_server,
|
||||
add_mcp_server,
|
||||
get_mcp_server_details,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
+29
-318
@@ -1,83 +1,6 @@
|
||||
use std::process::Command;
|
||||
use tauri::command;
|
||||
|
||||
/// Generate PowerShell script for Windows Toast Notification
|
||||
fn generate_powershell_toast_script(title: &str, body: &str) -> String {
|
||||
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("\"", "`\"")
|
||||
)
|
||||
}
|
||||
|
||||
/// Format simple notification message
|
||||
fn format_simple_notification(title: &str, body: &str) -> String {
|
||||
format!("{}\n\n{}", title, body)
|
||||
}
|
||||
|
||||
/// Build notify-send command for testing (doesn't execute)
|
||||
#[cfg(test)]
|
||||
fn build_notify_send_command(title: &str, body: &str) -> (String, Vec<String>) {
|
||||
(
|
||||
"notify-send".to_string(),
|
||||
vec![
|
||||
title.to_string(),
|
||||
body.to_string(),
|
||||
"--urgency=normal".to_string(),
|
||||
"--app-name=Hikari Desktop".to_string(),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Build Windows PowerShell command for testing (doesn't execute)
|
||||
#[cfg(test)]
|
||||
fn build_windows_powershell_command(title: &str, body: &str) -> (String, Vec<String>) {
|
||||
let script = generate_powershell_toast_script(title, body);
|
||||
(
|
||||
"pwsh.exe".to_string(),
|
||||
vec![
|
||||
"-NoProfile".to_string(),
|
||||
"-WindowStyle".to_string(),
|
||||
"Hidden".to_string(),
|
||||
"-Command".to_string(),
|
||||
script,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Build simple notification command for testing (doesn't execute)
|
||||
#[cfg(test)]
|
||||
fn build_simple_notification_command(title: &str, body: &str) -> (String, Vec<String>) {
|
||||
let message = format_simple_notification(title, body);
|
||||
(
|
||||
"cmd.exe".to_string(),
|
||||
vec!["/c".to_string(), "msg".to_string(), "*".to_string(), message],
|
||||
)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
|
||||
// Use notify-send for Linux/WSL
|
||||
@@ -105,7 +28,34 @@ pub async fn send_notify_send(title: String, body: String) -> Result<(), String>
|
||||
#[command]
|
||||
pub async fn send_windows_notification(title: String, body: String) -> Result<(), String> {
|
||||
// Create PowerShell script for Windows Toast Notification
|
||||
let ps_script = generate_powershell_toast_script(&title, &body);
|
||||
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")
|
||||
@@ -137,7 +87,7 @@ pub async fn send_windows_notification(title: String, body: String) -> Result<()
|
||||
// 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_simple_notification(&title, &body);
|
||||
let message = format!("{}\n\n{}", title, body);
|
||||
|
||||
Command::new("cmd.exe")
|
||||
.arg("/c")
|
||||
@@ -149,242 +99,3 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(),
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generate_powershell_toast_script_basic() {
|
||||
let script = generate_powershell_toast_script("Title", "Body");
|
||||
|
||||
assert!(script.contains("Hikari Desktop"));
|
||||
assert!(script.contains("Title"));
|
||||
assert!(script.contains("Body"));
|
||||
assert!(script.contains("ToastNotification"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_powershell_toast_script_escapes_quotes() {
|
||||
let script = generate_powershell_toast_script("Title with \"quotes\"", "Body with \"quotes\"");
|
||||
|
||||
// Quotes should be escaped as `" in PowerShell
|
||||
assert!(script.contains("Title with `\"quotes`\""));
|
||||
assert!(script.contains("Body with `\"quotes`\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_powershell_toast_script_with_special_chars() {
|
||||
let script = generate_powershell_toast_script("Title: Test", "Body\nwith\nnewlines");
|
||||
|
||||
assert!(script.contains("Title: Test"));
|
||||
assert!(script.contains("Body\nwith\nnewlines"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_powershell_toast_script_unicode() {
|
||||
let script = generate_powershell_toast_script("日本語 Title", "Unicode: 🎉");
|
||||
|
||||
assert!(script.contains("日本語 Title"));
|
||||
assert!(script.contains("Unicode: 🎉"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_powershell_toast_script_empty() {
|
||||
let script = generate_powershell_toast_script("", "");
|
||||
|
||||
// Should still contain the structure
|
||||
assert!(script.contains("Hikari Desktop"));
|
||||
assert!(script.contains("ToastNotification"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_simple_notification_basic() {
|
||||
let message = format_simple_notification("Title", "Body");
|
||||
|
||||
assert_eq!(message, "Title\n\nBody");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_simple_notification_with_newlines() {
|
||||
let message = format_simple_notification("Multi\nLine\nTitle", "Multi\nLine\nBody");
|
||||
|
||||
assert!(message.contains("Multi\nLine\nTitle"));
|
||||
assert!(message.contains("\n\n"));
|
||||
assert!(message.contains("Multi\nLine\nBody"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_simple_notification_unicode() {
|
||||
let message = format_simple_notification("日本語", "🎉 Unicode");
|
||||
|
||||
assert_eq!(message, "日本語\n\n🎉 Unicode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_simple_notification_empty() {
|
||||
let message = format_simple_notification("", "");
|
||||
|
||||
assert_eq!(message, "\n\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_simple_notification_long_text() {
|
||||
let long_title = "A".repeat(1000);
|
||||
let long_body = "B".repeat(1000);
|
||||
let message = format_simple_notification(&long_title, &long_body);
|
||||
|
||||
assert!(message.starts_with(&long_title));
|
||||
assert!(message.ends_with(&long_body));
|
||||
assert!(message.contains("\n\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_powershell_toast_script_multiple_quotes() {
|
||||
let script = generate_powershell_toast_script(
|
||||
"\"Quoted\" \"Multiple\" \"Times\"",
|
||||
"\"More\" \"Quotes\" \"Here\""
|
||||
);
|
||||
|
||||
// Each quote should be escaped
|
||||
assert!(script.contains("`\"Quoted`\" `\"Multiple`\" `\"Times`\""));
|
||||
assert!(script.contains("`\"More`\" `\"Quotes`\" `\"Here`\""));
|
||||
}
|
||||
|
||||
// E2E Integration Tests - Command Structure Verification
|
||||
|
||||
#[test]
|
||||
fn test_e2e_notify_send_command_structure() {
|
||||
let (command, args) = build_notify_send_command("Test Title", "Test Body");
|
||||
|
||||
assert_eq!(command, "notify-send");
|
||||
assert_eq!(args.len(), 4);
|
||||
assert_eq!(args[0], "Test Title");
|
||||
assert_eq!(args[1], "Test Body");
|
||||
assert_eq!(args[2], "--urgency=normal");
|
||||
assert_eq!(args[3], "--app-name=Hikari Desktop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_notify_send_with_special_chars() {
|
||||
let (command, args) =
|
||||
build_notify_send_command("Title with \"quotes\"", "Body\nwith\nnewlines");
|
||||
|
||||
assert_eq!(command, "notify-send");
|
||||
assert_eq!(args[0], "Title with \"quotes\"");
|
||||
assert_eq!(args[1], "Body\nwith\nnewlines");
|
||||
// notify-send handles these directly
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_windows_powershell_command_structure() {
|
||||
let (command, args) = build_windows_powershell_command("Test Title", "Test Body");
|
||||
|
||||
assert_eq!(command, "pwsh.exe");
|
||||
assert_eq!(args.len(), 5);
|
||||
assert_eq!(args[0], "-NoProfile");
|
||||
assert_eq!(args[1], "-WindowStyle");
|
||||
assert_eq!(args[2], "Hidden");
|
||||
assert_eq!(args[3], "-Command");
|
||||
|
||||
// Verify the script in args[4] contains expected elements
|
||||
let script = &args[4];
|
||||
assert!(script.contains("Test Title"));
|
||||
assert!(script.contains("Test Body"));
|
||||
assert!(script.contains("Hikari Desktop"));
|
||||
assert!(script.contains("ToastNotification"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_windows_powershell_quote_escaping() {
|
||||
let (_, args) =
|
||||
build_windows_powershell_command("Title with \"quotes\"", "Body with \"quotes\"");
|
||||
|
||||
let script = &args[4];
|
||||
// Verify quotes are properly escaped in the PowerShell script
|
||||
assert!(script.contains("Title with `\"quotes`\""));
|
||||
assert!(script.contains("Body with `\"quotes`\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_simple_notification_command_structure() {
|
||||
let (command, args) = build_simple_notification_command("Test Title", "Test Body");
|
||||
|
||||
assert_eq!(command, "cmd.exe");
|
||||
assert_eq!(args.len(), 4);
|
||||
assert_eq!(args[0], "/c");
|
||||
assert_eq!(args[1], "msg");
|
||||
assert_eq!(args[2], "*");
|
||||
assert_eq!(args[3], "Test Title\n\nTest Body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_simple_notification_multiline() {
|
||||
let (_, args) =
|
||||
build_simple_notification_command("Multi\nLine\nTitle", "Multi\nLine\nBody");
|
||||
|
||||
let message = &args[3];
|
||||
assert!(message.contains("Multi\nLine\nTitle"));
|
||||
assert!(message.contains("\n\n"));
|
||||
assert!(message.contains("Multi\nLine\nBody"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_command_consistency_across_platforms() {
|
||||
// Test that different platforms use consistent parameters
|
||||
let title = "Consistency Test";
|
||||
let body = "Testing cross-platform consistency";
|
||||
|
||||
// Linux command
|
||||
let (notify_cmd, notify_args) = build_notify_send_command(title, body);
|
||||
assert!(notify_cmd.contains("notify"));
|
||||
assert!(notify_args.iter().any(|arg| arg.contains("Hikari Desktop")));
|
||||
|
||||
// Windows PowerShell command
|
||||
let (ps_cmd, ps_args) = build_windows_powershell_command(title, body);
|
||||
assert!(ps_cmd.contains("pwsh") || ps_cmd.contains("powershell"));
|
||||
let ps_script = &ps_args[4];
|
||||
assert!(ps_script.contains("Hikari Desktop"));
|
||||
|
||||
// Windows simple command
|
||||
let (msg_cmd, msg_args) = build_simple_notification_command(title, body);
|
||||
assert!(msg_cmd.contains("cmd"));
|
||||
assert!(msg_args[3].contains(title));
|
||||
assert!(msg_args[3].contains(body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_unicode_support_across_platforms() {
|
||||
let title = "日本語 Title";
|
||||
let body = "Unicode: 🎉";
|
||||
|
||||
// Verify all platforms preserve unicode
|
||||
let (_, notify_args) = build_notify_send_command(title, body);
|
||||
assert_eq!(notify_args[0], title);
|
||||
assert_eq!(notify_args[1], body);
|
||||
|
||||
let (_, ps_args) = build_windows_powershell_command(title, body);
|
||||
let ps_script = &ps_args[4];
|
||||
assert!(ps_script.contains(title));
|
||||
assert!(ps_script.contains(body));
|
||||
|
||||
let (_, msg_args) = build_simple_notification_command(title, body);
|
||||
assert!(msg_args[3].contains(title));
|
||||
assert!(msg_args[3].contains(body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_empty_input_handling() {
|
||||
// Test that empty inputs are handled gracefully
|
||||
let (_, notify_args) = build_notify_send_command("", "");
|
||||
assert_eq!(notify_args[0], "");
|
||||
assert_eq!(notify_args[1], "");
|
||||
|
||||
let (_, ps_args) = build_windows_powershell_command("", "");
|
||||
let ps_script = &ps_args[4];
|
||||
assert!(ps_script.contains("Hikari Desktop")); // Still has app name
|
||||
|
||||
let (_, msg_args) = build_simple_notification_command("", "");
|
||||
assert_eq!(msg_args[3], "\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
+49
-738
@@ -5,113 +5,6 @@ use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
/// Per-tool token usage statistics
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ToolTokenStats {
|
||||
pub call_count: u64,
|
||||
pub estimated_input_tokens: u64,
|
||||
pub estimated_output_tokens: u64,
|
||||
}
|
||||
|
||||
impl ToolTokenStats {
|
||||
#[allow(dead_code)]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn increment_call(&mut self) {
|
||||
self.call_count += 1;
|
||||
}
|
||||
|
||||
pub fn add_tokens(&mut self, input: u64, output: u64) {
|
||||
self.estimated_input_tokens += input;
|
||||
self.estimated_output_tokens += output;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn total_tokens(&self) -> u64 {
|
||||
self.estimated_input_tokens + self.estimated_output_tokens
|
||||
}
|
||||
}
|
||||
|
||||
/// Warning levels for context window utilisation
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContextWarning {
|
||||
/// 50-74% utilisation - conversation is getting long
|
||||
Moderate,
|
||||
/// 75-89% utilisation - consider summarising
|
||||
High,
|
||||
/// 90%+ utilisation - approaching limit
|
||||
Critical,
|
||||
}
|
||||
|
||||
/// Budget status indicating whether user is within their limits
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BudgetStatus {
|
||||
/// Within budget, no concerns
|
||||
Ok,
|
||||
/// Approaching budget limit (warning threshold reached)
|
||||
Warning {
|
||||
budget_type: BudgetType,
|
||||
percent_used: f32,
|
||||
},
|
||||
/// Budget exceeded
|
||||
Exceeded { budget_type: BudgetType },
|
||||
}
|
||||
|
||||
/// Type of budget limit
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BudgetType {
|
||||
Token,
|
||||
Cost,
|
||||
}
|
||||
|
||||
impl ContextWarning {
|
||||
#[allow(dead_code)]
|
||||
pub fn message(&self) -> &'static str {
|
||||
match self {
|
||||
ContextWarning::Moderate => "Context window is 50%+ full. Consider starting a new conversation for better performance.",
|
||||
ContextWarning::High => "Context window is 75%+ full. Responses may degrade. Consider summarising or starting fresh.",
|
||||
ContextWarning::Critical => "Context window is nearly full (90%+)! Start a new conversation to avoid errors.",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the context window limit (in tokens) for a given model
|
||||
fn get_context_window_limit(model: &str) -> u64 {
|
||||
match model {
|
||||
// Claude 4.6 family
|
||||
"claude-opus-4-6" => 200_000,
|
||||
"claude-sonnet-4-6" => 1_000_000, // 1M token context window
|
||||
// Claude 4.5 family - 200K standard context
|
||||
"claude-opus-4-5-20251101"
|
||||
| "claude-sonnet-4-5-20250929"
|
||||
| "claude-haiku-4-5-20251001" => 200_000,
|
||||
// Claude 4.x family - 200K standard context
|
||||
"claude-opus-4-1-20250805"
|
||||
| "claude-opus-4-20250514"
|
||||
| "claude-sonnet-4-20250514" => 200_000,
|
||||
// Claude 3.x family
|
||||
"claude-3-7-sonnet-20250219"
|
||||
| "claude-3-5-sonnet-20241022"
|
||||
| "claude-3-5-sonnet-20240620"
|
||||
| "claude-3-5-haiku-20241022"
|
||||
| "claude-3-opus-20240229"
|
||||
| "claude-3-sonnet-20240229"
|
||||
| "claude-3-haiku-20240307" => 200_000,
|
||||
// Default to 200K for unknown Claude models
|
||||
_ if model.starts_with("claude") => 200_000,
|
||||
// For non-Claude models (Ollama, etc.), use a conservative default
|
||||
_ => 128_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct UsageStats {
|
||||
pub total_input_tokens: u64,
|
||||
@@ -131,8 +24,8 @@ pub struct UsageStats {
|
||||
pub session_files_edited: u64,
|
||||
pub files_created: u64,
|
||||
pub session_files_created: u64,
|
||||
pub tools_usage: HashMap<String, ToolTokenStats>,
|
||||
pub session_tools_usage: HashMap<String, ToolTokenStats>,
|
||||
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>,
|
||||
@@ -145,28 +38,9 @@ pub struct UsageStats {
|
||||
pub night_sessions: u64, // Sessions started after 10 PM
|
||||
pub last_session_date: Option<String>, // ISO date string for streak tracking
|
||||
|
||||
// Context window tracking
|
||||
pub context_tokens_used: u64,
|
||||
pub context_window_limit: u64,
|
||||
pub context_utilisation_percent: f32,
|
||||
|
||||
// Cache analytics (tracks potential savings from repeated tool calls)
|
||||
pub potential_cache_hits: u64,
|
||||
pub potential_cache_savings_tokens: u64,
|
||||
|
||||
// Achievement tracking
|
||||
#[serde(skip)]
|
||||
pub achievements: AchievementProgress,
|
||||
|
||||
// Track current in-flight request for cost estimation on interrupt
|
||||
#[serde(skip)]
|
||||
pub current_request_input: Option<String>,
|
||||
#[serde(skip)]
|
||||
pub current_request_output_chars: u64,
|
||||
#[serde(skip)]
|
||||
pub current_request_thinking_chars: u64,
|
||||
#[serde(skip)]
|
||||
pub current_request_tools: Vec<String>,
|
||||
}
|
||||
|
||||
impl UsageStats {
|
||||
@@ -176,138 +50,17 @@ impl UsageStats {
|
||||
stats
|
||||
}
|
||||
|
||||
pub fn add_usage(
|
||||
&mut self,
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
model: &str,
|
||||
cache_creation_tokens: Option<u64>,
|
||||
cache_read_tokens: Option<u64>,
|
||||
) {
|
||||
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,
|
||||
cache_creation_tokens,
|
||||
cache_read_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());
|
||||
|
||||
// Update context window tracking
|
||||
self.update_context_tracking(model);
|
||||
}
|
||||
|
||||
pub fn update_context_tracking(&mut self, model: &str) {
|
||||
// Get context window limit for the current model
|
||||
self.context_window_limit = get_context_window_limit(model);
|
||||
|
||||
// Context tokens = input tokens (the prompt/context sent to the model)
|
||||
// We track cumulative session input as a proxy for context growth
|
||||
self.context_tokens_used = self.session_input_tokens;
|
||||
|
||||
// Calculate utilisation percentage
|
||||
if self.context_window_limit > 0 {
|
||||
self.context_utilisation_percent =
|
||||
(self.context_tokens_used as f32 / self.context_window_limit as f32) * 100.0;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_context_warning(&self) -> Option<ContextWarning> {
|
||||
if self.context_utilisation_percent >= 90.0 {
|
||||
Some(ContextWarning::Critical)
|
||||
} else if self.context_utilisation_percent >= 75.0 {
|
||||
Some(ContextWarning::High)
|
||||
} else if self.context_utilisation_percent >= 50.0 {
|
||||
Some(ContextWarning::Moderate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn estimate_remaining_tokens(&self) -> u64 {
|
||||
self.context_window_limit
|
||||
.saturating_sub(self.context_tokens_used)
|
||||
}
|
||||
|
||||
/// Check budget status given current usage and budget settings
|
||||
#[allow(dead_code)]
|
||||
pub fn check_budget(
|
||||
&self,
|
||||
budget_enabled: bool,
|
||||
token_budget: Option<u64>,
|
||||
cost_budget: Option<f64>,
|
||||
warning_threshold: f32,
|
||||
) -> BudgetStatus {
|
||||
if !budget_enabled {
|
||||
return BudgetStatus::Ok;
|
||||
}
|
||||
|
||||
let session_tokens = self.session_input_tokens + self.session_output_tokens;
|
||||
|
||||
// Check token budget
|
||||
if let Some(limit) = token_budget {
|
||||
if session_tokens >= limit {
|
||||
return BudgetStatus::Exceeded {
|
||||
budget_type: BudgetType::Token,
|
||||
};
|
||||
}
|
||||
let percent_used = session_tokens as f32 / limit as f32;
|
||||
if percent_used >= warning_threshold {
|
||||
return BudgetStatus::Warning {
|
||||
budget_type: BudgetType::Token,
|
||||
percent_used: percent_used * 100.0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check cost budget
|
||||
if let Some(limit) = cost_budget {
|
||||
if self.session_cost_usd >= limit {
|
||||
return BudgetStatus::Exceeded {
|
||||
budget_type: BudgetType::Cost,
|
||||
};
|
||||
}
|
||||
let percent_used = (self.session_cost_usd / limit) as f32;
|
||||
if percent_used >= warning_threshold {
|
||||
return BudgetStatus::Warning {
|
||||
budget_type: BudgetType::Cost,
|
||||
percent_used: percent_used * 100.0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
BudgetStatus::Ok
|
||||
}
|
||||
|
||||
/// Get remaining token budget (None if no budget set)
|
||||
#[allow(dead_code)]
|
||||
pub fn get_remaining_token_budget(&self, token_budget: Option<u64>) -> Option<u64> {
|
||||
token_budget.map(|limit| {
|
||||
let used = self.session_input_tokens + self.session_output_tokens;
|
||||
limit.saturating_sub(used)
|
||||
})
|
||||
}
|
||||
|
||||
/// Get remaining cost budget (None if no budget set)
|
||||
#[allow(dead_code)]
|
||||
pub fn get_remaining_cost_budget(&self, cost_budget: Option<f64>) -> Option<f64> {
|
||||
cost_budget.map(|limit| {
|
||||
if limit > self.session_cost_usd {
|
||||
limit - self.session_cost_usd
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reset_session(&mut self) {
|
||||
@@ -323,13 +76,6 @@ impl UsageStats {
|
||||
self.session_start = Some(Instant::now());
|
||||
self.achievements.start_session();
|
||||
|
||||
// Reset context window tracking
|
||||
self.context_tokens_used = 0;
|
||||
self.context_utilisation_percent = 0.0;
|
||||
|
||||
// Note: Cache analytics are NOT reset here - they're cumulative across sessions
|
||||
// to show total potential savings over time
|
||||
|
||||
// Track session start for achievements
|
||||
self.track_session_start();
|
||||
}
|
||||
@@ -393,32 +139,11 @@ impl UsageStats {
|
||||
}
|
||||
|
||||
pub fn increment_tool_usage(&mut self, tool_name: &str) {
|
||||
self.tools_usage
|
||||
*self.tools_usage.entry(tool_name.to_string()).or_insert(0) += 1;
|
||||
*self
|
||||
.session_tools_usage
|
||||
.entry(tool_name.to_string())
|
||||
.or_default()
|
||||
.increment_call();
|
||||
self.session_tools_usage
|
||||
.entry(tool_name.to_string())
|
||||
.or_default()
|
||||
.increment_call();
|
||||
}
|
||||
|
||||
pub fn add_tool_tokens(&mut self, tool_name: &str, input_tokens: u64, output_tokens: u64) {
|
||||
self.tools_usage
|
||||
.entry(tool_name.to_string())
|
||||
.or_default()
|
||||
.add_tokens(input_tokens, output_tokens);
|
||||
self.session_tools_usage
|
||||
.entry(tool_name.to_string())
|
||||
.or_default()
|
||||
.add_tokens(input_tokens, output_tokens);
|
||||
}
|
||||
|
||||
/// Record a potential cache hit (when the same tool call is made twice)
|
||||
#[allow(dead_code)]
|
||||
pub fn add_potential_cache_hit(&mut self, tokens_saved: u64) {
|
||||
self.potential_cache_hits += 1;
|
||||
self.potential_cache_savings_tokens += tokens_saved;
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
|
||||
pub fn get_session_duration(&mut self) -> u64 {
|
||||
@@ -459,16 +184,7 @@ impl UsageStats {
|
||||
morning_sessions: self.morning_sessions,
|
||||
night_sessions: self.night_sessions,
|
||||
last_session_date: self.last_session_date.clone(),
|
||||
context_tokens_used: self.context_tokens_used,
|
||||
context_window_limit: self.context_window_limit,
|
||||
context_utilisation_percent: self.context_utilisation_percent,
|
||||
potential_cache_hits: self.potential_cache_hits,
|
||||
potential_cache_savings_tokens: self.potential_cache_savings_tokens,
|
||||
achievements: AchievementProgress::new(), // Dummy for copy
|
||||
current_request_input: None, // Don't copy tracking fields
|
||||
current_request_output_chars: 0,
|
||||
current_request_thinking_chars: 0,
|
||||
current_request_tools: Vec::new(),
|
||||
};
|
||||
check_achievements(&stats_copy, &mut self.achievements)
|
||||
}
|
||||
@@ -490,33 +206,20 @@ fn is_consecutive_day(prev_date: &str, current_date: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Pricing as of February 2026
|
||||
// https://platform.claude.com/docs/en/about-claude/models/overview
|
||||
// Cache pricing: https://platform.claude.com/docs/en/build-with-claude/prompt-caching
|
||||
pub fn calculate_cost(
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
model: &str,
|
||||
cache_creation_tokens: Option<u64>,
|
||||
cache_read_tokens: Option<u64>,
|
||||
) -> f64 {
|
||||
// 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 {
|
||||
// Current generation (Claude 4.6)
|
||||
"claude-opus-4-6" => (5.0, 25.0),
|
||||
"claude-sonnet-4-6" => (3.0, 15.0),
|
||||
// Opus 4.5
|
||||
"claude-opus-4-5-20251101" => (15.0, 75.0),
|
||||
|
||||
// Previous generation (Claude 4.5)
|
||||
"claude-opus-4-5-20251101" => (5.0, 25.0),
|
||||
"claude-sonnet-4-5-20250929" => (3.0, 15.0),
|
||||
"claude-haiku-4-5-20251001" => (1.0, 5.0),
|
||||
|
||||
// Previous generation (Claude 4.x)
|
||||
"claude-opus-4-1-20250805" => (15.0, 75.0),
|
||||
// Opus 4
|
||||
"claude-opus-4-20250514" => (15.0, 75.0),
|
||||
|
||||
// Sonnet 4
|
||||
"claude-sonnet-4-20250514" => (3.0, 15.0),
|
||||
|
||||
// Legacy (Claude 3.x)
|
||||
"claude-3-7-sonnet-20250219" => (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),
|
||||
@@ -528,25 +231,10 @@ pub fn calculate_cost(
|
||||
_ => (3.0, 15.0),
|
||||
};
|
||||
|
||||
// Regular input/output tokens
|
||||
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;
|
||||
|
||||
// Cache write tokens (cache creation) cost 1.25x the base input price
|
||||
let cache_write_cost = if let Some(cache_creation) = cache_creation_tokens {
|
||||
(cache_creation as f64 / 1_000_000.0) * input_price_per_million * 1.25
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Cache read tokens cost 0.1x (10%) the base input price
|
||||
let cache_read_cost = if let Some(cache_read) = cache_read_tokens {
|
||||
(cache_read as f64 / 1_000_000.0) * input_price_per_million * 0.1
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
input_cost + output_cost + cache_write_cost + cache_read_cost
|
||||
input_cost + output_cost
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -564,7 +252,7 @@ pub struct PersistedStats {
|
||||
pub code_blocks_generated: u64,
|
||||
pub files_edited: u64,
|
||||
pub files_created: u64,
|
||||
pub tools_usage: HashMap<String, ToolTokenStats>,
|
||||
pub tools_usage: HashMap<String, u64>,
|
||||
pub sessions_started: u64,
|
||||
pub consecutive_days: u64,
|
||||
pub total_days_used: u64,
|
||||
@@ -620,7 +308,7 @@ pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<()
|
||||
|
||||
let persisted = PersistedStats::from(stats);
|
||||
|
||||
tracing::info!("Saving stats: {:?}", persisted);
|
||||
println!("Saving stats: {:?}", persisted);
|
||||
|
||||
store.set(
|
||||
"lifetime_stats",
|
||||
@@ -628,32 +316,32 @@ pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<()
|
||||
);
|
||||
store.save().map_err(|e| e.to_string())?;
|
||||
|
||||
tracing::info!("Stats saved successfully");
|
||||
println!("Stats saved successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load lifetime stats from persistent store
|
||||
pub async fn load_stats(app: &tauri::AppHandle) -> Option<PersistedStats> {
|
||||
tracing::info!("Loading stats from store...");
|
||||
println!("Loading stats from store...");
|
||||
|
||||
let store = match app.store("stats.json") {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to open stats store: {}", e);
|
||||
println!("Failed to open stats store: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(stats_value) = store.get("lifetime_stats") {
|
||||
tracing::info!("Found lifetime stats in store: {:?}", stats_value);
|
||||
println!("Found lifetime stats in store: {:?}", stats_value);
|
||||
if let Ok(persisted) = serde_json::from_value::<PersistedStats>(stats_value.clone()) {
|
||||
tracing::info!("Loaded lifetime stats successfully");
|
||||
println!("Loaded lifetime stats successfully");
|
||||
return Some(persisted);
|
||||
} else {
|
||||
tracing::error!("Failed to parse lifetime stats");
|
||||
println!("Failed to parse lifetime stats");
|
||||
}
|
||||
} else {
|
||||
tracing::info!("No lifetime stats found in store");
|
||||
println!("No lifetime stats found in store");
|
||||
}
|
||||
|
||||
None
|
||||
@@ -665,7 +353,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_cost_calculation_sonnet() {
|
||||
let cost = calculate_cost(1000, 2000, "claude-sonnet-4-20250514", None, None);
|
||||
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
|
||||
@@ -674,7 +362,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_cost_calculation_opus() {
|
||||
let cost = calculate_cost(1000, 2000, "claude-opus-4-20250514", None, None);
|
||||
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
|
||||
@@ -683,16 +371,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_cost_calculation_opus_45() {
|
||||
let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101", None, None);
|
||||
// Opus 4.5 pricing: $5/MTok input, $25/MTok output
|
||||
// 1000 input tokens = $0.005, 2000 output tokens = $0.05
|
||||
// Total = $0.055
|
||||
assert!((cost - 0.055).abs() < 0.0001);
|
||||
let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101");
|
||||
// Same pricing as Opus 4
|
||||
assert!((cost - 0.165).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_calculation_haiku() {
|
||||
let cost = calculate_cost(1000, 2000, "claude-3-5-haiku-20241022", None, None);
|
||||
let cost = calculate_cost(1000, 2000, "claude-3-5-haiku-20241022");
|
||||
// 1000 input * $1/M = $0.001
|
||||
// 2000 output * $5/M = $0.010
|
||||
// Total = $0.011
|
||||
@@ -701,14 +387,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_cost_calculation_unknown_defaults_to_sonnet() {
|
||||
let cost = calculate_cost(1000, 2000, "some-unknown-model", None, None);
|
||||
let cost = calculate_cost(1000, 2000, "some-unknown-model");
|
||||
// Should default to Sonnet pricing
|
||||
assert!((cost - 0.033).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_calculation_legacy_sonnet() {
|
||||
let cost = calculate_cost(1000, 2000, "claude-3-5-sonnet-20241022", None, None);
|
||||
let cost = calculate_cost(1000, 2000, "claude-3-5-sonnet-20241022");
|
||||
// Same as Sonnet 4 pricing
|
||||
assert!((cost - 0.033).abs() < 0.0001);
|
||||
}
|
||||
@@ -716,7 +402,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_usage_stats_accumulation() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514", None, None);
|
||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
|
||||
|
||||
assert_eq!(stats.total_input_tokens, 1000);
|
||||
assert_eq!(stats.total_output_tokens, 2000);
|
||||
@@ -728,8 +414,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_usage_stats_multiple_accumulations() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514", None, None);
|
||||
stats.add_usage(500, 500, "claude-sonnet-4-20250514", None, None);
|
||||
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514");
|
||||
stats.add_usage(500, 500, "claude-sonnet-4-20250514");
|
||||
|
||||
assert_eq!(stats.total_input_tokens, 1500);
|
||||
assert_eq!(stats.total_output_tokens, 1500);
|
||||
@@ -740,17 +426,17 @@ mod tests {
|
||||
#[test]
|
||||
fn test_usage_stats_model_updated() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514", None, None);
|
||||
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514");
|
||||
assert_eq!(stats.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||
|
||||
stats.add_usage(500, 500, "claude-opus-4-20250514", None, None);
|
||||
stats.add_usage(500, 500, "claude-opus-4-20250514");
|
||||
assert_eq!(stats.model, Some("claude-opus-4-20250514".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_reset() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514", None, None);
|
||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
|
||||
stats.reset_session();
|
||||
|
||||
assert_eq!(stats.total_input_tokens, 1000);
|
||||
@@ -826,33 +512,10 @@ mod tests {
|
||||
stats.increment_tool_usage("Read");
|
||||
stats.increment_tool_usage("Write");
|
||||
|
||||
assert_eq!(stats.tools_usage.get("Read").map(|t| t.call_count), Some(2));
|
||||
assert_eq!(stats.tools_usage.get("Write").map(|t| t.call_count), Some(1));
|
||||
assert_eq!(stats.session_tools_usage.get("Read").map(|t| t.call_count), Some(2));
|
||||
assert_eq!(stats.session_tools_usage.get("Write").map(|t| t.call_count), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_tool_tokens() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.increment_tool_usage("Read");
|
||||
stats.add_tool_tokens("Read", 100, 50);
|
||||
stats.add_tool_tokens("Read", 200, 100);
|
||||
|
||||
let read_stats = stats.tools_usage.get("Read").unwrap();
|
||||
assert_eq!(read_stats.call_count, 1);
|
||||
assert_eq!(read_stats.estimated_input_tokens, 300);
|
||||
assert_eq!(read_stats.estimated_output_tokens, 150);
|
||||
assert_eq!(read_stats.total_tokens(), 450);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_token_stats_default() {
|
||||
let tool_stats = ToolTokenStats::new();
|
||||
assert_eq!(tool_stats.call_count, 0);
|
||||
assert_eq!(tool_stats.estimated_input_tokens, 0);
|
||||
assert_eq!(tool_stats.estimated_output_tokens, 0);
|
||||
assert_eq!(tool_stats.total_tokens(), 0);
|
||||
assert_eq!(stats.tools_usage.get("Read"), Some(&2));
|
||||
assert_eq!(stats.tools_usage.get("Write"), Some(&1));
|
||||
assert_eq!(stats.session_tools_usage.get("Read"), Some(&2));
|
||||
assert_eq!(stats.session_tools_usage.get("Write"), Some(&1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -927,11 +590,7 @@ mod tests {
|
||||
files_created: 5,
|
||||
tools_usage: {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("Read".to_string(), ToolTokenStats {
|
||||
call_count: 50,
|
||||
estimated_input_tokens: 5000,
|
||||
estimated_output_tokens: 2500,
|
||||
});
|
||||
map.insert("Read".to_string(), 50);
|
||||
map
|
||||
},
|
||||
sessions_started: 10,
|
||||
@@ -949,8 +608,7 @@ mod tests {
|
||||
assert_eq!(stats.total_output_tokens, 20000);
|
||||
assert_eq!(stats.total_cost_usd, 5.50);
|
||||
assert_eq!(stats.messages_exchanged, 100);
|
||||
assert_eq!(stats.tools_usage.get("Read").map(|t| t.call_count), Some(50));
|
||||
assert_eq!(stats.tools_usage.get("Read").map(|t| t.estimated_input_tokens), Some(5000));
|
||||
assert_eq!(stats.tools_usage.get("Read"), Some(&50));
|
||||
assert_eq!(stats.consecutive_days, 7);
|
||||
assert_eq!(stats.morning_sessions, 3);
|
||||
assert_eq!(stats.last_session_date, Some("2024-06-15".to_string()));
|
||||
@@ -977,7 +635,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_usage_stats_serialization() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514", None, None);
|
||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
|
||||
stats.increment_messages();
|
||||
|
||||
// UsageStats should be serializable (for events)
|
||||
@@ -1006,7 +664,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_stats_update_event_serialization() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(100, 200, "claude-sonnet-4-20250514", None, None);
|
||||
stats.add_usage(100, 200, "claude-sonnet-4-20250514");
|
||||
|
||||
let event = StatsUpdateEvent { stats };
|
||||
let json = serde_json::to_string(&event).expect("Failed to serialize");
|
||||
@@ -1014,351 +672,4 @@ mod tests {
|
||||
assert!(json.contains("stats"));
|
||||
assert!(json.contains("total_input_tokens"));
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Context Window Tracking tests
|
||||
// =====================
|
||||
|
||||
#[test]
|
||||
fn test_context_window_limit_claude_4() {
|
||||
assert_eq!(get_context_window_limit("claude-opus-4-5-20251101"), 200_000);
|
||||
assert_eq!(get_context_window_limit("claude-opus-4-20250514"), 200_000);
|
||||
assert_eq!(get_context_window_limit("claude-sonnet-4-20250514"), 200_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_window_limit_claude_35() {
|
||||
assert_eq!(
|
||||
get_context_window_limit("claude-3-5-sonnet-20241022"),
|
||||
200_000
|
||||
);
|
||||
assert_eq!(
|
||||
get_context_window_limit("claude-3-5-sonnet-20240620"),
|
||||
200_000
|
||||
);
|
||||
assert_eq!(
|
||||
get_context_window_limit("claude-3-5-haiku-20241022"),
|
||||
200_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_window_limit_unknown_claude() {
|
||||
assert_eq!(
|
||||
get_context_window_limit("claude-some-future-model"),
|
||||
200_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_window_limit_non_claude() {
|
||||
assert_eq!(get_context_window_limit("gpt-4"), 128_000);
|
||||
assert_eq!(get_context_window_limit("llama-3"), 128_000);
|
||||
assert_eq!(get_context_window_limit("unknown-model"), 128_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_tracking_update() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514", None, None);
|
||||
|
||||
assert_eq!(stats.context_tokens_used, 50_000);
|
||||
assert_eq!(stats.context_window_limit, 200_000);
|
||||
assert!((stats.context_utilisation_percent - 25.0).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_tracking_accumulates() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514", None, None);
|
||||
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514", None, None);
|
||||
|
||||
assert_eq!(stats.context_tokens_used, 100_000);
|
||||
assert!((stats.context_utilisation_percent - 50.0).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_warning_none() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.context_utilisation_percent = 40.0;
|
||||
assert!(stats.get_context_warning().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_warning_moderate() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.context_utilisation_percent = 55.0;
|
||||
assert_eq!(stats.get_context_warning(), Some(ContextWarning::Moderate));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_warning_high() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.context_utilisation_percent = 80.0;
|
||||
assert_eq!(stats.get_context_warning(), Some(ContextWarning::High));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_warning_critical() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.context_utilisation_percent = 95.0;
|
||||
assert_eq!(stats.get_context_warning(), Some(ContextWarning::Critical));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_estimate_remaining_tokens() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.context_tokens_used = 50_000;
|
||||
stats.context_window_limit = 200_000;
|
||||
|
||||
assert_eq!(stats.estimate_remaining_tokens(), 150_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_estimate_remaining_tokens_at_limit() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.context_tokens_used = 200_000;
|
||||
stats.context_window_limit = 200_000;
|
||||
|
||||
assert_eq!(stats.estimate_remaining_tokens(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_estimate_remaining_tokens_over_limit() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.context_tokens_used = 250_000;
|
||||
stats.context_window_limit = 200_000;
|
||||
|
||||
assert_eq!(stats.estimate_remaining_tokens(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_reset_on_session_reset() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(100_000, 20_000, "claude-sonnet-4-20250514", None, None);
|
||||
|
||||
assert!(stats.context_tokens_used > 0);
|
||||
assert!(stats.context_utilisation_percent > 0.0);
|
||||
|
||||
stats.reset_session();
|
||||
|
||||
assert_eq!(stats.context_tokens_used, 0);
|
||||
assert_eq!(stats.context_utilisation_percent, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_warning_message() {
|
||||
assert_eq!(
|
||||
ContextWarning::Moderate.message(),
|
||||
"Context window is 50%+ full. Consider starting a new conversation for better performance."
|
||||
);
|
||||
assert_eq!(
|
||||
ContextWarning::High.message(),
|
||||
"Context window is 75%+ full. Responses may degrade. Consider summarising or starting fresh."
|
||||
);
|
||||
assert_eq!(
|
||||
ContextWarning::Critical.message(),
|
||||
"Context window is nearly full (90%+)! Start a new conversation to avoid errors."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_warning_serialization() {
|
||||
let warning = ContextWarning::Critical;
|
||||
let json = serde_json::to_string(&warning).expect("Failed to serialize");
|
||||
assert_eq!(json, "\"critical\"");
|
||||
|
||||
let warning = ContextWarning::Moderate;
|
||||
let json = serde_json::to_string(&warning).expect("Failed to serialize");
|
||||
assert_eq!(json, "\"moderate\"");
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Budget Tracking tests
|
||||
// =====================
|
||||
|
||||
#[test]
|
||||
fn test_budget_disabled_returns_ok() {
|
||||
let stats = UsageStats::new();
|
||||
let status = stats.check_budget(false, Some(1000), Some(1.0), 0.8);
|
||||
assert_eq!(status, BudgetStatus::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_budget_no_limits_returns_ok() {
|
||||
let stats = UsageStats::new();
|
||||
let status = stats.check_budget(true, None, None, 0.8);
|
||||
assert_eq!(status, BudgetStatus::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_budget_within_limit() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.session_input_tokens = 500;
|
||||
stats.session_output_tokens = 300;
|
||||
|
||||
let status = stats.check_budget(true, Some(10000), None, 0.8);
|
||||
assert_eq!(status, BudgetStatus::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_budget_warning() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.session_input_tokens = 4500;
|
||||
stats.session_output_tokens = 4000;
|
||||
|
||||
let status = stats.check_budget(true, Some(10000), None, 0.8);
|
||||
match status {
|
||||
BudgetStatus::Warning {
|
||||
budget_type,
|
||||
percent_used,
|
||||
} => {
|
||||
assert_eq!(budget_type, BudgetType::Token);
|
||||
assert!(percent_used >= 80.0);
|
||||
}
|
||||
_ => panic!("Expected Warning status"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_budget_exceeded() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.session_input_tokens = 6000;
|
||||
stats.session_output_tokens = 5000;
|
||||
|
||||
let status = stats.check_budget(true, Some(10000), None, 0.8);
|
||||
assert_eq!(
|
||||
status,
|
||||
BudgetStatus::Exceeded {
|
||||
budget_type: BudgetType::Token
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_budget_within_limit() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.session_cost_usd = 0.50;
|
||||
|
||||
let status = stats.check_budget(true, None, Some(5.0), 0.8);
|
||||
assert_eq!(status, BudgetStatus::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_budget_warning() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.session_cost_usd = 4.25;
|
||||
|
||||
let status = stats.check_budget(true, None, Some(5.0), 0.8);
|
||||
match status {
|
||||
BudgetStatus::Warning {
|
||||
budget_type,
|
||||
percent_used,
|
||||
} => {
|
||||
assert_eq!(budget_type, BudgetType::Cost);
|
||||
assert!(percent_used >= 80.0);
|
||||
}
|
||||
_ => panic!("Expected Warning status"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_budget_exceeded() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.session_cost_usd = 5.50;
|
||||
|
||||
let status = stats.check_budget(true, None, Some(5.0), 0.8);
|
||||
assert_eq!(
|
||||
status,
|
||||
BudgetStatus::Exceeded {
|
||||
budget_type: BudgetType::Cost
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_budget_takes_priority() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.session_input_tokens = 12000;
|
||||
stats.session_output_tokens = 0;
|
||||
stats.session_cost_usd = 0.01;
|
||||
|
||||
// Token budget exceeded, cost budget OK
|
||||
let status = stats.check_budget(true, Some(10000), Some(5.0), 0.8);
|
||||
assert_eq!(
|
||||
status,
|
||||
BudgetStatus::Exceeded {
|
||||
budget_type: BudgetType::Token
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remaining_token_budget() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.session_input_tokens = 3000;
|
||||
stats.session_output_tokens = 2000;
|
||||
|
||||
assert_eq!(stats.get_remaining_token_budget(Some(10000)), Some(5000));
|
||||
assert_eq!(stats.get_remaining_token_budget(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remaining_token_budget_exceeded() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.session_input_tokens = 8000;
|
||||
stats.session_output_tokens = 5000;
|
||||
|
||||
assert_eq!(stats.get_remaining_token_budget(Some(10000)), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remaining_cost_budget() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.session_cost_usd = 2.50;
|
||||
|
||||
let remaining = stats.get_remaining_cost_budget(Some(5.0));
|
||||
assert!(remaining.is_some());
|
||||
assert!((remaining.unwrap() - 2.50).abs() < 0.001);
|
||||
assert_eq!(stats.get_remaining_cost_budget(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remaining_cost_budget_exceeded() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.session_cost_usd = 6.0;
|
||||
|
||||
let remaining = stats.get_remaining_cost_budget(Some(5.0));
|
||||
assert!(remaining.is_some());
|
||||
assert!((remaining.unwrap() - 0.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_budget_status_serialization() {
|
||||
let status = BudgetStatus::Warning {
|
||||
budget_type: BudgetType::Token,
|
||||
percent_used: 85.5,
|
||||
};
|
||||
let json = serde_json::to_string(&status).expect("Failed to serialize");
|
||||
assert!(json.contains("warning"));
|
||||
assert!(json.contains("token"));
|
||||
|
||||
let status = BudgetStatus::Exceeded {
|
||||
budget_type: BudgetType::Cost,
|
||||
};
|
||||
let json = serde_json::to_string(&status).expect("Failed to serialize");
|
||||
assert!(json.contains("exceeded"));
|
||||
assert!(json.contains("cost"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_budget_type_serialization() {
|
||||
let token = BudgetType::Token;
|
||||
let json = serde_json::to_string(&token).expect("Failed to serialize");
|
||||
assert_eq!(json, "\"token\"");
|
||||
|
||||
let cost = BudgetType::Cost;
|
||||
let json = serde_json::to_string(&cost).expect("Failed to serialize");
|
||||
assert_eq!(json, "\"cost\"");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@ impl TempFileManager {
|
||||
for file_path in files {
|
||||
if file_path.exists() {
|
||||
if let Err(e) = fs::remove_file(&file_path) {
|
||||
tracing::warn!(
|
||||
"Failed to remove temp file {:?}: {}",
|
||||
eprintln!(
|
||||
"Warning: Failed to remove temp file {:?}: {}",
|
||||
file_path, e
|
||||
);
|
||||
}
|
||||
@@ -115,7 +115,7 @@ impl TempFileManager {
|
||||
let path = entry.path();
|
||||
if path.is_file() && !tracked_files.contains(&path) {
|
||||
if let Err(e) = fs::remove_file(&path) {
|
||||
tracing::warn!("Failed to remove orphaned file {:?}: {}", path, e);
|
||||
eprintln!("Warning: Failed to remove orphaned file {:?}: {}", path, e);
|
||||
} else {
|
||||
cleaned_count += 1;
|
||||
}
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
/// Tools that could benefit from caching
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CacheableTool {
|
||||
Read,
|
||||
Glob,
|
||||
Grep,
|
||||
}
|
||||
|
||||
impl CacheableTool {
|
||||
#[allow(dead_code)]
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"Read" => Some(Self::Read),
|
||||
"Glob" => Some(Self::Glob),
|
||||
"Grep" => Some(Self::Grep),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistics about potential cache savings
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
pub struct CacheAnalytics {
|
||||
/// Number of tool calls that could have been cache hits
|
||||
pub potential_cache_hits: u64,
|
||||
/// Estimated tokens that could have been saved
|
||||
pub potential_savings_tokens: u64,
|
||||
/// Tracks unique tool invocations: hash -> (tool_name, call_count)
|
||||
#[serde(skip)]
|
||||
recent_invocations: HashMap<u64, (String, u64)>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl CacheAnalytics {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Compute a hash key from tool name and input
|
||||
fn compute_key(tool_name: &str, input: &serde_json::Value) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
tool_name.hash(&mut hasher);
|
||||
input.to_string().hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Track a tool invocation for analytics
|
||||
/// Returns true if this was a repeated invocation (potential cache hit)
|
||||
pub fn track_invocation(
|
||||
&mut self,
|
||||
tool_name: &str,
|
||||
input: &serde_json::Value,
|
||||
estimated_tokens: u64,
|
||||
) -> bool {
|
||||
// Only track cacheable tools
|
||||
if CacheableTool::from_name(tool_name).is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let key = Self::compute_key(tool_name, input);
|
||||
|
||||
if let Some((_, count)) = self.recent_invocations.get_mut(&key) {
|
||||
*count += 1;
|
||||
// This is a repeat - could have been a cache hit
|
||||
self.potential_cache_hits += 1;
|
||||
self.potential_savings_tokens += estimated_tokens;
|
||||
true
|
||||
} else {
|
||||
self.recent_invocations
|
||||
.insert(key, (tool_name.to_string(), 1));
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the number of unique tool invocations being tracked
|
||||
pub fn unique_invocations(&self) -> usize {
|
||||
self.recent_invocations.len()
|
||||
}
|
||||
|
||||
/// Get invocations that were called more than once
|
||||
pub fn repeated_invocations(&self) -> Vec<(&str, u64)> {
|
||||
self.recent_invocations
|
||||
.values()
|
||||
.filter(|(_, count)| *count > 1)
|
||||
.map(|(name, count)| (name.as_str(), *count))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Clear session analytics (keep totals)
|
||||
pub fn clear_session(&mut self) {
|
||||
self.recent_invocations.clear();
|
||||
}
|
||||
|
||||
/// Fully reset all analytics
|
||||
pub fn reset(&mut self) {
|
||||
self.potential_cache_hits = 0;
|
||||
self.potential_savings_tokens = 0;
|
||||
self.recent_invocations.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_cacheable_tool_from_name() {
|
||||
assert_eq!(CacheableTool::from_name("Read"), Some(CacheableTool::Read));
|
||||
assert_eq!(CacheableTool::from_name("Glob"), Some(CacheableTool::Glob));
|
||||
assert_eq!(CacheableTool::from_name("Grep"), Some(CacheableTool::Grep));
|
||||
assert_eq!(CacheableTool::from_name("Bash"), None);
|
||||
assert_eq!(CacheableTool::from_name("Edit"), None);
|
||||
assert_eq!(CacheableTool::from_name("Write"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_first_invocation_not_cache_hit() {
|
||||
let mut analytics = CacheAnalytics::new();
|
||||
let input = json!({"file_path": "/home/test/file.txt"});
|
||||
|
||||
let is_repeat = analytics.track_invocation("Read", &input, 100);
|
||||
|
||||
assert!(!is_repeat);
|
||||
assert_eq!(analytics.potential_cache_hits, 0);
|
||||
assert_eq!(analytics.potential_savings_tokens, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_second_invocation_is_cache_hit() {
|
||||
let mut analytics = CacheAnalytics::new();
|
||||
let input = json!({"file_path": "/home/test/file.txt"});
|
||||
|
||||
analytics.track_invocation("Read", &input, 100);
|
||||
let is_repeat = analytics.track_invocation("Read", &input, 100);
|
||||
|
||||
assert!(is_repeat);
|
||||
assert_eq!(analytics.potential_cache_hits, 1);
|
||||
assert_eq!(analytics.potential_savings_tokens, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_inputs_not_cache_hit() {
|
||||
let mut analytics = CacheAnalytics::new();
|
||||
let input1 = json!({"file_path": "/home/test/file1.txt"});
|
||||
let input2 = json!({"file_path": "/home/test/file2.txt"});
|
||||
|
||||
analytics.track_invocation("Read", &input1, 100);
|
||||
let is_repeat = analytics.track_invocation("Read", &input2, 100);
|
||||
|
||||
assert!(!is_repeat);
|
||||
assert_eq!(analytics.potential_cache_hits, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_cacheable_tool_ignored() {
|
||||
let mut analytics = CacheAnalytics::new();
|
||||
let input = json!({"command": "ls -la"});
|
||||
|
||||
let is_repeat = analytics.track_invocation("Bash", &input, 100);
|
||||
analytics.track_invocation("Bash", &input, 100);
|
||||
|
||||
assert!(!is_repeat);
|
||||
assert_eq!(analytics.potential_cache_hits, 0);
|
||||
assert_eq!(analytics.unique_invocations(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_repeated_invocations() {
|
||||
let mut analytics = CacheAnalytics::new();
|
||||
let input = json!({"file_path": "/home/test/file.txt"});
|
||||
|
||||
analytics.track_invocation("Read", &input, 100);
|
||||
analytics.track_invocation("Read", &input, 100);
|
||||
analytics.track_invocation("Read", &input, 100);
|
||||
|
||||
assert_eq!(analytics.potential_cache_hits, 2);
|
||||
assert_eq!(analytics.potential_savings_tokens, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unique_invocations_count() {
|
||||
let mut analytics = CacheAnalytics::new();
|
||||
|
||||
analytics.track_invocation("Read", &json!({"file_path": "/file1.txt"}), 100);
|
||||
analytics.track_invocation("Read", &json!({"file_path": "/file2.txt"}), 100);
|
||||
analytics.track_invocation("Glob", &json!({"pattern": "*.rs"}), 50);
|
||||
|
||||
assert_eq!(analytics.unique_invocations(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repeated_invocations_list() {
|
||||
let mut analytics = CacheAnalytics::new();
|
||||
|
||||
// file1 read twice
|
||||
analytics.track_invocation("Read", &json!({"file_path": "/file1.txt"}), 100);
|
||||
analytics.track_invocation("Read", &json!({"file_path": "/file1.txt"}), 100);
|
||||
|
||||
// file2 read once
|
||||
analytics.track_invocation("Read", &json!({"file_path": "/file2.txt"}), 100);
|
||||
|
||||
// glob run 3 times
|
||||
analytics.track_invocation("Glob", &json!({"pattern": "*.rs"}), 50);
|
||||
analytics.track_invocation("Glob", &json!({"pattern": "*.rs"}), 50);
|
||||
analytics.track_invocation("Glob", &json!({"pattern": "*.rs"}), 50);
|
||||
|
||||
let repeated = analytics.repeated_invocations();
|
||||
assert_eq!(repeated.len(), 2); // file1 and glob pattern
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_session() {
|
||||
let mut analytics = CacheAnalytics::new();
|
||||
let input = json!({"file_path": "/file.txt"});
|
||||
|
||||
analytics.track_invocation("Read", &input, 100);
|
||||
analytics.track_invocation("Read", &input, 100);
|
||||
|
||||
assert_eq!(analytics.potential_cache_hits, 1);
|
||||
assert_eq!(analytics.unique_invocations(), 1);
|
||||
|
||||
analytics.clear_session();
|
||||
|
||||
assert_eq!(analytics.potential_cache_hits, 1); // Preserved
|
||||
assert_eq!(analytics.unique_invocations(), 0); // Cleared
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset() {
|
||||
let mut analytics = CacheAnalytics::new();
|
||||
let input = json!({"file_path": "/file.txt"});
|
||||
|
||||
analytics.track_invocation("Read", &input, 100);
|
||||
analytics.track_invocation("Read", &input, 100);
|
||||
|
||||
analytics.reset();
|
||||
|
||||
assert_eq!(analytics.potential_cache_hits, 0);
|
||||
assert_eq!(analytics.potential_savings_tokens, 0);
|
||||
assert_eq!(analytics.unique_invocations(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialization() {
|
||||
let mut analytics = CacheAnalytics::new();
|
||||
analytics.potential_cache_hits = 10;
|
||||
analytics.potential_savings_tokens = 500;
|
||||
|
||||
let json = serde_json::to_string(&analytics).expect("Failed to serialize");
|
||||
let deserialized: CacheAnalytics =
|
||||
serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
|
||||
assert_eq!(deserialized.potential_cache_hits, 10);
|
||||
assert_eq!(deserialized.potential_savings_tokens, 500);
|
||||
// recent_invocations is skipped in serialization
|
||||
assert_eq!(deserialized.unique_invocations(), 0);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ use tauri::{
|
||||
AppHandle, Manager,
|
||||
};
|
||||
|
||||
use crate::config::HikariConfig;
|
||||
|
||||
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||
let show_item = MenuItem::with_id(app, "show", "Show Hikari", true, None::<&str>)?;
|
||||
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||
@@ -46,3 +48,21 @@ pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn should_minimize_to_tray(app: &AppHandle) -> bool {
|
||||
let config_path = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.ok()
|
||||
.map(|p| p.join("hikari-config.json"));
|
||||
|
||||
if let Some(path) = config_path {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
if let Ok(config) = serde_json::from_str::<HikariConfig>(&content) {
|
||||
return config.minimize_to_tray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
+4
-90
@@ -4,10 +4,6 @@ use serde::{Deserialize, Serialize};
|
||||
pub struct UsageInfo {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
#[serde(default)]
|
||||
pub cache_creation_input_tokens: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub cache_read_input_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
@@ -180,14 +176,6 @@ pub struct StateChangeEvent {
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Cost information for a message
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageCost {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cost_usd: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutputEvent {
|
||||
pub line_type: String,
|
||||
@@ -195,23 +183,14 @@ pub struct OutputEvent {
|
||||
pub tool_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cost: Option<MessageCost>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_tool_use_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PermissionPromptEventItem {
|
||||
pub id: String,
|
||||
pub tool_name: String,
|
||||
pub tool_input: serde_json::Value,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PermissionPromptEvent {
|
||||
pub permissions: Vec<PermissionPromptEventItem>,
|
||||
pub id: String,
|
||||
pub tool_name: String,
|
||||
pub tool_input: serde_json::Value,
|
||||
pub description: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
@@ -255,48 +234,6 @@ pub struct UserQuestionEvent {
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentStartEvent {
|
||||
pub tool_use_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_id: Option<String>,
|
||||
pub description: String,
|
||||
pub subagent_type: String,
|
||||
pub started_at: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_tool_use_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentEndEvent {
|
||||
pub tool_use_id: String,
|
||||
pub ended_at: u64,
|
||||
pub is_error: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub num_turns: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TodoItem {
|
||||
pub content: String,
|
||||
pub status: String, // "pending", "in_progress", or "completed"
|
||||
#[serde(rename = "activeForm")]
|
||||
pub active_form: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TodoUpdateEvent {
|
||||
pub todos: Vec<TodoItem>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -417,33 +354,10 @@ mod tests {
|
||||
content: "Test output".to_string(),
|
||||
tool_name: None,
|
||||
conversation_id: None,
|
||||
cost: None,
|
||||
parent_tool_use_id: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"line_type\":\"assistant\""));
|
||||
assert!(serialized.contains("\"content\":\"Test output\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_event_with_cost() {
|
||||
let event = OutputEvent {
|
||||
line_type: "assistant".to_string(),
|
||||
content: "Test output".to_string(),
|
||||
tool_name: None,
|
||||
conversation_id: Some("conv-123".to_string()),
|
||||
cost: Some(MessageCost {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cost_usd: 0.005,
|
||||
}),
|
||||
parent_tool_use_id: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"cost\":"));
|
||||
assert!(serialized.contains("\"input_tokens\":100"));
|
||||
assert!(serialized.contains("\"output_tokens\":50"));
|
||||
}
|
||||
}
|
||||
|
||||
+86
-967
File diff suppressed because it is too large
Load Diff
@@ -48,15 +48,15 @@ $notifier.Show($toast)
|
||||
match output {
|
||||
Ok(result) => {
|
||||
if result.status.success() {
|
||||
tracing::info!("WSL notification sent successfully");
|
||||
println!("WSL notification sent successfully");
|
||||
return Ok(());
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&result.stderr);
|
||||
tracing::error!("PowerShell toast failed: {}", stderr);
|
||||
println!("PowerShell toast failed: {}", stderr);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to run PowerShell: {}", e);
|
||||
println!("Failed to run PowerShell: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ $notifier.Show($toast)
|
||||
|
||||
if let Ok(result) = notify_result {
|
||||
if result.status.success() {
|
||||
tracing::info!("Notification sent via wsl-notify-send");
|
||||
println!("Notification sent via wsl-notify-send");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "hikari-desktop",
|
||||
"version": "1.6.0",
|
||||
"version": "1.1.1",
|
||||
"identifier": "com.naomi.hikari-desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
@@ -8,13 +8,9 @@ import {
|
||||
} from "./slashCommands";
|
||||
|
||||
// Mock all external dependencies
|
||||
vi.mock("svelte/store", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("svelte/store")>();
|
||||
return {
|
||||
...actual,
|
||||
get: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("svelte/store", () => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
|
||||
@@ -2,10 +2,8 @@ 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, updateDiscordRpc } from "$lib/tauri";
|
||||
import { setSkipNextGreeting } from "$lib/tauri";
|
||||
import { searchState } from "$lib/stores/search";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
@@ -37,12 +35,6 @@ async function changeDirectory(path: string): Promise<void> {
|
||||
// Capture conversation history before disconnecting
|
||||
const conversationHistory = claudeStore.getConversationHistory();
|
||||
|
||||
// Get currently granted tools and config auto-granted tools
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
const grantedTools = activeConversation ? Array.from(activeConversation.grantedTools) : [];
|
||||
const config = configStore.getConfig();
|
||||
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
|
||||
|
||||
await invoke("stop_claude", { conversationId });
|
||||
|
||||
// Wait for clean shutdown
|
||||
@@ -56,23 +48,9 @@ async function changeDirectory(path: string): Promise<void> {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: validatedPath,
|
||||
model: config.model || null,
|
||||
api_key: config.api_key || null,
|
||||
custom_instructions: config.custom_instructions || null,
|
||||
mcp_servers_json: config.mcp_servers_json || null,
|
||||
allowed_tools: allAllowedTools,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when reconnecting after directory change
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for connection to establish
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
@@ -111,12 +89,6 @@ async function startNewConversation(): Promise<void> {
|
||||
conversationId,
|
||||
});
|
||||
|
||||
// Get granted tools before interrupting
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
const grantedTools = activeConversation ? Array.from(activeConversation.grantedTools) : [];
|
||||
const config = configStore.getConfig();
|
||||
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
|
||||
|
||||
claudeStore.addLine("system", "Starting new conversation...");
|
||||
characterState.setState("thinking");
|
||||
|
||||
@@ -130,23 +102,9 @@ async function startNewConversation(): Promise<void> {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDir,
|
||||
model: config.model || null,
|
||||
api_key: config.api_key || null,
|
||||
custom_instructions: config.custom_instructions || null,
|
||||
mcp_servers_json: config.mcp_servers_json || null,
|
||||
allowed_tools: allAllowedTools,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when starting new conversation
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
|
||||
claudeStore.addLine("system", "New conversation started!");
|
||||
characterState.setState("idle");
|
||||
} catch (error) {
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
|
||||
<!-- Celebration confetti effect (CSS only) -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
||||
{#each Array.from({ length: 10 }, (_, i) => i) as confettiIndex (confettiIndex)}
|
||||
{#each Array(10) as _ (_)}
|
||||
<div
|
||||
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
|
||||
getAchievementRarity(currentAchievement.achievement.id)
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { agentStore, getAgentsForConversation } from "$lib/stores/agents";
|
||||
import type { AgentInfo } from "$lib/types/agents";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { isOpen, onClose }: Props = $props();
|
||||
|
||||
let now = $state(Date.now());
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// We need a reactive subscription to agents for the active conversation
|
||||
let agents: AgentInfo[] = $state([]);
|
||||
let agentsUnsubscribe: (() => void) | null = null;
|
||||
|
||||
// Track active conversation reactively
|
||||
let currentConversationId = $state<string | null>("");
|
||||
const conversationIdUnsubscribe = claudeStore.activeConversationId.subscribe((id) => {
|
||||
currentConversationId = id;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Re-subscribe when conversation changes
|
||||
if (agentsUnsubscribe) {
|
||||
agentsUnsubscribe();
|
||||
}
|
||||
if (currentConversationId) {
|
||||
const store = getAgentsForConversation(currentConversationId);
|
||||
agentsUnsubscribe = store.subscribe((value) => {
|
||||
agents = value;
|
||||
});
|
||||
} else {
|
||||
agents = [];
|
||||
}
|
||||
});
|
||||
|
||||
const runningAgents = $derived(agents.filter((a) => a.status === "running"));
|
||||
const completedAgents = $derived(agents.filter((a) => a.status === "completed"));
|
||||
const erroredAgents = $derived(agents.filter((a) => a.status === "errored"));
|
||||
|
||||
// Organize agents into a tree structure based on parent_tool_use_id
|
||||
const agentTree = $derived.by(() => {
|
||||
const topLevel = agents.filter((a) => !a.parentToolUseId);
|
||||
const childrenMap = new SvelteMap<string, AgentInfo[]>();
|
||||
|
||||
// Group children by their parent
|
||||
agents.forEach((agent) => {
|
||||
if (agent.parentToolUseId) {
|
||||
const siblings = childrenMap.get(agent.parentToolUseId) || [];
|
||||
siblings.push(agent);
|
||||
childrenMap.set(agent.parentToolUseId, siblings);
|
||||
}
|
||||
});
|
||||
|
||||
return { topLevel, childrenMap };
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
timerInterval = setInterval(() => {
|
||||
now = Date.now();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
if (agentsUnsubscribe) agentsUnsubscribe();
|
||||
conversationIdUnsubscribe();
|
||||
});
|
||||
|
||||
function formatDuration(startedAt: number, endedAt?: number): string {
|
||||
const end = endedAt || now;
|
||||
const durationMs = end - startedAt;
|
||||
const seconds = Math.floor(durationMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function getSubagentTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
Explore: "Explorer",
|
||||
"general-purpose": "General",
|
||||
Plan: "Planner",
|
||||
Bash: "Shell",
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
function getStatusBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "bg-blue-500/20 text-blue-400 border-blue-500/30";
|
||||
case "completed":
|
||||
return "bg-green-500/20 text-green-400 border-green-500/30";
|
||||
case "errored":
|
||||
return "bg-red-500/20 text-red-400 border-red-500/30";
|
||||
default:
|
||||
return "bg-gray-500/20 text-gray-400 border-gray-500/30";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKillAll() {
|
||||
if (!currentConversationId) return;
|
||||
|
||||
try {
|
||||
await invoke("interrupt_claude", { conversationId: currentConversationId });
|
||||
// Mark all running agents as errored after killing the process
|
||||
agentStore.markAllErrored(currentConversationId);
|
||||
} catch (error) {
|
||||
console.error("Failed to kill Claude process:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClearCompleted() {
|
||||
if (currentConversationId) {
|
||||
agentStore.clearCompleted(currentConversationId);
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten the tree for rendering with depth information
|
||||
const flattenedAgents = $derived.by(() => {
|
||||
const result: { agent: AgentInfo; depth: number }[] = [];
|
||||
const { topLevel, childrenMap } = agentTree;
|
||||
|
||||
function addAgentAndChildren(agent: AgentInfo, depth: number) {
|
||||
result.push({ agent, depth });
|
||||
const children = childrenMap.get(agent.toolUseId);
|
||||
if (children) {
|
||||
children.forEach((child) => addAgentAndChildren(child, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
topLevel.forEach((agent) => addAgentAndChildren(agent, 0));
|
||||
return result;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 z-40" onclick={onClose}></div>
|
||||
|
||||
<div
|
||||
class="fixed top-12 right-0 bottom-0 w-80 bg-[var(--bg-primary)] border-l border-[var(--border-color)] shadow-xl z-50 flex flex-col overflow-hidden"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<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="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-sm font-semibold text-[var(--text-primary)]">Agent Monitor</h3>
|
||||
{#if runningAgents.length > 0}
|
||||
<span
|
||||
class="px-1.5 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400 animate-pulse"
|
||||
>
|
||||
{runningAgents.length} running
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close agent monitor"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex gap-2 px-4 py-2 border-b border-[var(--border-color)]">
|
||||
<button
|
||||
onclick={handleKillAll}
|
||||
disabled={runningAgents.length === 0}
|
||||
class="flex-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Kills the entire Claude Code process to stop all agents"
|
||||
>
|
||||
Kill All
|
||||
</button>
|
||||
<button
|
||||
onclick={handleClearCompleted}
|
||||
disabled={completedAgents.length === 0 && erroredAgents.length === 0}
|
||||
class="flex-1 px-2 py-1 text-xs bg-[var(--bg-secondary)] hover:bg-[var(--bg-hover,var(--bg-secondary))] text-[var(--text-secondary)] rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Clear Finished
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Agent list -->
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{#if agents.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)] text-sm"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 mb-2 opacity-50"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
<p>No agents detected yet</p>
|
||||
<p class="text-xs mt-1 opacity-70">
|
||||
Agents will appear here when Claude uses the Task tool
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each flattenedAgents as { agent, depth } (agent.toolUseId)}
|
||||
<div
|
||||
class="p-3 rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] {agent.status ===
|
||||
'running'
|
||||
? 'border-l-2 border-l-blue-500'
|
||||
: agent.status === 'errored'
|
||||
? 'border-l-2 border-l-red-500'
|
||||
: 'border-l-2 border-l-green-500'}"
|
||||
style="margin-left: {depth * 12}px; width: calc(100% - {depth * 12}px);"
|
||||
>
|
||||
<!-- Agent header -->
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if depth > 0}
|
||||
<svg
|
||||
class="w-3 h-3 text-[var(--text-secondary)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<img
|
||||
src={agent.characterAvatar}
|
||||
alt={agent.characterName}
|
||||
class="w-5 h-5 rounded-full object-cover"
|
||||
/>
|
||||
<span class="text-[10px] font-medium text-[var(--text-primary)]">
|
||||
{agent.characterName}
|
||||
</span>
|
||||
<span
|
||||
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
|
||||
agent.status
|
||||
)}"
|
||||
>
|
||||
{getSubagentTypeLabel(agent.subagentType)}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] {agent.status === 'running'
|
||||
? 'text-blue-400'
|
||||
: 'text-[var(--text-secondary)]'}"
|
||||
>
|
||||
{#if agent.durationMs !== undefined}
|
||||
{Math.floor(agent.durationMs / 1000)}s
|
||||
{:else}
|
||||
{formatDuration(agent.startedAt, agent.endedAt)}
|
||||
{/if}
|
||||
{#if agent.status === "running"}
|
||||
<span class="inline-block w-1 h-1 bg-blue-400 rounded-full animate-pulse ml-1"
|
||||
></span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Agent description -->
|
||||
<p class="text-xs text-[var(--text-primary)] truncate" title={agent.description}>
|
||||
{agent.description}
|
||||
</p>
|
||||
|
||||
<!-- Status indicator -->
|
||||
<div class="mt-1 flex items-center gap-1">
|
||||
{#if agent.status === "running"}
|
||||
<span class="text-[10px] text-blue-400">Running...</span>
|
||||
{:else if agent.status === "completed"}
|
||||
<span class="text-[10px] text-green-400">Completed</span>
|
||||
{:else}
|
||||
<span class="text-[10px] text-red-400">Errored / Killed</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer summary -->
|
||||
{#if agents.length > 0}
|
||||
<div
|
||||
class="px-4 py-2 border-t border-[var(--border-color)] text-[10px] text-[var(--text-secondary)]"
|
||||
>
|
||||
{agents.length} total ·
|
||||
{runningAgents.length} running ·
|
||||
{completedAgents.length} completed ·
|
||||
{erroredAgents.length} errored
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,140 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onClose}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="cast-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 id="cast-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
Meet the Team
|
||||
</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Principal cast: Hikari + Naomi -->
|
||||
<div class="grid grid-cols-1 gap-3 mb-6 sm:grid-cols-2">
|
||||
<div
|
||||
class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--accent-primary)]/40"
|
||||
>
|
||||
<img
|
||||
src="https://cdn.nhcarrigan.com/hikari.png"
|
||||
alt="Hikari"
|
||||
class="w-16 h-16 object-cover rounded-full border-2 border-[var(--border-color)] shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-semibold text-[var(--text-primary)]">Hikari</span>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
|
||||
>
|
||||
Chief Operating Officer
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-secondary)]">
|
||||
Holds the line so the others don't have to. Never without her clipboard — or her
|
||||
glasses.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--accent-primary)]/40"
|
||||
>
|
||||
<img
|
||||
src="https://cdn.nhcarrigan.com/profile.png"
|
||||
alt="Naomi"
|
||||
class="w-16 h-16 object-cover rounded-full border-2 border-[var(--border-color)] shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-semibold text-[var(--text-primary)]">Naomi</span>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
|
||||
>
|
||||
Chief hEx-ecutive Officer
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-secondary)]">
|
||||
A 525-year-old vampire running a tech company from behind a VTuber avatar. Fixes server
|
||||
crashes at 4 AM.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subagent girls grid -->
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-3">
|
||||
Subagent Squad
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{#each CHARACTER_POOL as character (character.name)}
|
||||
<div
|
||||
class="flex flex-col items-center gap-2 p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] text-center"
|
||||
>
|
||||
<img
|
||||
src={character.avatar}
|
||||
alt={character.name}
|
||||
class="w-14 h-14 object-cover rounded-full border-2 border-[var(--border-color)]"
|
||||
/>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">{character.name}</span>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
|
||||
>
|
||||
{character.title}
|
||||
</span>
|
||||
<p class="text-xs text-[var(--text-secondary)] leading-snug">{character.description}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,68 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let version = $state("Loading...");
|
||||
|
||||
async function fetchVersion() {
|
||||
try {
|
||||
const result = await invoke<string>("get_claude_version");
|
||||
version = result;
|
||||
} catch (error) {
|
||||
console.error("Failed to get Claude CLI version:", error);
|
||||
version = "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchVersion();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="cli-version">
|
||||
<svg
|
||||
class="terminal-icon"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
<span class="version-text">CLI {version}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cli-version {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cli-version:hover {
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.terminal-icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.version-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,116 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
hasActiveConversation: boolean;
|
||||
onClose: () => void;
|
||||
onMinimize: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const { isOpen, hasActiveConversation, onClose, onMinimize, onCancel }: Props = $props();
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!isOpen) return;
|
||||
|
||||
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 Hikari Desktop?
|
||||
</h3>
|
||||
<p id="confirm-message" class="text-sm text-[var(--text-secondary)]">
|
||||
{#if hasActiveConversation}
|
||||
You have an active conversation with Claude. Are you sure you want to close the
|
||||
application? Your conversation history will be saved, but any in-progress tasks will
|
||||
be interrupted.
|
||||
{:else}
|
||||
Are you sure you want to close the application?
|
||||
{/if}
|
||||
</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={onMinimize}
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
||||
>
|
||||
Minimize to Tray
|
||||
</button>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
|
||||
>
|
||||
Close Application
|
||||
</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>
|
||||
@@ -5,7 +5,6 @@
|
||||
import { characterState, characterInfo } from "$lib/stores/character";
|
||||
import { isStreamerMode } from "$lib/stores/config";
|
||||
import { handleNewUserMessage } from "$lib/notifications/rules";
|
||||
import { setSkipNextGreeting } from "$lib/tauri";
|
||||
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
|
||||
|
||||
interface Props {
|
||||
@@ -128,9 +127,6 @@
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) return;
|
||||
|
||||
// Set flag to preserve stats/permissions (don't treat next connect as new session)
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
await invoke("interrupt_claude", { conversationId });
|
||||
claudeStore.addLine("system", "Interrupted");
|
||||
characterState.setState("idle");
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
} from "$lib/stores/config";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import CostSummary from "./CostSummary.svelte";
|
||||
|
||||
let config: HikariConfig = $state({
|
||||
model: null,
|
||||
@@ -26,6 +25,7 @@
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: false,
|
||||
minimize_to_tray: false,
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: null,
|
||||
font_size: 14,
|
||||
@@ -45,13 +45,6 @@
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
budget_enabled: false,
|
||||
session_token_budget: null,
|
||||
session_cost_budget: null,
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
});
|
||||
|
||||
let showCustomThemeEditor = $state(false);
|
||||
@@ -81,22 +74,8 @@
|
||||
|
||||
const availableModels = [
|
||||
{ value: "", label: "Default (from ~/.claude)" },
|
||||
// Current generation (Claude 4.6)
|
||||
{ value: "claude-opus-4-6", label: "Claude Opus 4.6 (Most Capable)" },
|
||||
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 (Recommended)" },
|
||||
// Previous generation (Claude 4.5)
|
||||
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
|
||||
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" },
|
||||
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5" },
|
||||
// Previous generation (Claude 4.x)
|
||||
{ value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" },
|
||||
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
|
||||
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
|
||||
// Legacy (Claude 3.x)
|
||||
{ value: "claude-3-7-sonnet-20250219", label: "Claude 3.7 Sonnet" },
|
||||
{ value: "claude-3-5-sonnet-20241022", label: "Claude 3.5 Sonnet (Oct 2024)" },
|
||||
{ value: "claude-3-5-sonnet-20240620", label: "Claude 3.5 Sonnet (Jun 2024)" },
|
||||
{ value: "claude-3-haiku-20240307", label: "Claude 3 Haiku (Cheapest)" },
|
||||
];
|
||||
|
||||
const commonTools = [
|
||||
@@ -705,22 +684,6 @@
|
||||
Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Show Thinking Blocks Toggle -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={config.show_thinking_blocks}
|
||||
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)]">Show Extended Thinking Blocks</span>
|
||||
</label>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||
Display Claude's extended thinking process in the conversation. Thinking blocks can be
|
||||
expanded/collapsed to see reasoning details.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Window Section -->
|
||||
@@ -745,6 +708,21 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Minimize to Tray Toggle -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={config.minimize_to_tray}
|
||||
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
|
||||
/>
|
||||
<span class="text-sm text-[var(--text-primary)]">Minimize to system tray</span>
|
||||
</label>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||
Hide to tray instead of closing when you click the X button
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Update Checks Toggle -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
@@ -800,135 +778,6 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Budget Settings Section -->
|
||||
<section class="mb-6">
|
||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||
Budget Settings
|
||||
</h3>
|
||||
|
||||
<!-- Enable Budget Tracking -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={config.budget_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 budget tracking</span>
|
||||
</label>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||
Set limits on token usage and costs per session
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if config.budget_enabled}
|
||||
<!-- Token Budget -->
|
||||
<div class="mb-4">
|
||||
<label for="token-budget" class="block text-sm text-[var(--text-secondary)] mb-1">
|
||||
Session Token Budget
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="token-budget"
|
||||
type="number"
|
||||
bind:value={config.session_token_budget}
|
||||
min="0"
|
||||
step="10000"
|
||||
placeholder="e.g., 100000"
|
||||
class="flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">tokens</span>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1">Leave empty for unlimited tokens</p>
|
||||
</div>
|
||||
|
||||
<!-- Cost Budget -->
|
||||
<div class="mb-4">
|
||||
<label for="cost-budget" class="block text-sm text-[var(--text-secondary)] mb-1">
|
||||
Session Cost Budget
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-[var(--text-secondary)]">$</span>
|
||||
<input
|
||||
id="cost-budget"
|
||||
type="number"
|
||||
bind:value={config.session_cost_budget}
|
||||
min="0"
|
||||
step="0.50"
|
||||
placeholder="e.g., 5.00"
|
||||
class="flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">USD</span>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1">Leave empty for unlimited spending</p>
|
||||
</div>
|
||||
|
||||
<!-- Warning Threshold -->
|
||||
<div class="mb-4">
|
||||
<label for="warning-threshold" class="block text-sm text-[var(--text-secondary)] mb-2">
|
||||
Warning Threshold
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
id="warning-threshold"
|
||||
type="range"
|
||||
bind:value={config.budget_warning_threshold}
|
||||
min="0.5"
|
||||
max="0.95"
|
||||
step="0.05"
|
||||
class="flex-1 h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<span class="text-sm text-[var(--text-secondary)] w-12 text-right">
|
||||
{Math.round(config.budget_warning_threshold * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
Show warning when this percentage of budget is used
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Budget Action -->
|
||||
<div class="mb-4">
|
||||
<span class="block text-sm text-[var(--text-secondary)] mb-2"
|
||||
>When budget is exceeded</span
|
||||
>
|
||||
<div class="flex gap-2" role="group" aria-label="Budget action">
|
||||
<button
|
||||
onclick={() => (config.budget_action = "warn")}
|
||||
class="flex-1 px-3 py-2 rounded-lg border transition-colors text-sm {config.budget_action ===
|
||||
'warn'
|
||||
? '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)]'}"
|
||||
>
|
||||
Warn Only
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (config.budget_action = "block")}
|
||||
class="flex-1 px-3 py-2 rounded-lg border transition-colors text-sm {config.budget_action ===
|
||||
'block'
|
||||
? '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)]'}"
|
||||
>
|
||||
Block Input
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-2">
|
||||
{config.budget_action === "warn"
|
||||
? "Show a warning but allow continued usage"
|
||||
: "Prevent sending more messages until session is reset"}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Cost History Section -->
|
||||
<section class="mb-6">
|
||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||
Cost History
|
||||
</h3>
|
||||
<CostSummary />
|
||||
</section>
|
||||
|
||||
<!-- Notifications Section -->
|
||||
<section class="mb-6">
|
||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||
@@ -974,30 +823,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Discord Rich Presence Section -->
|
||||
<section class="pt-6 pb-6 border-t border-[var(--border-color)]">
|
||||
<h3 class="text-lg font-semibold text-[var(--accent-primary)] mb-4 flex items-center gap-2">
|
||||
<span>🎮</span>
|
||||
<span>Discord Rich Presence</span>
|
||||
</h3>
|
||||
|
||||
<!-- Enable/Disable Discord RPC -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={config.discord_rpc_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)]">Show activity in Discord</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-[var(--text-tertiary)]">
|
||||
Display your current conversation session name and model in Discord when enabled.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="sticky bottom-0 pt-4 pb-2 bg-[var(--bg-secondary)]">
|
||||
<button
|
||||
|
||||
@@ -1,402 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
costTrackingStore,
|
||||
formattedCosts,
|
||||
formatCost,
|
||||
type CostSummary,
|
||||
type CostAlertThresholds,
|
||||
} from "$lib/stores/costTracking";
|
||||
|
||||
let selectedPeriod = $state<7 | 30 | 90>(7);
|
||||
let summary = $state<CostSummary | null>(null);
|
||||
let isLoading = $state(false);
|
||||
let showThresholdSettings = $state(false);
|
||||
let thresholds = $state<CostAlertThresholds>({
|
||||
daily: null,
|
||||
weekly: null,
|
||||
monthly: null,
|
||||
});
|
||||
|
||||
const costs = $derived($formattedCosts);
|
||||
|
||||
async function loadSummary() {
|
||||
isLoading = true;
|
||||
summary = await costTrackingStore.getSummary(selectedPeriod);
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
const csv = await costTrackingStore.exportCsv(selectedPeriod);
|
||||
if (csv) {
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `hikari-costs-${selectedPeriod}days.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveThresholds() {
|
||||
await costTrackingStore.setAlertThresholds(thresholds);
|
||||
showThresholdSettings = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadSummary();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="cost-summary">
|
||||
<h3 class="summary-title">Cost Summary</h3>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="quick-stats">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Today</span>
|
||||
<span class="stat-value">{costs.today}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">This Week</span>
|
||||
<span class="stat-value">{costs.week}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">This Month</span>
|
||||
<span class="stat-value">{costs.month}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Period Selector -->
|
||||
<div class="period-selector">
|
||||
<button
|
||||
class="period-btn"
|
||||
class:active={selectedPeriod === 7}
|
||||
onclick={() => (selectedPeriod = 7)}
|
||||
>
|
||||
7 Days
|
||||
</button>
|
||||
<button
|
||||
class="period-btn"
|
||||
class:active={selectedPeriod === 30}
|
||||
onclick={() => (selectedPeriod = 30)}
|
||||
>
|
||||
30 Days
|
||||
</button>
|
||||
<button
|
||||
class="period-btn"
|
||||
class:active={selectedPeriod === 90}
|
||||
onclick={() => (selectedPeriod = 90)}
|
||||
>
|
||||
90 Days
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary Details -->
|
||||
{#if isLoading}
|
||||
<div class="loading">Loading...</div>
|
||||
{:else if summary}
|
||||
<div class="summary-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Total Cost</span>
|
||||
<span class="detail-value highlight">{formatCost(summary.total_cost)}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Average Daily</span>
|
||||
<span class="detail-value">{formatCost(summary.average_daily_cost)}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Messages</span>
|
||||
<span class="detail-value">{summary.total_messages.toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Sessions</span>
|
||||
<span class="detail-value">{summary.total_sessions.toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Input Tokens</span>
|
||||
<span class="detail-value">{summary.total_input_tokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Output Tokens</span>
|
||||
<span class="detail-value">{summary.total_output_tokens.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily Breakdown (mini chart) -->
|
||||
{#if summary.daily_breakdown.length > 0}
|
||||
<div class="chart-section">
|
||||
<h4 class="chart-title">Daily Spending</h4>
|
||||
<div class="mini-chart">
|
||||
{#each summary.daily_breakdown.slice(-14) as day (day.date)}
|
||||
{@const maxCost = Math.max(...summary.daily_breakdown.map((d) => d.cost_usd), 0.01)}
|
||||
{@const height = (day.cost_usd / maxCost) * 100}
|
||||
<div class="chart-bar-container" title="{day.date}: {formatCost(day.cost_usd)}">
|
||||
<div class="chart-bar" style="height: {height}%"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="actions">
|
||||
<button class="action-btn" onclick={handleExport}> Export CSV </button>
|
||||
<button class="action-btn" onclick={() => (showThresholdSettings = !showThresholdSettings)}>
|
||||
Set Alerts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Threshold Settings -->
|
||||
{#if showThresholdSettings}
|
||||
<div class="threshold-settings">
|
||||
<h4>Cost Alert Thresholds</h4>
|
||||
<div class="threshold-row">
|
||||
<label for="daily-threshold">Daily</label>
|
||||
<input
|
||||
id="daily-threshold"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="e.g., 1.00"
|
||||
bind:value={thresholds.daily}
|
||||
/>
|
||||
</div>
|
||||
<div class="threshold-row">
|
||||
<label for="weekly-threshold">Weekly</label>
|
||||
<input
|
||||
id="weekly-threshold"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="e.g., 5.00"
|
||||
bind:value={thresholds.weekly}
|
||||
/>
|
||||
</div>
|
||||
<div class="threshold-row">
|
||||
<label for="monthly-threshold">Monthly</label>
|
||||
<input
|
||||
id="monthly-threshold"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="e.g., 20.00"
|
||||
bind:value={thresholds.monthly}
|
||||
/>
|
||||
</div>
|
||||
<button class="save-btn" onclick={handleSaveThresholds}>Save Thresholds</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cost-summary {
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.quick-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-primary);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.period-selector {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.period-btn {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.period-btn:hover {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.period-btn.active {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.summary-details {
|
||||
background: var(--bg-primary);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value.highlight {
|
||||
color: var(--accent-primary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mini-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 60px;
|
||||
background: var(--bg-primary);
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.chart-bar-container {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.chart-bar {
|
||||
width: 100%;
|
||||
background: var(--accent-primary);
|
||||
border-radius: 2px 2px 0 0;
|
||||
min-height: 2px;
|
||||
transition: height 0.3s;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.threshold-settings {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.threshold-settings h4 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.threshold-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.threshold-row label {
|
||||
width: 60px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.threshold-row input {
|
||||
flex: 1;
|
||||
padding: 0.4rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.save-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
@@ -1,330 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { debugConsoleStore, filteredLogs, type LogLevel } from "$lib/stores/debugConsole";
|
||||
|
||||
let isOpen = $state(false);
|
||||
let logs = $state($filteredLogs);
|
||||
let filterLevel = $state<LogLevel | "all">("all");
|
||||
let autoScroll = $state(true);
|
||||
let logContainerElement: HTMLDivElement | undefined = $state();
|
||||
|
||||
// Watch for log changes and auto-scroll
|
||||
$effect(() => {
|
||||
logs = $filteredLogs;
|
||||
|
||||
// Auto-scroll to bottom when logs change
|
||||
if (autoScroll && logContainerElement) {
|
||||
setTimeout(() => {
|
||||
if (logContainerElement) {
|
||||
logContainerElement.scrollTop = logContainerElement.scrollHeight;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// Set up console capture and backend listener
|
||||
debugConsoleStore.setupConsoleCapture();
|
||||
debugConsoleStore.setupBackendLogsListener();
|
||||
|
||||
// Subscribe to store
|
||||
const unsubscribe = debugConsoleStore.subscribe((state) => {
|
||||
isOpen = state.isOpen;
|
||||
filterLevel = state.filterLevel;
|
||||
autoScroll = state.autoScroll;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
debugConsoleStore.restoreConsole();
|
||||
};
|
||||
});
|
||||
|
||||
function handleClose() {
|
||||
debugConsoleStore.close();
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
debugConsoleStore.clear();
|
||||
}
|
||||
|
||||
function handleFilterChange(level: LogLevel | "all") {
|
||||
debugConsoleStore.setFilterLevel(level);
|
||||
}
|
||||
|
||||
function handleAutoScrollToggle() {
|
||||
debugConsoleStore.setAutoScroll(!autoScroll);
|
||||
}
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
fractionalSecondDigits: 3,
|
||||
});
|
||||
}
|
||||
|
||||
function getLevelColor(level: LogLevel): string {
|
||||
switch (level) {
|
||||
case "debug":
|
||||
return "#9CA3AF"; // gray
|
||||
case "info":
|
||||
return "#3B82F6"; // blue
|
||||
case "warn":
|
||||
return "#F59E0B"; // amber
|
||||
case "error":
|
||||
return "#EF4444"; // red
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceBadgeColor(source: "frontend" | "backend"): string {
|
||||
return source === "frontend" ? "#8B5CF6" : "#10B981"; // purple for frontend, green for backend
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="debug-console-overlay">
|
||||
<div class="debug-console">
|
||||
<div class="debug-console-header">
|
||||
<h2>Debug Console</h2>
|
||||
<div class="debug-console-controls">
|
||||
<div class="filter-buttons">
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:active={filterLevel === "all"}
|
||||
onclick={() => handleFilterChange("all")}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:active={filterLevel === "debug"}
|
||||
onclick={() => handleFilterChange("debug")}
|
||||
style="color: {getLevelColor('debug')}"
|
||||
>
|
||||
Debug
|
||||
</button>
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:active={filterLevel === "info"}
|
||||
onclick={() => handleFilterChange("info")}
|
||||
style="color: {getLevelColor('info')}"
|
||||
>
|
||||
Info
|
||||
</button>
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:active={filterLevel === "warn"}
|
||||
onclick={() => handleFilterChange("warn")}
|
||||
style="color: {getLevelColor('warn')}"
|
||||
>
|
||||
Warn
|
||||
</button>
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:active={filterLevel === "error"}
|
||||
onclick={() => handleFilterChange("error")}
|
||||
style="color: {getLevelColor('error')}"
|
||||
>
|
||||
Error
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="auto-scroll-btn"
|
||||
class:active={autoScroll}
|
||||
onclick={handleAutoScrollToggle}
|
||||
>
|
||||
{autoScroll ? "🔒" : "🔓"} Auto-scroll
|
||||
</button>
|
||||
<button class="clear-btn" onclick={handleClear}> 🗑️ Clear </button>
|
||||
<button class="close-btn" onclick={handleClose}> ✕ </button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="debug-console-content" bind:this={logContainerElement}>
|
||||
{#if logs.length === 0}
|
||||
<div class="empty-state">No logs yet...</div>
|
||||
{:else}
|
||||
{#each logs as log (log.id)}
|
||||
<div class="log-entry" data-level={log.level}>
|
||||
<span class="log-timestamp">{formatTimestamp(log.timestamp)}</span>
|
||||
<span class="log-level" style="color: {getLevelColor(log.level)}">
|
||||
[{log.level.toUpperCase()}]
|
||||
</span>
|
||||
<span class="log-source" style="background-color: {getSourceBadgeColor(log.source)}">
|
||||
{log.source}
|
||||
</span>
|
||||
<span class="log-message">{log.message}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.debug-console-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.debug-console {
|
||||
width: 90%;
|
||||
height: 80%;
|
||||
max-width: 1400px;
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.debug-console-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background-color: #252525;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.debug-console-header h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.debug-console-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 4px 12px;
|
||||
background-color: transparent;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background-color: #444;
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
.auto-scroll-btn,
|
||||
.clear-btn {
|
||||
padding: 4px 12px;
|
||||
background-color: #333;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.auto-scroll-btn:hover,
|
||||
.clear-btn:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
.auto-scroll-btn.active {
|
||||
background-color: #10b981;
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 4px 12px;
|
||||
background-color: #ef4444;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
|
||||
.debug-console-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background-color: #0f0f0f;
|
||||
font-family: "Fira Code", "Consolas", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.log-entry:hover {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.log-source {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
color: #e5e5e5;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,7 @@
|
||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { handleNewUserMessage } from "$lib/notifications/rules";
|
||||
import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri";
|
||||
import { setSkipNextGreeting } from "$lib/tauri";
|
||||
import { clipboardStore } from "$lib/stores/clipboard";
|
||||
import {
|
||||
setShouldRestoreHistory,
|
||||
@@ -17,8 +17,6 @@
|
||||
} from "$lib/stores/historyRestore";
|
||||
import MessageModeSelector from "$lib/components/MessageModeSelector.svelte";
|
||||
import SlashCommandMenu from "$lib/components/SlashCommandMenu.svelte";
|
||||
import SystemClock from "$lib/components/SystemClock.svelte";
|
||||
import CliVersion from "$lib/components/CliVersion.svelte";
|
||||
import { getCurrentMode } from "$lib/stores/messageMode";
|
||||
import { formatMessageWithMode } from "$lib/types/messageMode";
|
||||
import {
|
||||
@@ -28,8 +26,6 @@
|
||||
type SlashCommand,
|
||||
} from "$lib/commands/slashCommands";
|
||||
import { configStore, isStreamerMode } from "$lib/stores/config";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { stats, estimateMessageCost, formatTokenCount } from "$lib/stores/stats";
|
||||
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
|
||||
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
|
||||
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
|
||||
@@ -54,13 +50,6 @@
|
||||
let showClipboardHistory = $state(false);
|
||||
let streamerModeActive = $state(false);
|
||||
|
||||
// Cost estimation for pre-submission display
|
||||
let costEstimate = $derived(
|
||||
inputValue.trim()
|
||||
? estimateMessageCost(inputValue, $stats.context_tokens_used, $stats.model)
|
||||
: null
|
||||
);
|
||||
|
||||
// Context menu state
|
||||
let textareaElement: HTMLTextAreaElement | undefined = $state();
|
||||
let contextMenuShow = $state(false);
|
||||
@@ -340,39 +329,19 @@ User: ${formattedMessage}`;
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
|
||||
// Get current working directory and granted tools before reconnecting
|
||||
// Get current working directory before reconnecting
|
||||
const workingDir = await invoke<string>("get_working_directory", { conversationId });
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
const grantedTools = activeConversation
|
||||
? Array.from(activeConversation.grantedTools)
|
||||
: [];
|
||||
const config = configStore.getConfig();
|
||||
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
|
||||
|
||||
// Set the flag to skip greeting on next connection
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
// Reconnect to Claude with preserved permissions
|
||||
// Reconnect to Claude
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDir,
|
||||
model: config.model || null,
|
||||
api_key: config.api_key || null,
|
||||
custom_instructions: config.custom_instructions || null,
|
||||
mcp_servers_json: config.mcp_servers_json || null,
|
||||
allowed_tools: allAllowedTools,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when reconnecting
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
} catch (reconnectError) {
|
||||
console.error("Failed to auto-reconnect:", reconnectError);
|
||||
claudeStore.addLine("error", `Failed to reconnect: ${reconnectError}`);
|
||||
@@ -458,12 +427,11 @@ User: ${formattedMessage}`;
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
||||
const result = await invoke<{ path: string; filename: string }>("save_temp_file", {
|
||||
savedPath = await invoke<string>("save_temp_file", {
|
||||
conversationId,
|
||||
filename,
|
||||
data: bytes,
|
||||
});
|
||||
savedPath = result.path;
|
||||
} catch (error) {
|
||||
console.error("Failed to save dropped file to temp:", error);
|
||||
savedPath = file.name;
|
||||
@@ -597,12 +565,11 @@ User: ${formattedMessage}`;
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
||||
const result = await invoke<{ path: string; filename: string }>("save_temp_file", {
|
||||
savedPath = await invoke<string>("save_temp_file", {
|
||||
conversationId,
|
||||
filename,
|
||||
data: bytes,
|
||||
});
|
||||
savedPath = result.path;
|
||||
} catch (error) {
|
||||
console.error("Failed to save pasted file to temp:", error);
|
||||
}
|
||||
@@ -660,12 +627,11 @@ User: ${formattedMessage}`;
|
||||
try {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
||||
const result = await invoke<{ path: string; filename: string }>("save_temp_file", {
|
||||
savedPath = await invoke<string>("save_temp_file", {
|
||||
conversationId,
|
||||
filename,
|
||||
data: bytes,
|
||||
});
|
||||
savedPath = result.path;
|
||||
} catch (error) {
|
||||
console.error("Failed to save clipboard image to temp:", error);
|
||||
}
|
||||
@@ -916,9 +882,6 @@ User: ${formattedMessage}`;
|
||||
</svg>
|
||||
<span>Clipboard</span>
|
||||
</button>
|
||||
|
||||
<CliVersion />
|
||||
<SystemClock />
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
@@ -950,13 +913,6 @@ User: ${formattedMessage}`;
|
||||
</div>
|
||||
|
||||
<div class="button-wrapper">
|
||||
{#if costEstimate && isConnected && !isProcessing}
|
||||
<div class="cost-estimate" title="Estimated input cost for this message">
|
||||
<span class="cost-tokens">+{formatTokenCount(costEstimate.messageTokens)}</span>
|
||||
<span class="cost-value">${costEstimate.estimatedCost.toFixed(4)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files">
|
||||
<svg
|
||||
width="20"
|
||||
@@ -1071,7 +1027,6 @@ User: ${formattedMessage}`;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
@@ -1088,18 +1043,6 @@ User: ${formattedMessage}`;
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Hide button text on smaller screens, show icons only */
|
||||
@media (max-width: 640px) {
|
||||
.control-button span {
|
||||
display: none;
|
||||
}
|
||||
.control-button {
|
||||
padding: 10px;
|
||||
min-width: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.control-button:hover {
|
||||
@@ -1195,28 +1138,6 @@ User: ${formattedMessage}`;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cost-estimate {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding: 0 8px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
min-width: 60px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.cost-tokens {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.cost-value {
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.attach-button {
|
||||
|
||||
@@ -1,433 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { onMount } from "svelte";
|
||||
import { Trash2, RefreshCw, Server, Globe, Terminal } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface McpServerInfo {
|
||||
name: string;
|
||||
command: string | null;
|
||||
url: string | null;
|
||||
transport: string; // "stdio", "http", or "sse"
|
||||
env: Record<string, string> | null;
|
||||
status: string | null; // "Connected" or "Failed to connect"
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
let servers = $state<McpServerInfo[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let selectedServer = $state<McpServerInfo | null>(null);
|
||||
let isLoadingDetails = $state(false);
|
||||
let actionInProgress = $state<string | null>(null);
|
||||
let showAddForm = $state(false);
|
||||
let serverDetails = $state<string>("");
|
||||
|
||||
// Add server form fields
|
||||
let newServerName = $state("");
|
||||
let newServerUrl = $state("");
|
||||
let newServerTransport = $state("stdio");
|
||||
let isAdding = $state(false);
|
||||
|
||||
async function loadServers(): Promise<void> {
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
servers = await invoke<McpServerInfo[]>("list_mcp_servers");
|
||||
} catch (e) {
|
||||
error = `Failed to load MCP servers: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServerDetails(name: string): Promise<void> {
|
||||
try {
|
||||
isLoadingDetails = true;
|
||||
error = null;
|
||||
selectedServer = await invoke<McpServerInfo>("get_mcp_server", { name });
|
||||
serverDetails = await invoke<string>("get_mcp_server_details", { name });
|
||||
} catch (e) {
|
||||
error = `Failed to load server details: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoadingDetails = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeServer(name: string): Promise<void> {
|
||||
try {
|
||||
actionInProgress = name;
|
||||
error = null;
|
||||
await invoke("remove_mcp_server", { name });
|
||||
if (selectedServer?.name === name) {
|
||||
selectedServer = null;
|
||||
serverDetails = "";
|
||||
}
|
||||
await loadServers();
|
||||
} catch (e) {
|
||||
error = `Failed to remove server: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function addServer(): Promise<void> {
|
||||
if (!newServerName.trim() || !newServerUrl.trim()) return;
|
||||
|
||||
try {
|
||||
isAdding = true;
|
||||
error = null;
|
||||
await invoke("add_mcp_server", {
|
||||
name: newServerName.trim(),
|
||||
commandOrUrl: newServerUrl.trim(),
|
||||
transport: newServerTransport,
|
||||
envVars: null,
|
||||
headers: null,
|
||||
});
|
||||
newServerName = "";
|
||||
newServerUrl = "";
|
||||
newServerTransport = "stdio";
|
||||
showAddForm = false;
|
||||
await loadServers();
|
||||
} catch (e) {
|
||||
error = `Failed to add server: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isAdding = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getTransportIcon(transport: string) {
|
||||
switch (transport) {
|
||||
case "http":
|
||||
return Globe;
|
||||
case "stdio":
|
||||
return Terminal;
|
||||
case "sse":
|
||||
return Server;
|
||||
default:
|
||||
return Server;
|
||||
}
|
||||
}
|
||||
|
||||
function getTransportColor(transport: string) {
|
||||
switch (transport) {
|
||||
case "http":
|
||||
return "text-blue-400";
|
||||
case "stdio":
|
||||
return "text-green-400";
|
||||
case "sse":
|
||||
return "text-purple-400";
|
||||
default:
|
||||
return "text-[var(--text-secondary)]";
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadServers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed top-0 right-0 h-full w-[700px] bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-[var(--accent-primary)]">
|
||||
<Server class="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">MCP Server Management</h2>
|
||||
<p class="text-xs text-[var(--text-secondary)]">
|
||||
{servers.length} server{servers.length !== 1 ? "s" : ""} configured
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
|
||||
aria-label="Close MCP panel"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add Server Button -->
|
||||
<div class="p-4 border-b border-[var(--border-color)]">
|
||||
<button
|
||||
onclick={() => (showAddForm = !showAddForm)}
|
||||
class="w-full px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 transition-opacity flex items-center justify-center gap-2"
|
||||
>
|
||||
<Server class="w-4 h-4" />
|
||||
{showAddForm ? "Cancel" : "Add New Server"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add Server Form -->
|
||||
{#if showAddForm}
|
||||
<div
|
||||
class="mx-4 mt-4 p-4 bg-[var(--bg-secondary)]/50 border border-[var(--border-color)] rounded-lg"
|
||||
>
|
||||
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-3">Add MCP Server</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
||||
>Server Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newServerName}
|
||||
placeholder="my-server"
|
||||
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
||||
>Transport</label
|
||||
>
|
||||
<select
|
||||
bind:value={newServerTransport}
|
||||
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
>
|
||||
<option value="stdio">STDIO</option>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="sse">SSE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1">
|
||||
{newServerTransport === "stdio" ? "Command" : "URL"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newServerUrl}
|
||||
placeholder={newServerTransport === "stdio"
|
||||
? "npx my-mcp-server"
|
||||
: "https://mcp.example.com"}
|
||||
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onclick={addServer}
|
||||
disabled={isAdding || !newServerName.trim() || !newServerUrl.trim()}
|
||||
class="w-full px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if isAdding}
|
||||
<RefreshCw class="w-4 h-4 animate-spin" />
|
||||
{:else}
|
||||
<Server class="w-4 h-4" />
|
||||
{/if}
|
||||
Add Server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error Display -->
|
||||
{#if error}
|
||||
<div class="mx-4 mt-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
|
||||
<p class="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto p-4 flex gap-4">
|
||||
<!-- Server List -->
|
||||
<div class="flex-1">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center h-full text-[var(--text-secondary)]">
|
||||
<RefreshCw class="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
{:else if servers.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
|
||||
<Server class="w-16 h-16 mb-4 opacity-50" />
|
||||
<p class="text-center">No MCP servers configured</p>
|
||||
<p class="text-sm text-center mt-2">Add servers via Settings</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each servers as server (server.name)}
|
||||
<button
|
||||
onclick={() => loadServerDetails(server.name)}
|
||||
class="w-full bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all text-left"
|
||||
class:border-[var(--accent-primary)]={selectedServer?.name === server.name}
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
|
||||
<svelte:component
|
||||
this={getTransportIcon(server.transport)}
|
||||
class="w-4 h-4 {getTransportColor(server.transport)}"
|
||||
/>
|
||||
{server.name}
|
||||
{#if server.status}
|
||||
{#if server.status.includes("Connected")}
|
||||
<span
|
||||
class="px-2 py-0.5 bg-[var(--success-color)]/20 text-[var(--success-color)] text-xs rounded border border-[var(--success-color)]/30"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="px-2 py-0.5 bg-red-500/20 text-red-400 text-xs rounded border border-red-500/30"
|
||||
>
|
||||
✗
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</h4>
|
||||
<p class="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{server.transport.toUpperCase()}
|
||||
{#if server.url}
|
||||
• {server.url}
|
||||
{:else if server.command}
|
||||
• {server.command}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Server Details Panel -->
|
||||
{#if selectedServer}
|
||||
<div
|
||||
class="w-80 bg-[var(--bg-secondary)]/50 rounded-lg p-4 border border-[var(--border-color)]"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Server Details</h3>
|
||||
|
||||
{#if isLoadingDetails}
|
||||
<div class="flex items-center justify-center h-32">
|
||||
<RefreshCw class="w-6 h-6 animate-spin text-[var(--text-secondary)]" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Name</label
|
||||
>
|
||||
<p class="text-sm text-[var(--text-primary)] mt-1">{selectedServer.name}</p>
|
||||
</div>
|
||||
|
||||
<!-- Transport -->
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Transport</label
|
||||
>
|
||||
<p class="text-sm text-[var(--text-primary)] mt-1 flex items-center gap-2">
|
||||
<svelte:component
|
||||
this={getTransportIcon(selectedServer.transport)}
|
||||
class="w-4 h-4 {getTransportColor(selectedServer.transport)}"
|
||||
/>
|
||||
{selectedServer.transport.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- URL or Command -->
|
||||
{#if selectedServer.url}
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>URL</label
|
||||
>
|
||||
<p
|
||||
class="text-sm text-[var(--text-primary)] mt-1 break-all font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
|
||||
>
|
||||
{selectedServer.url}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedServer.command}
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Command</label
|
||||
>
|
||||
<p
|
||||
class="text-sm text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
|
||||
>
|
||||
{selectedServer.command}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Environment Variables -->
|
||||
{#if selectedServer.env}
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Environment</label
|
||||
>
|
||||
<pre
|
||||
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto">{JSON.stringify(
|
||||
selectedServer.env,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Full Server Details -->
|
||||
{#if serverDetails}
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Full Details</label
|
||||
>
|
||||
<pre
|
||||
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto whitespace-pre-wrap">{serverDetails}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="pt-4 border-t border-[var(--border-color)]">
|
||||
<button
|
||||
onclick={() => selectedServer && removeServer(selectedServer.name)}
|
||||
disabled={actionInProgress === selectedServer?.name}
|
||||
class="w-full px-4 py-2 bg-red-500/20 border border-red-500/30 rounded-lg text-sm text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
Remove Server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -1,458 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import Markdown from "./Markdown.svelte";
|
||||
|
||||
let memoryFiles: string[] = $state([]);
|
||||
let selectedFile: string | null = $state(null);
|
||||
let fileContent: string = $state("");
|
||||
let isLoading = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
let isPanelOpen = $state(false);
|
||||
|
||||
interface MemoryFilesResponse {
|
||||
files: string[];
|
||||
}
|
||||
|
||||
async function loadMemoryFiles() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
const response = await invoke<MemoryFilesResponse>("list_memory_files");
|
||||
memoryFiles = response.files;
|
||||
} catch (e) {
|
||||
error = `Failed to load memory files: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFileContent(filePath: string) {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
// Use our backend command instead of Tauri plugin to handle WSL paths
|
||||
const content = await invoke<string>("read_file_content", { path: filePath });
|
||||
fileContent = content;
|
||||
selectedFile = filePath;
|
||||
} catch (e) {
|
||||
error = `Failed to read file: ${e}`;
|
||||
console.error(error);
|
||||
fileContent = "";
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getFileName(path: string): string {
|
||||
return path.split("/").pop() || path;
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
isPanelOpen = !isPanelOpen;
|
||||
if (isPanelOpen && memoryFiles.length === 0) {
|
||||
loadMemoryFiles();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Don't load on mount - only when panel is opened
|
||||
});
|
||||
</script>
|
||||
|
||||
<button class="memory-toggle" onclick={togglePanel} title="Memory Browser">
|
||||
<svg
|
||||
class="icon"
|
||||
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 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
<span class="label">Memory</span>
|
||||
</button>
|
||||
|
||||
{#if isPanelOpen}
|
||||
<div class="memory-panel">
|
||||
<div class="panel-header">
|
||||
<div class="header-title">
|
||||
<svg
|
||||
class="header-icon"
|
||||
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 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
<h3>Memory Files</h3>
|
||||
</div>
|
||||
<button class="close-btn" onclick={togglePanel} title="Close">
|
||||
<svg
|
||||
class="close-icon"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
{#if isLoading && memoryFiles.length === 0}
|
||||
<div class="loading">
|
||||
<svg
|
||||
class="spinner"
|
||||
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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Loading memory files...
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error">
|
||||
<p>{error}</p>
|
||||
<button class="retry-btn" onclick={loadMemoryFiles}>Retry</button>
|
||||
</div>
|
||||
{:else if memoryFiles.length === 0}
|
||||
<div class="empty">
|
||||
<p>No memory files found.</p>
|
||||
<p class="hint">
|
||||
Memory files are created automatically as I learn from our conversations!
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="panel-layout">
|
||||
<div class="file-list">
|
||||
{#each memoryFiles as file (file)}
|
||||
<button
|
||||
class="file-item"
|
||||
class:active={selectedFile === file}
|
||||
onclick={() => loadFileContent(file)}
|
||||
>
|
||||
<svg
|
||||
class="file-icon"
|
||||
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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="file-name">{getFileName(file)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="file-viewer">
|
||||
{#if selectedFile && fileContent}
|
||||
<div class="viewer-header">
|
||||
<h4>{getFileName(selectedFile)}</h4>
|
||||
</div>
|
||||
<div class="viewer-content">
|
||||
<Markdown content={fileContent} />
|
||||
</div>
|
||||
{:else if selectedFile && isLoading}
|
||||
<div class="loading-file">
|
||||
<svg
|
||||
class="spinner"
|
||||
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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Loading file...
|
||||
</div>
|
||||
{:else}
|
||||
<div class="no-selection">
|
||||
<p>Select a memory file to view its contents</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.memory-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.memory-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.memory-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 600px;
|
||||
height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
border-left: 1px solid var(--border-color);
|
||||
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 3rem 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error p {
|
||||
color: var(--terminal-error, #f87171);
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--accent-primary);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.panel-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: 1.5rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.file-item.active {
|
||||
background: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.viewer-header h4 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.viewer-content {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.loading-file,
|
||||
.no-selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 3rem 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,24 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { claudeStore, hasPermissionPending } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import type { PermissionRequest } from "$lib/types/messages";
|
||||
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
let permissions: PermissionRequest[] = $state([]);
|
||||
let selectedPermissions = new SvelteSet<string>();
|
||||
let isVisible = $state(false);
|
||||
let permission: PermissionRequest | null = $state(null);
|
||||
let grantedToolsList: string[] = $state([]);
|
||||
let workingDirectory = $state("");
|
||||
|
||||
conversationsStore.pendingPermissions.subscribe((perms) => {
|
||||
permissions = perms;
|
||||
// When new permissions arrive, select all by default
|
||||
if (perms.length > 0) {
|
||||
selectedPermissions = new SvelteSet(perms.map((p) => p.id));
|
||||
hasPermissionPending.subscribe((pending) => {
|
||||
isVisible = pending;
|
||||
});
|
||||
|
||||
claudeStore.pendingPermission.subscribe((perm) => {
|
||||
permission = perm;
|
||||
if (perm) {
|
||||
characterState.setState("permission");
|
||||
}
|
||||
});
|
||||
@@ -32,103 +30,66 @@
|
||||
});
|
||||
|
||||
async function handleApproveAndReconnect() {
|
||||
const selectedPerms = permissions.filter((p) => selectedPermissions.has(p.id));
|
||||
if (permission) {
|
||||
// Capture conversation history before clearing/reconnecting
|
||||
const conversationHistory = claudeStore.getConversationHistory();
|
||||
const approvedTool = permission.tool;
|
||||
const toolInput = permission.input;
|
||||
|
||||
if (selectedPerms.length === 0) {
|
||||
claudeStore.addLine("system", "No permissions selected to approve");
|
||||
claudeStore.grantTool(approvedTool);
|
||||
const newGrantedTools = [...grantedToolsList, approvedTool];
|
||||
claudeStore.addLine(
|
||||
"system",
|
||||
`Permission granted for: ${approvedTool}. Reconnecting with context...`
|
||||
);
|
||||
claudeStore.clearPermission();
|
||||
characterState.setTemporaryState("idle", 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture conversation history before clearing/reconnecting
|
||||
const conversationHistory = claudeStore.getConversationHistory();
|
||||
// Stop current session and reconnect with new permissions
|
||||
try {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
|
||||
// Grant all selected tools
|
||||
const newlyGrantedTools: string[] = [];
|
||||
for (const perm of selectedPerms) {
|
||||
if (!grantedToolsList.includes(perm.tool)) {
|
||||
claudeStore.grantTool(perm.tool);
|
||||
newlyGrantedTools.push(perm.tool);
|
||||
}
|
||||
}
|
||||
await invoke("stop_claude", { conversationId });
|
||||
|
||||
const newGrantedTools = [...grantedToolsList, ...newlyGrantedTools];
|
||||
const toolNames = selectedPerms.map((p) => p.tool).join(", ");
|
||||
// Small delay to ensure clean shutdown
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
claudeStore.addLine(
|
||||
"system",
|
||||
`Permission granted for ${selectedPerms.length} tool(s): ${toolNames}. Reconnecting with context...`
|
||||
);
|
||||
claudeStore.clearPermission();
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDirectory || "/home/naomi",
|
||||
allowed_tools: newGrantedTools,
|
||||
},
|
||||
});
|
||||
|
||||
// Stop current session and reconnect with new permissions
|
||||
try {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
// Wait for connection to establish
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Prevent stats reset on reconnection
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
await invoke("stop_claude", { conversationId });
|
||||
|
||||
// Small delay to ensure clean shutdown
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const config = configStore.getConfig();
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDirectory || "/home/naomi",
|
||||
model: config.model || null,
|
||||
api_key: config.api_key || null,
|
||||
custom_instructions: config.custom_instructions || null,
|
||||
mcp_servers_json: config.mcp_servers_json || null,
|
||||
allowed_tools: newGrantedTools,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when reconnecting after permission grant
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for connection to establish
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Send conversation context to restore state
|
||||
if (conversationHistory) {
|
||||
const blockedActions = selectedPerms
|
||||
.map((p) => `- ${p.tool} with input:\n${JSON.stringify(p.input, null, 2)}`)
|
||||
.join("\n\n");
|
||||
|
||||
const contextMessage = `[CONTEXT RESTORATION]
|
||||
I just granted you permission to use ${selectedPerms.length} tool(s): ${toolNames}. Here's our conversation so far:
|
||||
// Send conversation context to restore state
|
||||
if (conversationHistory) {
|
||||
const contextMessage = `[CONTEXT RESTORATION]
|
||||
I just granted you permission to use the ${approvedTool} tool. Here's our conversation so far:
|
||||
|
||||
${conversationHistory}
|
||||
|
||||
The actions that were blocked:
|
||||
${blockedActions}
|
||||
The last action that was blocked was: ${approvedTool} with input:
|
||||
${JSON.stringify(toolInput, null, 2)}
|
||||
|
||||
Please continue where we left off and retry those actions now that you have permission.`;
|
||||
Please continue where we left off and retry that action now that you have permission.`;
|
||||
|
||||
await invoke("send_prompt", {
|
||||
conversationId,
|
||||
message: contextMessage,
|
||||
});
|
||||
await invoke("send_prompt", {
|
||||
conversationId,
|
||||
message: contextMessage,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to reconnect:", error);
|
||||
claudeStore.addLine("error", `Reconnect failed: ${error}`);
|
||||
}
|
||||
characterState.setTemporaryState("success", 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to reconnect:", error);
|
||||
claudeStore.addLine("error", `Reconnect failed: ${error}`);
|
||||
}
|
||||
characterState.setTemporaryState("success", 2000);
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
@@ -149,24 +110,8 @@ Please continue where we left off and retry those actions now that you have perm
|
||||
return grantedToolsList.includes(toolName);
|
||||
}
|
||||
|
||||
function togglePermission(toolRequestId: string) {
|
||||
if (selectedPermissions.has(toolRequestId)) {
|
||||
selectedPermissions.delete(toolRequestId);
|
||||
} else {
|
||||
selectedPermissions.add(toolRequestId);
|
||||
}
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedPermissions = new SvelteSet(permissions.map((p) => p.id));
|
||||
}
|
||||
|
||||
function selectNone() {
|
||||
selectedPermissions = new SvelteSet();
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (permissions.length === 0) return;
|
||||
if (!isVisible || !permission) return;
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
@@ -180,126 +125,72 @@ Please continue where we left off and retry those actions now that you have perm
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if permissions.length > 0}
|
||||
{#if isVisible && permission}
|
||||
<div
|
||||
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-[60] backdrop-blur-sm"
|
||||
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
class="permission-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-2xl w-full mx-4 shadow-2xl max-h-[90vh] overflow-y-auto"
|
||||
class="permission-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-yellow-500/20 flex items-center justify-center">
|
||||
<span class="text-xl">🔐</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{permissions.length === 1
|
||||
? "Permission Required"
|
||||
: `${permissions.length} Permissions Required`}
|
||||
</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
Hikari tried to use {permissions.length === 1
|
||||
? "a restricted tool"
|
||||
: "restricted tools"}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2 text-xs">
|
||||
<button
|
||||
onclick={selectAll}
|
||||
class="px-2 py-1 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded transition-colors"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
onclick={selectNone}
|
||||
class="px-2 py-1 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded transition-colors"
|
||||
>
|
||||
Select None
|
||||
</button>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Permission Blocked</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">Hikari tried to use a restricted tool</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 px-3 py-2 bg-amber-500/10 border border-amber-500/30 rounded-md">
|
||||
<p class="text-sm text-amber-300">
|
||||
{permissions.length === 1
|
||||
? "This action was automatically blocked. Select which permissions to grant."
|
||||
: "These actions were automatically blocked. Select which permissions to grant."}
|
||||
This action was automatically blocked. Approve to allow this tool for future requests.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-6">
|
||||
{#each permissions as perm (perm.id)}
|
||||
<div
|
||||
class="border border-[var(--border-color)] rounded-lg p-4 cursor-pointer transition-colors {selectedPermissions.has(
|
||||
perm.id
|
||||
)
|
||||
? 'bg-green-500/10 border-green-500/30'
|
||||
: 'bg-[var(--bg-secondary)] hover:bg-[var(--bg-secondary)]/80'}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => togglePermission(perm.id)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
togglePermission(perm.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="mt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPermissions.has(perm.id)}
|
||||
onchange={() => togglePermission(perm.id)}
|
||||
class="w-4 h-4 accent-green-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-[var(--accent-primary)] font-mono text-sm font-medium">
|
||||
{perm.tool}
|
||||
</span>
|
||||
{#if isToolAlreadyGranted(perm.tool)}
|
||||
<span class="text-xs text-green-400 bg-green-500/20 px-2 py-0.5 rounded">
|
||||
Already Granted
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-sm text-[var(--text-secondary)] mb-2">
|
||||
{perm.description}
|
||||
</div>
|
||||
{#if Object.keys(perm.input).length > 0}
|
||||
<details class="text-xs">
|
||||
<summary
|
||||
class="cursor-pointer text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
View details
|
||||
</summary>
|
||||
<pre
|
||||
class="mt-2 px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] overflow-x-auto max-h-32">{formatInput(
|
||||
perm.input
|
||||
)}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="mb-4">
|
||||
<div class="text-sm text-[var(--text-secondary)] mb-1">Tool</div>
|
||||
<div
|
||||
class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--accent-primary)] font-mono flex items-center justify-between"
|
||||
>
|
||||
<span>{permission.tool}</span>
|
||||
{#if isToolAlreadyGranted(permission.tool)}
|
||||
<span class="text-xs text-green-400 bg-green-500/20 px-2 py-0.5 rounded"
|
||||
>Already Granted</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-sm text-[var(--text-secondary)] mb-1">Description</div>
|
||||
<div class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--text-primary)]">
|
||||
{permission.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if Object.keys(permission.input).length > 0}
|
||||
<div class="mb-6">
|
||||
<div class="text-sm text-[var(--text-secondary)] mb-1">Details</div>
|
||||
<pre
|
||||
class="px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] text-xs overflow-x-auto max-h-32">{formatInput(
|
||||
permission.input
|
||||
)}</pre>
|
||||
</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 All
|
||||
Dismiss
|
||||
</button>
|
||||
<button
|
||||
onclick={handleApproveAndReconnect}
|
||||
disabled={selectedPermissions.size === 0}
|
||||
class="flex-1 px-4 py-2 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="flex-1 px-4 py-2 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Approve Selected ({selectedPermissions.size})
|
||||
Allow & Reconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,447 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { onMount } from "svelte";
|
||||
import { Download, Trash2, Power, PowerOff, RefreshCw } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface PluginInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface MarketplaceInfo {
|
||||
name: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
let plugins = $state<PluginInfo[]>([]);
|
||||
let marketplaces = $state<MarketplaceInfo[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let isLoadingMarketplaces = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let newPluginName = $state("");
|
||||
let isInstalling = $state(false);
|
||||
let actionInProgress = $state<string | null>(null);
|
||||
let showMarketplaces = $state(false);
|
||||
let newMarketplaceSource = $state("");
|
||||
let isAddingMarketplace = $state(false);
|
||||
|
||||
async function loadPlugins(): Promise<void> {
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
plugins = await invoke<PluginInfo[]>("list_plugins");
|
||||
} catch (e) {
|
||||
error = `Failed to load plugins: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMarketplaces(): Promise<void> {
|
||||
try {
|
||||
isLoadingMarketplaces = true;
|
||||
error = null;
|
||||
marketplaces = await invoke<MarketplaceInfo[]>("list_marketplaces");
|
||||
} catch (e) {
|
||||
error = `Failed to load marketplaces: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoadingMarketplaces = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function installPlugin(): Promise<void> {
|
||||
if (!newPluginName.trim()) return;
|
||||
|
||||
try {
|
||||
isInstalling = true;
|
||||
error = null;
|
||||
await invoke("install_plugin", { pluginName: newPluginName.trim() });
|
||||
newPluginName = "";
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
error = `Failed to install plugin: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isInstalling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallPlugin(pluginName: string): Promise<void> {
|
||||
try {
|
||||
actionInProgress = pluginName;
|
||||
error = null;
|
||||
await invoke("uninstall_plugin", { pluginName });
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
error = `Failed to uninstall plugin: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlugin(plugin: PluginInfo): Promise<void> {
|
||||
try {
|
||||
actionInProgress = plugin.name;
|
||||
error = null;
|
||||
if (plugin.enabled) {
|
||||
await invoke("disable_plugin", { pluginName: plugin.name });
|
||||
} else {
|
||||
await invoke("enable_plugin", { pluginName: plugin.name });
|
||||
}
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
error = `Failed to ${plugin.enabled ? "disable" : "enable"} plugin: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePlugin(pluginName: string): Promise<void> {
|
||||
try {
|
||||
actionInProgress = pluginName;
|
||||
error = null;
|
||||
await invoke("update_plugin", { pluginName });
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
error = `Failed to update plugin: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function addMarketplace(): Promise<void> {
|
||||
if (!newMarketplaceSource.trim()) return;
|
||||
|
||||
try {
|
||||
isAddingMarketplace = true;
|
||||
error = null;
|
||||
await invoke("add_marketplace", { source: newMarketplaceSource.trim() });
|
||||
newMarketplaceSource = "";
|
||||
await loadMarketplaces();
|
||||
} catch (e) {
|
||||
error = `Failed to add marketplace: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isAddingMarketplace = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMarketplace(name: string): Promise<void> {
|
||||
try {
|
||||
actionInProgress = name;
|
||||
error = null;
|
||||
await invoke("remove_marketplace", { name });
|
||||
await loadMarketplaces();
|
||||
} catch (e) {
|
||||
error = `Failed to remove marketplace: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadPlugins();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed top-0 right-0 h-full w-[600px] bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-[var(--accent-primary)]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Plugin Management</h2>
|
||||
<p class="text-xs text-[var(--text-secondary)]">
|
||||
{plugins.length} plugin{plugins.length !== 1 ? "s" : ""} installed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
|
||||
aria-label="Close plugin panel"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Install Plugin Section -->
|
||||
<div class="p-4 border-b border-[var(--border-color)]">
|
||||
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-2">Install New Plugin</h3>
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-3">
|
||||
Enter plugin name (e.g., "macrodata" or "macrodata@macrodata" for specific marketplace)
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newPluginName}
|
||||
placeholder="plugin-name or plugin@marketplace"
|
||||
class="flex-1 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
onkeydown={(e) => e.key === "Enter" && installPlugin()}
|
||||
disabled={isInstalling}
|
||||
/>
|
||||
<button
|
||||
onclick={installPlugin}
|
||||
disabled={isInstalling || !newPluginName.trim()}
|
||||
class="px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{#if isInstalling}
|
||||
<RefreshCw class="w-4 h-4 animate-spin" />
|
||||
{:else}
|
||||
<Download class="w-4 h-4" />
|
||||
{/if}
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Marketplace Management Section -->
|
||||
<div class="p-4 border-b border-[var(--border-color)]">
|
||||
<button
|
||||
onclick={() => {
|
||||
showMarketplaces = !showMarketplaces;
|
||||
if (showMarketplaces && marketplaces.length === 0) {
|
||||
loadMarketplaces();
|
||||
}
|
||||
}}
|
||||
class="w-full text-left flex items-center justify-between text-sm font-medium text-[var(--text-primary)] hover:text-[var(--accent-primary)] transition-colors"
|
||||
>
|
||||
<span>Manage Marketplaces</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 transition-transform"
|
||||
class:rotate-180={showMarketplaces}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showMarketplaces}
|
||||
<div class="mt-3 space-y-3">
|
||||
<!-- Add Marketplace Form -->
|
||||
<div>
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-2">
|
||||
Add a marketplace from GitHub (e.g., "ascorbic/macrodata")
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newMarketplaceSource}
|
||||
placeholder="owner/repo"
|
||||
class="flex-1 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
onkeydown={(e) => e.key === "Enter" && addMarketplace()}
|
||||
disabled={isAddingMarketplace}
|
||||
/>
|
||||
<button
|
||||
onclick={addMarketplace}
|
||||
disabled={isAddingMarketplace || !newMarketplaceSource.trim()}
|
||||
class="px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{#if isAddingMarketplace}
|
||||
<RefreshCw class="w-4 h-4 animate-spin" />
|
||||
{:else}
|
||||
<Download class="w-4 h-4" />
|
||||
{/if}
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Marketplaces List -->
|
||||
{#if isLoadingMarketplaces}
|
||||
<div class="flex items-center justify-center py-4">
|
||||
<RefreshCw class="w-5 h-5 animate-spin text-[var(--text-secondary)]" />
|
||||
</div>
|
||||
{:else if marketplaces.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each marketplaces as marketplace (marketplace.name)}
|
||||
<div
|
||||
class="bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)]"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-[var(--text-primary)]">{marketplace.name}</h4>
|
||||
<p class="text-xs text-[var(--text-secondary)] mt-1">{marketplace.source}</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => removeMarketplace(marketplace.name)}
|
||||
disabled={actionInProgress === marketplace.name}
|
||||
class="px-2 py-1 text-red-400 hover:bg-red-500/20 rounded transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-[var(--text-secondary)] text-center py-4">
|
||||
No marketplaces configured
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
{#if error}
|
||||
<div class="mx-4 mt-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
|
||||
<p class="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Plugins List -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center h-full text-[var(--text-secondary)]">
|
||||
<RefreshCw class="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
{:else if plugins.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-16 w-16 mb-4 opacity-50"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-center">No plugins installed</p>
|
||||
<p class="text-sm text-center mt-2">Install a plugin using the form above</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each plugins as plugin (plugin.name)}
|
||||
<div
|
||||
class="bg-[var(--bg-secondary)]/50 rounded-lg p-4 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
|
||||
{plugin.name}
|
||||
{#if plugin.enabled}
|
||||
<span
|
||||
class="px-2 py-0.5 bg-[var(--success-color)]/20 text-[var(--success-color)] text-xs rounded border border-[var(--success-color)]/30"
|
||||
>
|
||||
Enabled
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="px-2 py-0.5 bg-[var(--text-secondary)]/20 text-[var(--text-secondary)] text-xs rounded border border-[var(--border-color)]"
|
||||
>
|
||||
Disabled
|
||||
</span>
|
||||
{/if}
|
||||
</h4>
|
||||
<p class="text-xs text-[var(--text-secondary)] mt-1">v{plugin.version}</p>
|
||||
{#if plugin.description}
|
||||
<p class="text-sm text-[var(--text-secondary)] mt-2">{plugin.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-3">
|
||||
<button
|
||||
onclick={() => togglePlugin(plugin)}
|
||||
disabled={actionInProgress === plugin.name}
|
||||
class="flex-1 px-3 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if plugin.enabled}
|
||||
<PowerOff class="w-4 h-4" />
|
||||
Disable
|
||||
{:else}
|
||||
<Power class="w-4 h-4" />
|
||||
Enable
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => updatePlugin(plugin.name)}
|
||||
disabled={actionInProgress === plugin.name}
|
||||
class="px-3 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" />
|
||||
Update
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => uninstallPlugin(plugin.name)}
|
||||
disabled={actionInProgress === plugin.name}
|
||||
class="px-3 py-1.5 bg-red-500/20 border border-red-500/30 rounded text-sm text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
Uninstall
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -15,23 +15,9 @@
|
||||
let showDeleteConfirm = $state<string | null>(null);
|
||||
let showExportMenu = $state<string | null>(null);
|
||||
let isImporting = $state(false);
|
||||
let showClearAllConfirm = $state(false);
|
||||
|
||||
let sessions = $state<SessionListItem[]>([]);
|
||||
let isLoading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const unsubSessions = sessionsStore.sessions.subscribe((value) => {
|
||||
sessions = value;
|
||||
});
|
||||
const unsubLoading = sessionsStore.isLoading.subscribe((value) => {
|
||||
isLoading = value;
|
||||
});
|
||||
return () => {
|
||||
unsubSessions();
|
||||
unsubLoading();
|
||||
};
|
||||
});
|
||||
const sessions = $derived(sessionsStore.sessions);
|
||||
const isLoading = $derived(sessionsStore.isLoading);
|
||||
|
||||
onMount(() => {
|
||||
sessionsStore.loadSessions();
|
||||
@@ -135,11 +121,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearAll(): Promise<void> {
|
||||
await sessionsStore.clearAllSessions();
|
||||
showClearAllConfirm = false;
|
||||
}
|
||||
|
||||
function toggleExportMenu(sessionId: string): void {
|
||||
if (showExportMenu === sessionId) {
|
||||
showExportMenu = null;
|
||||
@@ -205,22 +186,6 @@
|
||||
</svg>
|
||||
{isImporting ? "Importing..." : "Import"}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showClearAllConfirm = true)}
|
||||
disabled={sessions.length === 0}
|
||||
class="px-3 py-1.5 text-sm font-medium bg-red-500/10 text-red-500 border border-red-500/30 rounded hover:bg-red-500/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
title="Clear all sessions"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
Clear All
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={onClose}
|
||||
@@ -316,11 +281,11 @@
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
{#if isLoading}
|
||||
{#if $isLoading}
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<div class="text-[var(--text-tertiary)]">Loading sessions...</div>
|
||||
</div>
|
||||
{:else if sessions.length === 0}
|
||||
{:else if $sessions.length === 0}
|
||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||
<svg
|
||||
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
|
||||
@@ -342,7 +307,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-[var(--border-color)]">
|
||||
{#each sessions as session (session.id)}
|
||||
{#each $sessions as session (session.id)}
|
||||
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<button class="flex-1 text-left" onclick={() => handleViewSession(session)}>
|
||||
@@ -463,62 +428,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showClearAllConfirm}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
|
||||
onclick={() => (showClearAllConfirm = false)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && (showClearAllConfirm = false)}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-red-500/30 rounded-lg shadow-xl max-w-md w-full p-6"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="clear-all-title"
|
||||
aria-describedby="clear-all-description"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 text-red-500">
|
||||
<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="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="clear-all-title" class="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
Clear All Sessions?
|
||||
</h3>
|
||||
<p id="clear-all-description" class="text-[var(--text-secondary)] mb-4">
|
||||
This will permanently delete all {sessions.length} session{sessions.length === 1
|
||||
? ""
|
||||
: "s"}. This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
onclick={() => (showClearAllConfirm = false)}
|
||||
class="px-4 py-2 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleClearAll}
|
||||
class="px-4 py-2 text-sm font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Clear All Sessions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
|
||||
@@ -1,84 +1,8 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
formattedStats,
|
||||
contextWarning,
|
||||
getContextWarningMessage,
|
||||
stats,
|
||||
checkBudget,
|
||||
getBudgetStatusMessage,
|
||||
getRemainingTokenBudget,
|
||||
getRemainingCostBudget,
|
||||
} from "$lib/stores/stats";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
import { costTrackingStore, formattedCosts } from "$lib/stores/costTracking";
|
||||
import { formattedStats } from "$lib/stores/stats";
|
||||
import { fade } from "svelte/transition";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface Props {
|
||||
onRequestSummary?: () => void;
|
||||
onStartFreshWithContext?: () => void;
|
||||
isSummarising?: boolean;
|
||||
}
|
||||
|
||||
let { onRequestSummary, onStartFreshWithContext, isSummarising = false }: Props = $props();
|
||||
|
||||
let showToolsBreakdown = $state(false);
|
||||
let showHistoricalCosts = $state(false);
|
||||
const historicalCosts = $derived($formattedCosts);
|
||||
|
||||
// Initialize cost tracking on mount
|
||||
onMount(() => {
|
||||
costTrackingStore.refresh();
|
||||
});
|
||||
|
||||
// Subscribe to config store
|
||||
const config = configStore.config;
|
||||
|
||||
const warning = $derived($contextWarning);
|
||||
|
||||
// Budget tracking - must be defined before showCompactionOptions
|
||||
const budgetStatus = $derived(
|
||||
checkBudget(
|
||||
$stats,
|
||||
$config.budget_enabled,
|
||||
$config.session_token_budget,
|
||||
$config.session_cost_budget,
|
||||
$config.budget_warning_threshold
|
||||
)
|
||||
);
|
||||
const budgetMessage = $derived(getBudgetStatusMessage(budgetStatus));
|
||||
|
||||
// Show compaction options when context or budget is at warning/critical levels
|
||||
const showCompactionOptions = $derived(
|
||||
warning === "high" ||
|
||||
warning === "critical" ||
|
||||
budgetStatus.type === "warning" ||
|
||||
budgetStatus.type === "exceeded"
|
||||
);
|
||||
|
||||
const remainingTokens = $derived(getRemainingTokenBudget($stats, $config.session_token_budget));
|
||||
const remainingCost = $derived(getRemainingCostBudget($stats, $config.session_cost_budget));
|
||||
|
||||
// Calculate budget usage percentages for progress bars
|
||||
const tokenBudgetPercent = $derived(() => {
|
||||
const budget = $config.session_token_budget;
|
||||
if (budget === null || budget === 0) return 0;
|
||||
const used = $stats.session_input_tokens + $stats.session_output_tokens;
|
||||
return Math.min(100, (used / budget) * 100);
|
||||
});
|
||||
|
||||
const costBudgetPercent = $derived(() => {
|
||||
const budget = $config.session_cost_budget;
|
||||
if (budget === null || budget === 0) return 0;
|
||||
return Math.min(100, ($stats.session_cost_usd / budget) * 100);
|
||||
});
|
||||
|
||||
// Get the appropriate colour class for the progress bar
|
||||
function getBudgetBarClass(percent: number, warningThreshold: number): string {
|
||||
if (percent >= 100) return "budget-bar-exceeded";
|
||||
if (percent >= warningThreshold * 100) return "budget-bar-warning";
|
||||
return "budget-bar-ok";
|
||||
}
|
||||
let showToolsBreakdown = false;
|
||||
</script>
|
||||
|
||||
<div class="stats-display" transition:fade={{ duration: 200 }}>
|
||||
@@ -92,120 +16,6 @@
|
||||
<span class="stat-value">{$formattedStats.messagesSession}</span>
|
||||
</div>
|
||||
|
||||
<div class="stats-section">
|
||||
<h3>Context Window</h3>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Used:</span>
|
||||
<span class="stat-value">{$formattedStats.contextUsed} / {$formattedStats.contextLimit}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Utilisation:</span>
|
||||
<span class="stat-value context-util {warning ? `warning-${warning}` : ''}"
|
||||
>{$formattedStats.contextUtilisation}</span
|
||||
>
|
||||
</div>
|
||||
{#if warning}
|
||||
<div class="context-warning warning-{warning}">
|
||||
{getContextWarningMessage(warning)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if showCompactionOptions && (onRequestSummary || onStartFreshWithContext)}
|
||||
<div class="compaction-actions">
|
||||
{#if onRequestSummary}
|
||||
<button
|
||||
class="compaction-btn"
|
||||
onclick={onRequestSummary}
|
||||
disabled={isSummarising}
|
||||
title="Compact conversation history to reduce context usage"
|
||||
>
|
||||
{#if isSummarising}
|
||||
Compacting...
|
||||
{:else}
|
||||
Compact
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{#if onStartFreshWithContext}
|
||||
<button
|
||||
class="compaction-btn compaction-btn-primary"
|
||||
onclick={onStartFreshWithContext}
|
||||
disabled={isSummarising}
|
||||
title="Start a new conversation with context from this one"
|
||||
>
|
||||
Fresh Start
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $config.budget_enabled}
|
||||
<div class="stats-section">
|
||||
<h3>Budget</h3>
|
||||
{#if $config.session_token_budget !== null}
|
||||
<div class="budget-item">
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Tokens:</span>
|
||||
<span
|
||||
class="stat-value {budgetStatus.type !== 'ok' && budgetStatus.budget_type === 'token'
|
||||
? `budget-${budgetStatus.type}`
|
||||
: ''}"
|
||||
>
|
||||
{($stats.session_input_tokens + $stats.session_output_tokens).toLocaleString()} / {$config.session_token_budget.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="budget-bar-container">
|
||||
<div
|
||||
class="budget-bar {getBudgetBarClass(
|
||||
tokenBudgetPercent(),
|
||||
$config.budget_warning_threshold
|
||||
)}"
|
||||
style="width: {tokenBudgetPercent()}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="budget-remaining">
|
||||
{remainingTokens?.toLocaleString() ?? 0} remaining ({(
|
||||
100 - tokenBudgetPercent()
|
||||
).toFixed(1)}%)
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $config.session_cost_budget !== null}
|
||||
<div class="budget-item">
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Cost:</span>
|
||||
<span
|
||||
class="stat-value {budgetStatus.type !== 'ok' && budgetStatus.budget_type === 'cost'
|
||||
? `budget-${budgetStatus.type}`
|
||||
: ''}"
|
||||
>
|
||||
${$stats.session_cost_usd.toFixed(4)} / ${$config.session_cost_budget.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="budget-bar-container">
|
||||
<div
|
||||
class="budget-bar {getBudgetBarClass(
|
||||
costBudgetPercent(),
|
||||
$config.budget_warning_threshold
|
||||
)}"
|
||||
style="width: {costBudgetPercent()}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="budget-remaining">
|
||||
${remainingCost?.toFixed(4) ?? "0.0000"} remaining ({(
|
||||
100 - costBudgetPercent()
|
||||
).toFixed(1)}%)
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if budgetMessage}
|
||||
<div class="budget-warning budget-{budgetStatus.type}">
|
||||
{budgetMessage}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="stats-section">
|
||||
<h3>Tokens & Cost</h3>
|
||||
<div class="stat-row">
|
||||
@@ -239,7 +49,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $formattedStats.sessionToolsFormatted.length > 0}
|
||||
{#if Object.keys($formattedStats.sessionToolsUsage).length > 0}
|
||||
<div class="stats-section">
|
||||
<h3 class="tools-header">
|
||||
<button class="tools-toggle" onclick={() => (showToolsBreakdown = !showToolsBreakdown)}>
|
||||
@@ -249,57 +59,17 @@
|
||||
</h3>
|
||||
{#if showToolsBreakdown}
|
||||
<div class="tools-breakdown">
|
||||
{#each $formattedStats.sessionToolsFormatted.sort((a, b) => b.totalTokens - a.totalTokens) as tool (tool.name)}
|
||||
<div class="stat-row stat-detail tool-row">
|
||||
<span class="stat-label">{tool.name}:</span>
|
||||
<span class="stat-value tool-stats">
|
||||
<span class="tool-calls">{tool.callCount} calls</span>
|
||||
{#if tool.totalTokens > 0}
|
||||
<span class="tool-tokens">(~{tool.formattedTokens})</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#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 class="tools-note">* Token estimates based on attribution</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Historical Costs Section -->
|
||||
<div class="stats-section">
|
||||
<h3 class="costs-header">
|
||||
<button class="costs-toggle" onclick={() => (showHistoricalCosts = !showHistoricalCosts)}>
|
||||
Historical Costs
|
||||
<span class="toggle-icon">{showHistoricalCosts ? "▼" : "▶"}</span>
|
||||
</button>
|
||||
</h3>
|
||||
{#if !showHistoricalCosts}
|
||||
<div class="costs-quick-stats">
|
||||
<span class="cost-badge" title="Today's cost">Today: {historicalCosts.today}</span>
|
||||
<span class="cost-badge" title="This week's cost">Week: {historicalCosts.week}</span>
|
||||
<span class="cost-badge" title="This month's cost">Month: {historicalCosts.month}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showHistoricalCosts}
|
||||
<div class="historical-costs-expanded">
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Today:</span>
|
||||
<span class="stat-value cost-value">{historicalCosts.today}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">This Week:</span>
|
||||
<span class="stat-value cost-value">{historicalCosts.week}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">This Month:</span>
|
||||
<span class="stat-value cost-value">{historicalCosts.month}</span>
|
||||
</div>
|
||||
<p class="costs-note">Open Settings to view detailed cost history and set alerts.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="model-info">
|
||||
<span class="model-label">Model:</span>
|
||||
<span class="model-value">{$formattedStats.model}</span>
|
||||
@@ -358,79 +128,6 @@
|
||||
color: var(--text-primary, #e5e7eb);
|
||||
}
|
||||
|
||||
.stat-cost {
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--accent-primary, #10b981);
|
||||
font-size: 0.8rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0;
|
||||
}
|
||||
|
||||
.tools-header {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.tools-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tools-toggle:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tools-breakdown {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.tool-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tool-stats {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tool-calls {
|
||||
color: var(--text-primary, #e5e7eb);
|
||||
}
|
||||
|
||||
.tool-tokens {
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.tools-note {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.model-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -451,220 +148,4 @@
|
||||
color: var(--text-primary, #e5e7eb);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.context-util {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.context-util.warning-moderate {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.context-util.warning-high {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.context-util.warning-critical {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.context-warning {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.context-warning.warning-moderate {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.context-warning.warning-high {
|
||||
background: rgba(249, 115, 22, 0.15);
|
||||
border: 1px solid rgba(249, 115, 22, 0.3);
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
.context-warning.warning-critical {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* Budget progress bar styles */
|
||||
.budget-item {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.budget-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.budget-bar-container {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 3px;
|
||||
margin-top: 0.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.budget-bar {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition:
|
||||
width 0.3s ease,
|
||||
background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.budget-bar-ok {
|
||||
background: linear-gradient(90deg, #10b981, #34d399);
|
||||
}
|
||||
|
||||
.budget-bar-warning {
|
||||
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||
}
|
||||
|
||||
.budget-bar-exceeded {
|
||||
background: linear-gradient(90deg, #ef4444, #f87171);
|
||||
}
|
||||
|
||||
.budget-remaining {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.125rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Budget warning styles */
|
||||
.budget-warning {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.budget-warning.budget-warning {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.budget-warning.budget-exceeded {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.stat-value.budget-warning {
|
||||
color: #f59e0b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-value.budget-exceeded {
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Compaction action buttons */
|
||||
.compaction-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.compaction-btn {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.compaction-btn:hover:not(:disabled) {
|
||||
border-color: var(--accent-primary);
|
||||
background: rgba(233, 69, 96, 0.1);
|
||||
}
|
||||
|
||||
.compaction-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.compaction-btn-primary {
|
||||
background: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.compaction-btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-secondary);
|
||||
border-color: var(--accent-secondary);
|
||||
}
|
||||
|
||||
/* Historical costs styles */
|
||||
.costs-header {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.costs-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.costs-toggle:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.costs-quick-stats {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cost-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
border-radius: 3px;
|
||||
color: #10b981;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.historical-costs-expanded {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.cost-value {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.costs-note {
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,23 +21,9 @@
|
||||
import HelpPanel from "./HelpPanel.svelte";
|
||||
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
|
||||
import { achievementProgress } from "$lib/stores/achievements";
|
||||
import { runningAgentCount } from "$lib/stores/agents";
|
||||
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
|
||||
import TodoPanel from "./TodoPanel.svelte";
|
||||
import GitPanel from "./GitPanel.svelte";
|
||||
import ProfilePanel from "./ProfilePanel.svelte";
|
||||
import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
|
||||
import CastPanel from "./CastPanel.svelte";
|
||||
import PluginManagementPanel from "./PluginManagementPanel.svelte";
|
||||
import McpManagementPanel from "./McpManagementPanel.svelte";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import {
|
||||
generateContextInjection,
|
||||
createSummary,
|
||||
sanitizeForJson,
|
||||
} from "$lib/utils/conversationUtils";
|
||||
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
||||
import { debugConsoleStore } from "$lib/stores/debugConsole";
|
||||
|
||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||
const DONATE_URL = "https://donate.nhcarrigan.com";
|
||||
@@ -53,16 +39,9 @@
|
||||
let showHelp = $state(false);
|
||||
let showKeyboardShortcuts = $state(false);
|
||||
let showSessionHistory = $state(false);
|
||||
let showTodoPanel = $state(false);
|
||||
let showGitPanel = $state(false);
|
||||
let showProfile = $state(false);
|
||||
let showAgentMonitor = $state(false);
|
||||
let showCastPanel = $state(false);
|
||||
let showPluginPanel = $state(false);
|
||||
let showMcpPanel = $state(false);
|
||||
let isSummarising = $state(false);
|
||||
const progress = $derived($achievementProgress);
|
||||
const activeAgentCount = $derived($runningAgentCount);
|
||||
let currentConfig: HikariConfig = $state({
|
||||
model: null,
|
||||
api_key: null,
|
||||
@@ -78,6 +57,7 @@
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: null,
|
||||
font_size: 14,
|
||||
minimize_to_tray: false,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
@@ -94,13 +74,6 @@
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
budget_enabled: false,
|
||||
session_token_budget: null,
|
||||
session_cost_budget: null,
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
});
|
||||
|
||||
let streamerModeActive = $state(false);
|
||||
@@ -180,16 +153,6 @@
|
||||
allowed_tools: allAllowedTools,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when a new session starts
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
currentConfig.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to start Claude:", error);
|
||||
claudeStore.addLine("error", `Connection failed: ${error}`);
|
||||
@@ -203,9 +166,6 @@
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
await invoke("stop_claude", { conversationId });
|
||||
|
||||
// Clear granted permissions when user explicitly disconnects
|
||||
claudeStore.revokeAllTools();
|
||||
} catch (error) {
|
||||
console.error("Failed to stop Claude:", error);
|
||||
}
|
||||
@@ -240,109 +200,6 @@
|
||||
function toggleAchievements() {
|
||||
onToggleAchievements();
|
||||
}
|
||||
|
||||
async function handleCompactConversation() {
|
||||
const activeId = get(conversationsStore.activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
isSummarising = true;
|
||||
|
||||
try {
|
||||
const conversationContent = conversationsStore.getConversationForSummary(activeId);
|
||||
const messageCount =
|
||||
get(conversationsStore.activeConversation)?.terminalLines.filter(
|
||||
(l) => l.type === "user" || l.type === "assistant"
|
||||
).length || 0;
|
||||
const tokenEstimate = conversationsStore.estimateTokenCount(activeId);
|
||||
|
||||
// Create a summary from the conversation content (truncate if too long)
|
||||
// Apply sanitization early to handle any problematic escape sequences
|
||||
const sanitizedContent = sanitizeForJson(conversationContent);
|
||||
const summaryContent =
|
||||
sanitizedContent.length > 4000
|
||||
? `${sanitizedContent.slice(0, 4000)}\n\n[Truncated for length - original had ${messageCount} messages]`
|
||||
: sanitizedContent;
|
||||
|
||||
// Step 1: Disconnect from Claude to reset context
|
||||
// Prevent stats reset on reconnection
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
if (connectionStatus === "connected") {
|
||||
await invoke("stop_claude", { conversationId: activeId });
|
||||
}
|
||||
|
||||
// Step 2: Clear messages and store summary
|
||||
conversationsStore.compactWithSummary(activeId, summaryContent, messageCount, tokenEstimate);
|
||||
|
||||
// Step 3: Reconnect to Claude with fresh context
|
||||
const allAllowedTools = [
|
||||
...(currentConfig.auto_granted_tools || []),
|
||||
...Array.from(get(claudeStore.grantedTools)),
|
||||
];
|
||||
|
||||
await invoke("start_claude", {
|
||||
conversationId: activeId,
|
||||
options: {
|
||||
working_dir: workingDirectory || selectedDirectory,
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
// Step 4: Send the context summary to Claude as the first message
|
||||
const contextPrompt = generateContextInjection(
|
||||
createSummary(summaryContent, messageCount, tokenEstimate)
|
||||
);
|
||||
|
||||
await invoke("send_prompt", {
|
||||
conversationId: activeId,
|
||||
message: contextPrompt,
|
||||
});
|
||||
|
||||
claudeStore.addLine(
|
||||
"system",
|
||||
"Conversation compacted. Context from previous session has been provided to Claude."
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to compact conversation:", error);
|
||||
claudeStore.addLine("error", `Failed to compact conversation: ${error}`);
|
||||
} finally {
|
||||
isSummarising = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartFreshWithContext() {
|
||||
const activeId = get(conversationsStore.activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
const conversationContent = conversationsStore.getConversationForSummary(activeId);
|
||||
const messageCount =
|
||||
get(conversationsStore.activeConversation)?.terminalLines.filter(
|
||||
(l) => l.type === "user" || l.type === "assistant"
|
||||
).length || 0;
|
||||
const tokenEstimate = conversationsStore.estimateTokenCount(activeId);
|
||||
|
||||
const summary = createSummary(
|
||||
`This is a continuation of a previous conversation. Here's what was discussed:\n\n${conversationContent.slice(0, 4000)}${conversationContent.length > 4000 ? "\n\n[Truncated for length...]" : ""}`,
|
||||
messageCount,
|
||||
tokenEstimate
|
||||
);
|
||||
|
||||
const newConvId = conversationsStore.createConversation("Fresh Start");
|
||||
|
||||
conversationsStore.setSummary(newConvId, summary);
|
||||
|
||||
// Context injection is generated but the actual injection happens via the summary
|
||||
generateContextInjection(summary);
|
||||
claudeStore.addLine("system", "Started fresh conversation with context from previous session.");
|
||||
claudeStore.addLine(
|
||||
"system",
|
||||
`Previous session had ${messageCount} messages (~${tokenEstimate.toLocaleString()} tokens).`
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -383,16 +240,16 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap min-w-0">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if streamerModeActive}
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse shrink-0"
|
||||
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse"
|
||||
title="Streamer mode active (Ctrl+Shift+S to toggle)"
|
||||
></div>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => (showProfile = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover shrink-0"
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Profile"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -446,20 +303,6 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showTodoPanel = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Todo List"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showGitPanel = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
@@ -474,34 +317,6 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showPluginPanel = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Plugin Management"
|
||||
>
|
||||
<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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showMcpPanel = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="MCP Server Management"
|
||||
>
|
||||
<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="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={toggleEditor}
|
||||
disabled={connectionStatus !== "connected"}
|
||||
@@ -521,43 +336,6 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showCastPanel = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Meet the Team"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showAgentMonitor = !showAgentMonitor)}
|
||||
class="p-1 text-gray-500 icon-trans-hover relative {showAgentMonitor
|
||||
? 'text-[var(--trans-pink)]'
|
||||
: ''}"
|
||||
title="Agent Monitor"
|
||||
>
|
||||
<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 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
{#if activeAgentCount > 0}
|
||||
<span
|
||||
class="absolute -top-1 -right-1 bg-blue-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px] animate-pulse"
|
||||
>
|
||||
{activeAgentCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showStats = !showStats)}
|
||||
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
|
||||
@@ -572,20 +350,6 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => debugConsoleStore.toggle()}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Debug Console (Ctrl+`)"
|
||||
>
|
||||
<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 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={configStore.openSidebar}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
@@ -682,11 +446,7 @@
|
||||
|
||||
{#if showStats}
|
||||
<div class="absolute top-full right-0 mt-2 mr-4 z-50">
|
||||
<StatsDisplay
|
||||
onRequestSummary={handleCompactConversation}
|
||||
onStartFreshWithContext={handleStartFreshWithContext}
|
||||
{isSummarising}
|
||||
/>
|
||||
<StatsDisplay />
|
||||
</div>
|
||||
{/if}
|
||||
{#if connectionStatus === "connected"}
|
||||
@@ -712,12 +472,8 @@
|
||||
<!-- 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 max-h-[calc(100vh-4rem)] overflow-y-auto">
|
||||
<StatsDisplay
|
||||
onRequestSummary={handleCompactConversation}
|
||||
onStartFreshWithContext={handleStartFreshWithContext}
|
||||
{isSummarising}
|
||||
/>
|
||||
<div class="fixed top-14 right-4 z-50">
|
||||
<StatsDisplay />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -737,10 +493,6 @@
|
||||
<SessionHistoryPanel onClose={() => (showSessionHistory = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showTodoPanel}
|
||||
<TodoPanel onClose={() => (showTodoPanel = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showGitPanel}
|
||||
<GitPanel isOpen={showGitPanel} onClose={() => (showGitPanel = false)} />
|
||||
{/if}
|
||||
@@ -748,48 +500,3 @@
|
||||
{#if showProfile}
|
||||
<ProfilePanel onClose={() => (showProfile = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showAgentMonitor}
|
||||
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showCastPanel}
|
||||
<CastPanel onClose={() => (showCastPanel = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showPluginPanel}
|
||||
<PluginManagementPanel onClose={() => (showPluginPanel = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showMcpPanel}
|
||||
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Responsive status bar styling */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Make all icon buttons shrink but not grow */
|
||||
.status-bar button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Hide version text on very small screens */
|
||||
@media (max-width: 640px) {
|
||||
.status-bar button span:last-of-type {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stack left and right sections on very small screens */
|
||||
@media (max-width: 768px) {
|
||||
.status-bar {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
<script lang="ts">
|
||||
let currentTime = $state("");
|
||||
|
||||
function updateTime() {
|
||||
const now = new Date();
|
||||
|
||||
// Format date as "1 January 2026"
|
||||
const day = now.getDate();
|
||||
const month = now.toLocaleString("en-GB", { month: "long" });
|
||||
const year = now.getFullYear();
|
||||
|
||||
// Format time as HH:MM:SS (24-hour)
|
||||
const hours = String(now.getHours()).padStart(2, "0");
|
||||
const minutes = String(now.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(now.getSeconds()).padStart(2, "0");
|
||||
|
||||
currentTime = `${day} ${month} ${year}, ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
// Update immediately on mount
|
||||
updateTime();
|
||||
|
||||
// Update every second
|
||||
const interval = setInterval(updateTime, 1000);
|
||||
|
||||
// Cleanup on component destroy
|
||||
$effect(() => {
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="system-clock">
|
||||
<svg
|
||||
class="clock-icon"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<span class="clock-text">{currentTime}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.system-clock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
transition: all 0.2s;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.system-clock:hover {
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.clock-icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.clock-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* SystemClock Component Tests
|
||||
*
|
||||
* Note: This file tests the time formatting logic used by the SystemClock component.
|
||||
* Full component rendering tests are challenging with Svelte 5 + @testing-library/svelte
|
||||
* due to SSR/CSR compatibility issues. The component itself is simple and visually
|
||||
* testable - it displays the current date and time, updating every second.
|
||||
*
|
||||
* What this component does:
|
||||
* - Displays date in British format: "7 February 2026"
|
||||
* - Displays time in 24-hour format: "14:35:42"
|
||||
* - Updates every second via setInterval
|
||||
* - Cleans up interval on unmount via $effect
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] Clock appears above the Send button
|
||||
* - [ ] Time updates every second
|
||||
* - [ ] Date format is "DD Month YYYY"
|
||||
* - [ ] Time format is "HH:MM:SS" (24-hour)
|
||||
* - [ ] Hover effect works (border turns accent colour)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// Helper function that mirrors the component's formatting logic
|
||||
function formatDateTime(date: Date): string {
|
||||
const day = date.getDate();
|
||||
const month = date.toLocaleString("en-GB", { month: "long" });
|
||||
const year = date.getFullYear();
|
||||
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||
|
||||
return `${day} ${month} ${year}, ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
describe("SystemClock date/time formatting", () => {
|
||||
it("formats date in British format (DD Month YYYY)", () => {
|
||||
// Use local timezone (not UTC) since the component uses local time
|
||||
const date = new Date(2026, 1, 7, 14, 35, 42); // Feb 7, 2026 14:35:42 local
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toContain("7 February 2026");
|
||||
});
|
||||
|
||||
it("formats time in 24-hour format (HH:MM:SS)", () => {
|
||||
const date = new Date(2026, 1, 7, 14, 35, 42);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
// Should have the pattern HH:MM:SS
|
||||
expect(formatted).toMatch(/\d{2}:\d{2}:\d{2}/);
|
||||
expect(formatted).toContain("14:35:42");
|
||||
});
|
||||
|
||||
it("combines date and time with comma separator", () => {
|
||||
const date = new Date(2026, 1, 7, 14, 35, 42);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toBe("7 February 2026, 14:35:42");
|
||||
});
|
||||
|
||||
it("pads single-digit hours, minutes, and seconds with zeros", () => {
|
||||
const date = new Date(2026, 1, 7, 3, 5, 8);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
// Should have leading zeros: 03:05:08, not 3:5:8
|
||||
expect(formatted).toContain("03:05:08");
|
||||
});
|
||||
|
||||
it("handles different months correctly", () => {
|
||||
const date = new Date(2026, 11, 25, 12, 0, 0); // December is month 11
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toContain("25 December 2026");
|
||||
});
|
||||
|
||||
it("handles year changes correctly", () => {
|
||||
const date = new Date(2027, 0, 1, 0, 0, 0); // January is month 0
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toContain("1 January 2027");
|
||||
expect(formatted).toContain("00:00:00");
|
||||
});
|
||||
|
||||
it("handles midnight correctly", () => {
|
||||
const date = new Date(2026, 1, 7, 0, 0, 0);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toContain("00:00:00");
|
||||
});
|
||||
|
||||
it("handles noon correctly", () => {
|
||||
const date = new Date(2026, 1, 7, 12, 0, 0);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
// 24-hour format, so noon is 12:00:00, not 00:00:00
|
||||
expect(formatted).toContain("12:00:00");
|
||||
});
|
||||
|
||||
it("handles end of day correctly", () => {
|
||||
const date = new Date(2026, 1, 7, 23, 59, 59);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toContain("23:59:59");
|
||||
});
|
||||
|
||||
it("handles month boundaries correctly", () => {
|
||||
// Last day of January
|
||||
const jan31 = new Date(2026, 0, 31, 23, 59, 59);
|
||||
expect(formatDateTime(jan31)).toContain("31 January 2026");
|
||||
|
||||
// First day of February
|
||||
const feb1 = new Date(2026, 1, 1, 0, 0, 0);
|
||||
expect(formatDateTime(feb1)).toContain("1 February 2026");
|
||||
});
|
||||
|
||||
it("handles leap year February correctly", () => {
|
||||
// 2024 is a leap year
|
||||
const feb29 = new Date(2024, 1, 29, 12, 0, 0);
|
||||
const formatted = formatDateTime(feb29);
|
||||
|
||||
expect(formatted).toContain("29 February 2024");
|
||||
});
|
||||
|
||||
it("handles all 12 months correctly", () => {
|
||||
const months = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
months.forEach((month, index) => {
|
||||
const date = new Date(2026, index, 15, 12, 0, 0);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toContain(month);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,9 @@
|
||||
import ConversationTabs from "./ConversationTabs.svelte";
|
||||
import Markdown from "./Markdown.svelte";
|
||||
import HighlightedText from "./HighlightedText.svelte";
|
||||
import ThinkingBlock from "./ThinkingBlock.svelte";
|
||||
import { searchState, searchQuery } from "$lib/stores/search";
|
||||
import { clipboardStore } from "$lib/stores/clipboard";
|
||||
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
|
||||
import { shouldHidePaths, maskPaths } from "$lib/stores/config";
|
||||
|
||||
let terminalElement: HTMLDivElement;
|
||||
let shouldAutoScroll = true;
|
||||
@@ -25,11 +24,6 @@
|
||||
hidePaths = value;
|
||||
});
|
||||
|
||||
let showThinking = true;
|
||||
showThinkingBlocks.subscribe((value) => {
|
||||
showThinking = value;
|
||||
});
|
||||
|
||||
claudeStore.terminalLines.subscribe((value) => {
|
||||
lines = value;
|
||||
});
|
||||
@@ -90,8 +84,6 @@
|
||||
return "terminal-tool";
|
||||
case "error":
|
||||
return "terminal-error";
|
||||
case "thinking":
|
||||
return "terminal-thinking";
|
||||
default:
|
||||
return "terminal-default";
|
||||
}
|
||||
@@ -217,86 +209,48 @@
|
||||
</div>
|
||||
{:else}
|
||||
{#each lines as line (line.id)}
|
||||
{#if line.type === "thinking"}
|
||||
{#if showThinking}
|
||||
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
|
||||
<div class="terminal-line mb-2 {getLineClass(line.type)} relative group">
|
||||
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
|
||||
{#if getLinePrefix(line.type)}
|
||||
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<div
|
||||
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
|
||||
style={line.parentToolUseId
|
||||
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
|
||||
: ""}
|
||||
>
|
||||
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
|
||||
{#if line.parentToolUseId}
|
||||
<span class="text-xs mr-2 opacity-60" title="Message from subagent">
|
||||
<svg
|
||||
class="inline-block w-3 h-3 -mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
{#if line.cost && line.cost.costUsd > 0}
|
||||
<span
|
||||
class="terminal-cost text-xs mr-2"
|
||||
title="Input: {line.cost.inputTokens} | Output: {line.cost.outputTokens}"
|
||||
>
|
||||
${line.cost.costUsd < 0.01
|
||||
? line.cost.costUsd.toFixed(4)
|
||||
: line.cost.costUsd.toFixed(3)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if getLinePrefix(line.type)}
|
||||
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
||||
{/if}
|
||||
{#if line.toolName}
|
||||
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
||||
{/if}
|
||||
{#if line.type === "assistant" || line.type === "user"}
|
||||
<div class="message-content-wrapper">
|
||||
<Markdown
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
searchQuery={currentSearchQuery}
|
||||
/>
|
||||
<button
|
||||
class="copy-message-btn opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick={() => handleCopyMessage(line.id, line.content)}
|
||||
title="Copy message"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<HighlightedText
|
||||
{#if line.toolName}
|
||||
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
||||
{/if}
|
||||
{#if line.type === "assistant" || line.type === "user"}
|
||||
<div class="message-content-wrapper">
|
||||
<Markdown
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
searchQuery={currentSearchQuery}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class="copy-message-btn opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick={() => handleCopyMessage(line.id, line.content)}
|
||||
title="Copy message"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<HighlightedText
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
searchQuery={currentSearchQuery}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -337,14 +291,6 @@
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
}
|
||||
|
||||
.terminal-cost {
|
||||
color: var(--terminal-cost, #10b981);
|
||||
background: var(--terminal-cost-bg, rgba(16, 185, 129, 0.1));
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.terminal-prefix {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
let { content, timestamp }: Props = $props();
|
||||
let isExpanded = $state(false);
|
||||
|
||||
function toggleExpanded() {
|
||||
isExpanded = !isExpanded;
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="thinking-block">
|
||||
<button class="thinking-header" onclick={toggleExpanded} type="button">
|
||||
<span class="thinking-timestamp">{formatTime(timestamp)}</span>
|
||||
<svg
|
||||
class="thinking-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="thinking-label">Extended Thinking</span>
|
||||
<svg
|
||||
class="chevron"
|
||||
class:expanded={isExpanded}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="thinking-content">
|
||||
{content}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.thinking-block {
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--bg-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.thinking-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.thinking-header:hover {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.thinking-timestamp {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.thinking-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thinking-label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.chevron.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.thinking-content {
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -1,182 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { todos } from "$lib/stores/todos";
|
||||
import { CheckCircle, Circle, Loader } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
const currentTodos = $derived($todos);
|
||||
const hasTodos = $derived(currentTodos.length > 0);
|
||||
const completedCount = $derived(currentTodos.filter((t) => t.status === "completed").length);
|
||||
const totalCount = $derived(currentTodos.length);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed top-0 right-0 h-full w-96 bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-[var(--accent-primary)]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Hikari's Todo List</h2>
|
||||
{#if hasTodos}
|
||||
<p class="text-xs text-[var(--text-secondary)]">
|
||||
{completedCount} of {totalCount} completed
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
|
||||
aria-label="Close todo panel"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
{#if !hasTodos}
|
||||
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-16 w-16 mb-4 opacity-50"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-center">No active todos</p>
|
||||
<p class="text-sm text-center mt-2">I'll update this when I start working on tasks!</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each currentTodos as todo (todo.content)}
|
||||
<div
|
||||
class="group bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all"
|
||||
class:opacity-60={todo.status === "completed"}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Status Icon -->
|
||||
<div class="mt-0.5 flex-shrink-0">
|
||||
{#if todo.status === "completed"}
|
||||
<CheckCircle class="w-5 h-5 text-[var(--success-color)]" />
|
||||
{:else if todo.status === "in_progress"}
|
||||
<Loader class="w-5 h-5 text-[var(--accent-primary)] animate-spin" />
|
||||
{:else}
|
||||
<Circle class="w-5 h-5 text-[var(--text-secondary)]" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-sm font-medium"
|
||||
class:text-[var(--text-secondary)]={todo.status === "completed"}
|
||||
class:line-through={todo.status === "completed"}
|
||||
class:text-[var(--text-primary)]={todo.status !== "completed"}
|
||||
>
|
||||
{todo.status === "in_progress" ? todo.activeForm : todo.content}
|
||||
</p>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<div class="mt-1">
|
||||
{#if todo.status === "completed"}
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[var(--success-color)]/20 text-[var(--success-color)] border border-[var(--success-color)]/30"
|
||||
>
|
||||
✓ Completed
|
||||
</span>
|
||||
{:else if todo.status === "in_progress"}
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] border border-[var(--accent-primary)]/30 animate-pulse"
|
||||
>
|
||||
⚡ In Progress
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-color)]"
|
||||
>
|
||||
○ Pending
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer with Progress Bar -->
|
||||
{#if hasTodos}
|
||||
<div class="border-t border-[var(--accent-primary)]/30 p-4 bg-[var(--bg-secondary)]/50">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-[var(--text-secondary)]">Progress</span>
|
||||
<span class="text-xs font-medium text-[var(--accent-primary)]">
|
||||
{Math.round((completedCount / totalCount) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-[var(--bg-secondary)] rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] h-2 rounded-full transition-all duration-500 ease-out"
|
||||
style="width: {(completedCount / totalCount) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -5,9 +5,6 @@
|
||||
import { claudeStore, hasQuestionPending } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import type { UserQuestionEvent } from "$lib/types/messages";
|
||||
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
let isVisible = $state(false);
|
||||
let question: UserQuestionEvent | null = $state(null);
|
||||
@@ -89,36 +86,18 @@
|
||||
claudeStore.clearQuestion();
|
||||
|
||||
try {
|
||||
// Prevent stats reset on reconnection
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
await invoke("stop_claude", { conversationId });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const config = configStore.getConfig();
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDirectory || "/home/naomi",
|
||||
model: config.model || null,
|
||||
api_key: config.api_key || null,
|
||||
custom_instructions: config.custom_instructions || null,
|
||||
mcp_servers_json: config.mcp_servers_json || null,
|
||||
allowed_tools: grantedToolsList,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when reconnecting after answering question
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
if (conversationHistory) {
|
||||
|
||||
@@ -90,8 +90,6 @@ class NotificationManager {
|
||||
return "Successfully connected to Claude Code";
|
||||
case NotificationType.TASK_START:
|
||||
return "Starting task...";
|
||||
case NotificationType.COST_ALERT:
|
||||
return "You've exceeded your cost threshold!";
|
||||
default:
|
||||
return "Notification";
|
||||
}
|
||||
@@ -117,10 +115,6 @@ class NotificationManager {
|
||||
async notifyTaskStart(message?: string): Promise<void> {
|
||||
await this.notify(NotificationType.TASK_START, message);
|
||||
}
|
||||
|
||||
async notifyCostAlert(message?: string): Promise<void> {
|
||||
await this.notify(NotificationType.COST_ALERT, message);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
||||
@@ -51,13 +51,9 @@ describe("notifications", () => {
|
||||
expect(NotificationType.ACHIEVEMENT).toBe("achievement");
|
||||
});
|
||||
|
||||
it("has exactly 7 notification types", () => {
|
||||
it("has exactly 6 notification types", () => {
|
||||
const types = Object.values(NotificationType);
|
||||
expect(types.length).toBe(7);
|
||||
});
|
||||
|
||||
it("has COST_ALERT type", () => {
|
||||
expect(NotificationType.COST_ALERT).toBe("cost_alert");
|
||||
expect(types.length).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -318,11 +314,10 @@ describe("notifications", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sound filenames are mostly unique", () => {
|
||||
it("sound filenames are unique", () => {
|
||||
const filenames = Object.values(NOTIFICATION_SOUNDS).map((s) => s.filename);
|
||||
const uniqueFilenames = new Set(filenames);
|
||||
// Allow some sound reuse (e.g., COST_ALERT reuses ERROR sound)
|
||||
expect(uniqueFilenames.size).toBeGreaterThanOrEqual(filenames.length - 1);
|
||||
expect(uniqueFilenames.size).toBe(filenames.length);
|
||||
});
|
||||
|
||||
it("phrases are unique", () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ export enum NotificationType {
|
||||
CONNECTION = "connection",
|
||||
TASK_START = "task_start",
|
||||
ACHIEVEMENT = "achievement",
|
||||
COST_ALERT = "cost_alert",
|
||||
}
|
||||
|
||||
export interface NotificationSound {
|
||||
@@ -53,10 +52,4 @@ export const NOTIFICATION_SOUNDS: Record<NotificationType, NotificationSound> =
|
||||
phrase: "Achievement Get~!",
|
||||
volume: 0.8,
|
||||
},
|
||||
[NotificationType.COST_ALERT]: {
|
||||
type: NotificationType.COST_ALERT,
|
||||
filename: "oh-no.mp3",
|
||||
phrase: "Cost Alert!",
|
||||
volume: 0.9,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { agentStore, getAgentsForConversation, runningAgentCount } from "./agents";
|
||||
import { get } from "svelte/store";
|
||||
import type { AgentInfo } from "$lib/types/agents";
|
||||
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
|
||||
|
||||
describe("agents store", () => {
|
||||
const conversationId = "test-conversation-1";
|
||||
const otherConversationId = "test-conversation-2";
|
||||
|
||||
type AgentInput = Omit<AgentInfo, "characterName" | "characterAvatar">;
|
||||
|
||||
const createMockAgent = (overrides?: Partial<AgentInput>): AgentInput => ({
|
||||
toolUseId: "toolu_test123",
|
||||
description: "Test agent",
|
||||
subagentType: "Explore",
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear all conversations by subscribing and getting state
|
||||
let state: Record<string, AgentInfo[]> = {};
|
||||
const unsub = agentStore.subscribe((s) => {
|
||||
state = s;
|
||||
});
|
||||
unsub();
|
||||
|
||||
// Clear each conversation
|
||||
for (const convId of Object.keys(state)) {
|
||||
agentStore.clearConversation(convId);
|
||||
}
|
||||
});
|
||||
|
||||
describe("addAgent", () => {
|
||||
it("adds an agent to a conversation", () => {
|
||||
const agent = createMockAgent();
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents).toHaveLength(1);
|
||||
expect(agents[0]).toMatchObject(agent);
|
||||
});
|
||||
|
||||
it("assigns a character name and avatar to added agents", () => {
|
||||
const agent = createMockAgent();
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
const validNames = CHARACTER_POOL.map((c) => c.name);
|
||||
expect(validNames).toContain(agents[0].characterName);
|
||||
expect(agents[0].characterAvatar).toMatch(/^https:\/\//u);
|
||||
});
|
||||
|
||||
it("avoids duplicate character names across agents when possible", () => {
|
||||
// Add 6 agents - each should ideally get a unique character
|
||||
for (let i = 0; i < 6; i++) {
|
||||
agentStore.addAgent(conversationId, createMockAgent({ toolUseId: `tool${i.toString()}` }));
|
||||
}
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
const names = agents.map((a) => a.characterName);
|
||||
const uniqueNames = new Set(names);
|
||||
expect(uniqueNames.size).toBe(6);
|
||||
});
|
||||
|
||||
it("adds multiple agents to the same conversation", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents).toHaveLength(2);
|
||||
expect(agents[0]).toMatchObject(agent1);
|
||||
expect(agents[1]).toMatchObject(agent2);
|
||||
});
|
||||
|
||||
it("keeps agents in different conversations separate", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(otherConversationId, agent2);
|
||||
|
||||
const agents1 = get(getAgentsForConversation(conversationId));
|
||||
const agents2 = get(getAgentsForConversation(otherConversationId));
|
||||
|
||||
expect(agents1).toHaveLength(1);
|
||||
expect(agents2).toHaveLength(1);
|
||||
expect(agents1[0]).toMatchObject(agent1);
|
||||
expect(agents2[0]).toMatchObject(agent2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateAgentId", () => {
|
||||
it("updates the agent_id for a specific agent", () => {
|
||||
const agent = createMockAgent({ agentId: undefined });
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
agentStore.updateAgentId(conversationId, agent.toolUseId, "agent-abc123");
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].agentId).toBe("agent-abc123");
|
||||
});
|
||||
|
||||
it("does nothing if conversation doesn't exist", () => {
|
||||
agentStore.updateAgentId("nonexistent", "tool1", "agent1");
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing if tool_use_id doesn't exist", () => {
|
||||
const agent = createMockAgent();
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
agentStore.updateAgentId(conversationId, "nonexistent-tool", "agent1");
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].agentId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("endAgent", () => {
|
||||
it("marks an agent as completed", () => {
|
||||
const agent = createMockAgent({ status: "running" });
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
const endTime = Date.now();
|
||||
agentStore.endAgent(conversationId, agent.toolUseId, endTime, false);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].status).toBe("completed");
|
||||
expect(agents[0].endedAt).toBe(endTime);
|
||||
expect(agents[0].durationMs).toBeGreaterThanOrEqual(0); // Duration can be 0 if timestamps are the same
|
||||
});
|
||||
|
||||
it("marks an agent as errored", () => {
|
||||
const agent = createMockAgent({ status: "running" });
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
const endTime = Date.now();
|
||||
agentStore.endAgent(conversationId, agent.toolUseId, endTime, true);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].status).toBe("errored");
|
||||
expect(agents[0].endedAt).toBe(endTime);
|
||||
});
|
||||
|
||||
it("calculates duration correctly", () => {
|
||||
const startTime = Date.now() - 5000; // 5 seconds ago
|
||||
const agent = createMockAgent({ startedAt: startTime, status: "running" });
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
const endTime = Date.now();
|
||||
agentStore.endAgent(conversationId, agent.toolUseId, endTime, false);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].durationMs).toBeGreaterThanOrEqual(5000);
|
||||
expect(agents[0].durationMs).toBeLessThanOrEqual(6000); // Allow some buffer
|
||||
});
|
||||
|
||||
it("does nothing if conversation doesn't exist", () => {
|
||||
agentStore.endAgent("nonexistent", "tool1", Date.now(), false);
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing if agent doesn't exist", () => {
|
||||
const agent = createMockAgent();
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
agentStore.endAgent(conversationId, "nonexistent-tool", Date.now(), false);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].status).toBe("running"); // Status unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe("markAllErrored", () => {
|
||||
it("marks all running agents as errored", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
|
||||
const agent3 = createMockAgent({ toolUseId: "tool3", status: "completed" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
agentStore.addAgent(conversationId, agent3);
|
||||
|
||||
agentStore.markAllErrored(conversationId);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].status).toBe("errored");
|
||||
expect(agents[0].endedAt).toBeGreaterThan(0);
|
||||
expect(agents[1].status).toBe("errored");
|
||||
expect(agents[1].endedAt).toBeGreaterThan(0);
|
||||
expect(agents[2].status).toBe("completed"); // Already completed, unchanged
|
||||
});
|
||||
|
||||
it("does nothing if conversation doesn't exist", () => {
|
||||
agentStore.markAllErrored("nonexistent");
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing if conversation has no running agents", () => {
|
||||
const agent = createMockAgent({ status: "completed" });
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
agentStore.markAllErrored(conversationId);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].status).toBe("completed"); // Unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearCompleted", () => {
|
||||
it("removes completed and errored agents", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2", status: "completed" });
|
||||
const agent3 = createMockAgent({ toolUseId: "tool3", status: "errored" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
agentStore.addAgent(conversationId, agent3);
|
||||
|
||||
agentStore.clearCompleted(conversationId);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents).toHaveLength(1);
|
||||
expect(agents[0].toolUseId).toBe("tool1"); // Only running agent remains
|
||||
});
|
||||
|
||||
it("does nothing if conversation doesn't exist", () => {
|
||||
agentStore.clearCompleted("nonexistent");
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("clears all agents if all are completed", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1", status: "completed" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2", status: "errored" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
|
||||
agentStore.clearCompleted(conversationId);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearConversation", () => {
|
||||
it("removes all agents from a conversation", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
|
||||
agentStore.clearConversation(conversationId);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("only removes agents from the specified conversation", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(otherConversationId, agent2);
|
||||
|
||||
agentStore.clearConversation(conversationId);
|
||||
|
||||
const agents1 = get(getAgentsForConversation(conversationId));
|
||||
const agents2 = get(getAgentsForConversation(otherConversationId));
|
||||
|
||||
expect(agents1).toHaveLength(0);
|
||||
expect(agents2).toHaveLength(1);
|
||||
expect(agents2[0]).toMatchObject(agent2);
|
||||
});
|
||||
|
||||
it("does nothing if conversation doesn't exist", () => {
|
||||
agentStore.clearConversation("nonexistent");
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runningAgentCount", () => {
|
||||
it("counts running agents across all conversations", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
|
||||
const agent3 = createMockAgent({ toolUseId: "tool3", status: "completed" });
|
||||
const agent4 = createMockAgent({ toolUseId: "tool4", status: "running" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
agentStore.addAgent(conversationId, agent3);
|
||||
agentStore.addAgent(otherConversationId, agent4);
|
||||
|
||||
const count = get(runningAgentCount);
|
||||
expect(count).toBe(3); // 2 from first conversation + 1 from second
|
||||
});
|
||||
|
||||
it("returns 0 when no agents are running", () => {
|
||||
const agent1 = createMockAgent({ status: "completed" });
|
||||
const agent2 = createMockAgent({ status: "errored" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(otherConversationId, agent2);
|
||||
|
||||
const count = get(runningAgentCount);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("updates when agents complete", () => {
|
||||
const agent = createMockAgent({ status: "running" });
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
let count = get(runningAgentCount);
|
||||
expect(count).toBe(1);
|
||||
|
||||
agentStore.endAgent(conversationId, agent.toolUseId, Date.now(), false);
|
||||
|
||||
count = get(runningAgentCount);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("updates when conversation is cleared", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
|
||||
let count = get(runningAgentCount);
|
||||
expect(count).toBe(2);
|
||||
|
||||
agentStore.clearConversation(conversationId);
|
||||
|
||||
count = get(runningAgentCount);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import type { AgentInfo } from "$lib/types/agents";
|
||||
import { assignCharacter } from "$lib/utils/agentCharacters";
|
||||
|
||||
// Map of conversation ID -> agents in that conversation
|
||||
const agentsByConversation = writable<Record<string, AgentInfo[]>>({});
|
||||
|
||||
function createAgentStore() {
|
||||
return {
|
||||
subscribe: agentsByConversation.subscribe,
|
||||
|
||||
addAgent(conversationId: string, agent: Omit<AgentInfo, "characterName" | "characterAvatar">) {
|
||||
agentsByConversation.update((state) => {
|
||||
const existing = state[conversationId] || [];
|
||||
const activeNames = existing.map((a) => a.characterName);
|
||||
const character = assignCharacter(activeNames);
|
||||
return {
|
||||
...state,
|
||||
[conversationId]: [
|
||||
...existing,
|
||||
{ ...agent, characterName: character.name, characterAvatar: character.avatar },
|
||||
],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
updateAgentId(conversationId: string, toolUseId: string, agentId: string) {
|
||||
agentsByConversation.update((state) => {
|
||||
const agents = state[conversationId];
|
||||
if (!agents) return state;
|
||||
|
||||
const agentIndex = agents.findIndex((a) => a.toolUseId === toolUseId);
|
||||
if (agentIndex === -1) return state;
|
||||
|
||||
const updated = [...agents];
|
||||
updated[agentIndex] = {
|
||||
...updated[agentIndex],
|
||||
agentId,
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
[conversationId]: updated,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
endAgent(conversationId: string, toolUseId: string, endedAt: number, isError: boolean) {
|
||||
agentsByConversation.update((state) => {
|
||||
const agents = state[conversationId];
|
||||
if (!agents) return state;
|
||||
|
||||
const agentIndex = agents.findIndex((a) => a.toolUseId === toolUseId);
|
||||
if (agentIndex === -1) return state;
|
||||
|
||||
const updated = [...agents];
|
||||
const agent = updated[agentIndex];
|
||||
const durationMs = endedAt - agent.startedAt;
|
||||
|
||||
updated[agentIndex] = {
|
||||
...agent,
|
||||
endedAt,
|
||||
status: isError ? "errored" : "completed",
|
||||
durationMs,
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
[conversationId]: updated,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
markAllErrored(conversationId: string) {
|
||||
agentsByConversation.update((state) => {
|
||||
const agents = state[conversationId];
|
||||
if (!agents) return state;
|
||||
|
||||
const now = Date.now();
|
||||
const updated = agents.map((agent) =>
|
||||
agent.status === "running"
|
||||
? { ...agent, endedAt: now, status: "errored" as const }
|
||||
: agent
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
[conversationId]: updated,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearCompleted(conversationId: string) {
|
||||
agentsByConversation.update((state) => {
|
||||
const agents = state[conversationId];
|
||||
if (!agents) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
[conversationId]: agents.filter((a) => a.status === "running"),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearConversation(conversationId: string) {
|
||||
agentsByConversation.update((state) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Unused destructured value
|
||||
const { [conversationId]: _, ...rest } = state;
|
||||
return rest;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const agentStore = createAgentStore();
|
||||
|
||||
export function getAgentsForConversation(conversationId: string) {
|
||||
return derived(agentsByConversation, ($state) => $state[conversationId] || []);
|
||||
}
|
||||
|
||||
export const runningAgentCount = derived(agentsByConversation, ($state) => {
|
||||
let count = 0;
|
||||
for (const agents of Object.values($state)) {
|
||||
count += agents.filter((a) => a.status === "running").length;
|
||||
}
|
||||
return count;
|
||||
});
|
||||
@@ -101,10 +101,7 @@ export const claudeStore = {
|
||||
|
||||
export const hasPermissionPending = derived(
|
||||
claudeStore.activeConversation,
|
||||
($conversation) =>
|
||||
$conversation?.pendingPermissions !== null &&
|
||||
$conversation?.pendingPermissions !== undefined &&
|
||||
$conversation.pendingPermissions.length > 0
|
||||
($conversation) => $conversation?.pendingPermission !== null
|
||||
);
|
||||
|
||||
export const hasQuestionPending = derived(
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
type Theme,
|
||||
type CustomThemeColors,
|
||||
} from "./config";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
// Mock Tauri APIs
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
@@ -168,6 +167,7 @@ describe("config store", () => {
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: false,
|
||||
minimize_to_tray: true,
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: 300,
|
||||
font_size: 14,
|
||||
@@ -187,13 +187,6 @@ describe("config store", () => {
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
budget_enabled: false,
|
||||
session_token_budget: null,
|
||||
session_cost_budget: null,
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
};
|
||||
|
||||
expect(config.model).toBe("claude-sonnet-4");
|
||||
@@ -214,6 +207,7 @@ describe("config store", () => {
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: false,
|
||||
minimize_to_tray: false,
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: null,
|
||||
font_size: 14,
|
||||
@@ -233,13 +227,6 @@ describe("config store", () => {
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
budget_enabled: false,
|
||||
session_token_budget: null,
|
||||
session_cost_budget: null,
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
};
|
||||
|
||||
expect(config.model).toBeNull();
|
||||
@@ -490,339 +477,4 @@ describe("config store", () => {
|
||||
expect(typeof configStore.saveError.subscribe).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Race Condition Tests", () => {
|
||||
beforeEach(async () => {
|
||||
// Setup mock to return a default config for load_config
|
||||
const mockInvokeImpl = vi.mocked(invoke);
|
||||
mockInvokeImpl.mockResolvedValue({
|
||||
model: null,
|
||||
api_key: null,
|
||||
custom_instructions: null,
|
||||
mcp_servers_json: null,
|
||||
auto_granted_tools: [],
|
||||
theme: "dark",
|
||||
greeting_enabled: false,
|
||||
greeting_custom_prompt: null,
|
||||
notifications_enabled: false,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: false,
|
||||
update_checks_enabled: false,
|
||||
character_panel_width: null,
|
||||
font_size: 14,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
profile_name: null,
|
||||
profile_avatar_path: null,
|
||||
profile_bio: null,
|
||||
custom_theme_colors: {
|
||||
bg_primary: null,
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
budget_enabled: false,
|
||||
session_token_budget: null,
|
||||
session_cost_budget: null,
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: false,
|
||||
});
|
||||
|
||||
// Load initial config
|
||||
await configStore.loadConfig();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("handles rapid sequential config updates correctly", async () => {
|
||||
// This test validates the fix for the config race condition that caused data loss
|
||||
const mockInvokeImpl = vi.mocked(invoke);
|
||||
const invokeCalls: Array<{ command: string; config: HikariConfig }> = [];
|
||||
|
||||
mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => {
|
||||
if (command === "save_config" && args && typeof args === "object" && "config" in args) {
|
||||
invokeCalls.push({ command, config: args.config as HikariConfig });
|
||||
// Simulate small delay in saving
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Perform rapid updates
|
||||
await Promise.all([
|
||||
configStore.updateConfig({ font_size: 16 }),
|
||||
configStore.updateConfig({ theme: "light" }),
|
||||
configStore.updateConfig({ compact_mode: true }),
|
||||
]);
|
||||
|
||||
// All three updates should have been saved
|
||||
expect(invokeCalls.length).toBe(3);
|
||||
|
||||
// Get final config
|
||||
const finalConfig = configStore.getConfig();
|
||||
|
||||
// Final config should have all updates
|
||||
// Note: The last update wins for each field, but all fields should be preserved
|
||||
expect(finalConfig.compact_mode).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves previous field values during concurrent updates", async () => {
|
||||
// Set initial values
|
||||
await configStore.updateConfig({
|
||||
font_size: 16,
|
||||
theme: "dark",
|
||||
compact_mode: false,
|
||||
streamer_mode: false,
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
const mockInvokeImpl = vi.mocked(invoke);
|
||||
const invokeCalls: Array<{ command: string; config: HikariConfig }> = [];
|
||||
|
||||
mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => {
|
||||
if (command === "save_config" && args && typeof args === "object" && "config" in args) {
|
||||
invokeCalls.push({ command, config: args.config as HikariConfig });
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Update different fields concurrently
|
||||
await Promise.all([
|
||||
configStore.updateConfig({ font_size: 18 }),
|
||||
configStore.updateConfig({ theme: "light" }),
|
||||
configStore.updateConfig({ compact_mode: true }),
|
||||
]);
|
||||
|
||||
// Check that each save included all previous config values
|
||||
invokeCalls.forEach((call) => {
|
||||
// Each save should have a complete config, not just the updated field
|
||||
expect(call.config).toHaveProperty("font_size");
|
||||
expect(call.config).toHaveProperty("theme");
|
||||
expect(call.config).toHaveProperty("compact_mode");
|
||||
expect(call.config).toHaveProperty("streamer_mode");
|
||||
expect(call.config).toHaveProperty("model");
|
||||
expect(call.config).toHaveProperty("api_key");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles update during save operation", async () => {
|
||||
const mockInvokeImpl = vi.mocked(invoke);
|
||||
let firstSaveStarted = false;
|
||||
let firstSaveCompleted = false;
|
||||
|
||||
mockInvokeImpl.mockImplementation(async (command: string) => {
|
||||
if (command === "save_config") {
|
||||
if (!firstSaveStarted) {
|
||||
firstSaveStarted = true;
|
||||
// Simulate slow save
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
firstSaveCompleted = true;
|
||||
} else {
|
||||
// Second save starts while first is in progress
|
||||
expect(firstSaveStarted).toBe(true);
|
||||
// First save might not be complete yet (race condition scenario)
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Start first update
|
||||
const firstUpdate = configStore.updateConfig({ font_size: 16 });
|
||||
|
||||
// Wait a bit then start second update whilst first is still saving
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const secondUpdate = configStore.updateConfig({ theme: "light" });
|
||||
|
||||
// Wait for both to complete
|
||||
await Promise.all([firstUpdate, secondUpdate]);
|
||||
|
||||
// Both should complete successfully without errors
|
||||
expect(firstSaveCompleted).toBe(true);
|
||||
});
|
||||
|
||||
it("getConfig returns most recently set configuration", async () => {
|
||||
await configStore.updateConfig({ font_size: 14 });
|
||||
expect(configStore.getConfig().font_size).toBe(14);
|
||||
|
||||
await configStore.updateConfig({ font_size: 16 });
|
||||
expect(configStore.getConfig().font_size).toBe(16);
|
||||
|
||||
await configStore.updateConfig({ font_size: 18 });
|
||||
expect(configStore.getConfig().font_size).toBe(18);
|
||||
});
|
||||
|
||||
it("updates do not lose data from previous operations", async () => {
|
||||
// Set multiple fields
|
||||
await configStore.updateConfig({
|
||||
font_size: 16,
|
||||
theme: "dark",
|
||||
compact_mode: true,
|
||||
streamer_mode: true,
|
||||
model: "claude-sonnet-4",
|
||||
});
|
||||
|
||||
// Update just one field
|
||||
await configStore.updateConfig({ theme: "light" });
|
||||
|
||||
// Other fields should be preserved
|
||||
const config = configStore.getConfig();
|
||||
expect(config.theme).toBe("light");
|
||||
expect(config.font_size).toBe(16);
|
||||
expect(config.compact_mode).toBe(true);
|
||||
expect(config.streamer_mode).toBe(true);
|
||||
expect(config.model).toBe("claude-sonnet-4");
|
||||
});
|
||||
|
||||
it("auto granted tools are not lost during other updates", async () => {
|
||||
// Add some tools
|
||||
await configStore.addAutoGrantedTool("Read");
|
||||
await configStore.addAutoGrantedTool("Write");
|
||||
|
||||
expect(configStore.getConfig().auto_granted_tools).toContain("Read");
|
||||
expect(configStore.getConfig().auto_granted_tools).toContain("Write");
|
||||
|
||||
// Update another field
|
||||
await configStore.updateConfig({ theme: "light" });
|
||||
|
||||
// Tools should still be there
|
||||
expect(configStore.getConfig().auto_granted_tools).toContain("Read");
|
||||
expect(configStore.getConfig().auto_granted_tools).toContain("Write");
|
||||
});
|
||||
|
||||
it("custom theme colors persist across other config updates", async () => {
|
||||
const customColors: CustomThemeColors = {
|
||||
bg_primary: "#1a1a2e",
|
||||
bg_secondary: "#16213e",
|
||||
bg_terminal: "#0f0f23",
|
||||
accent_primary: "#e94560",
|
||||
accent_secondary: "#533483",
|
||||
text_primary: "#eaeaea",
|
||||
text_secondary: "#a0a0a0",
|
||||
border_color: "#333355",
|
||||
};
|
||||
|
||||
await configStore.setCustomThemeColors(customColors);
|
||||
|
||||
// Update another field
|
||||
await configStore.updateConfig({ font_size: 18 });
|
||||
|
||||
// Colors should still be there
|
||||
const config = configStore.getConfig();
|
||||
expect(config.custom_theme_colors.bg_primary).toBe("#1a1a2e");
|
||||
expect(config.custom_theme_colors.accent_primary).toBe("#e94560");
|
||||
});
|
||||
|
||||
it("handles save errors gracefully without losing data", async () => {
|
||||
const mockInvokeImpl = vi.mocked(invoke);
|
||||
|
||||
// Mock console.error to suppress expected error output
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Set initial config
|
||||
await configStore.updateConfig({ font_size: 14 });
|
||||
|
||||
// Make next save fail
|
||||
mockInvokeImpl.mockRejectedValueOnce(new Error("Save failed"));
|
||||
|
||||
// Try to update - should throw
|
||||
await expect(configStore.updateConfig({ theme: "light" })).rejects.toThrow();
|
||||
|
||||
// Original config should still be accessible
|
||||
expect(configStore.getConfig().font_size).toBe(14);
|
||||
|
||||
// Verify error was logged
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to save config:", expect.any(Error));
|
||||
|
||||
// Restore console.error
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Config Persistence Tests", () => {
|
||||
it("loadConfig retrieves saved configuration", async () => {
|
||||
const mockConfig: HikariConfig = {
|
||||
model: "claude-sonnet-4",
|
||||
api_key: "test-key",
|
||||
custom_instructions: "Be helpful",
|
||||
mcp_servers_json: "{}",
|
||||
auto_granted_tools: ["Read", "Write"],
|
||||
theme: "light",
|
||||
greeting_enabled: false,
|
||||
greeting_custom_prompt: null,
|
||||
notifications_enabled: false,
|
||||
notification_volume: 0.5,
|
||||
always_on_top: true,
|
||||
update_checks_enabled: false,
|
||||
character_panel_width: 400,
|
||||
font_size: 18,
|
||||
streamer_mode: true,
|
||||
streamer_hide_paths: true,
|
||||
compact_mode: true,
|
||||
profile_name: "Test User",
|
||||
profile_avatar_path: "/test/avatar.png",
|
||||
profile_bio: "Test bio",
|
||||
custom_theme_colors: {
|
||||
bg_primary: null,
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
budget_enabled: true,
|
||||
session_token_budget: 100000,
|
||||
session_cost_budget: 1.5,
|
||||
budget_action: "block",
|
||||
budget_warning_threshold: 0.9,
|
||||
discord_rpc_enabled: false,
|
||||
show_thinking_blocks: true,
|
||||
};
|
||||
|
||||
const mockInvokeImpl = vi.mocked(invoke);
|
||||
mockInvokeImpl.mockResolvedValueOnce(mockConfig);
|
||||
|
||||
await configStore.loadConfig();
|
||||
|
||||
const loadedConfig = configStore.getConfig();
|
||||
expect(loadedConfig.model).toBe("claude-sonnet-4");
|
||||
expect(loadedConfig.theme).toBe("light");
|
||||
expect(loadedConfig.font_size).toBe(18);
|
||||
expect(loadedConfig.auto_granted_tools).toEqual(["Read", "Write"]);
|
||||
});
|
||||
|
||||
it("saveConfig persists configuration to backend", async () => {
|
||||
const mockInvokeImpl = vi.mocked(invoke);
|
||||
const savedConfigs: HikariConfig[] = [];
|
||||
|
||||
mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => {
|
||||
if (command === "save_config" && args && typeof args === "object" && "config" in args) {
|
||||
savedConfigs.push(args.config as HikariConfig);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const configToSave: Partial<HikariConfig> = {
|
||||
model: "claude-sonnet-4",
|
||||
theme: "dark",
|
||||
font_size: 16,
|
||||
};
|
||||
|
||||
await configStore.updateConfig(configToSave);
|
||||
|
||||
expect(savedConfigs.length).toBeGreaterThan(0);
|
||||
const lastSaved = savedConfigs[savedConfigs.length - 1];
|
||||
expect(lastSaved.model).toBe("claude-sonnet-4");
|
||||
expect(lastSaved.theme).toBe("dark");
|
||||
expect(lastSaved.font_size).toBe(16);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+23
-40
@@ -2,7 +2,6 @@ import { writable, derived } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export type Theme = "dark" | "light" | "high-contrast" | "custom";
|
||||
export type BudgetAction = "warn" | "block";
|
||||
|
||||
export interface CustomThemeColors {
|
||||
bg_primary: string | null;
|
||||
@@ -27,6 +26,7 @@ export interface HikariConfig {
|
||||
notifications_enabled: boolean;
|
||||
notification_volume: number;
|
||||
always_on_top: boolean;
|
||||
minimize_to_tray: boolean;
|
||||
update_checks_enabled: boolean;
|
||||
character_panel_width: number | null;
|
||||
font_size: number;
|
||||
@@ -37,16 +37,6 @@ export interface HikariConfig {
|
||||
profile_avatar_path: string | null;
|
||||
profile_bio: string | null;
|
||||
custom_theme_colors: CustomThemeColors;
|
||||
// Budget settings
|
||||
budget_enabled: boolean;
|
||||
session_token_budget: number | null;
|
||||
session_cost_budget: number | null;
|
||||
budget_action: BudgetAction;
|
||||
budget_warning_threshold: number;
|
||||
// Discord RPC settings
|
||||
discord_rpc_enabled: boolean;
|
||||
// Thinking blocks settings
|
||||
show_thinking_blocks: boolean;
|
||||
}
|
||||
|
||||
const defaultConfig: HikariConfig = {
|
||||
@@ -61,6 +51,7 @@ const defaultConfig: HikariConfig = {
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: false,
|
||||
minimize_to_tray: false,
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: null,
|
||||
font_size: 14,
|
||||
@@ -80,13 +71,6 @@ const defaultConfig: HikariConfig = {
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
budget_enabled: false,
|
||||
session_token_budget: null,
|
||||
session_cost_budget: null,
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
};
|
||||
|
||||
function createConfigStore() {
|
||||
@@ -95,14 +79,6 @@ function createConfigStore() {
|
||||
const isSidebarOpen = writable<boolean>(false);
|
||||
const saveError = writable<string | null>(null);
|
||||
|
||||
// Internal function to get current config synchronously
|
||||
function getCurrentConfig(): HikariConfig {
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
const unsubscribe = config.subscribe((c) => (currentConfig = c));
|
||||
unsubscribe();
|
||||
return currentConfig;
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
isLoading.set(true);
|
||||
try {
|
||||
@@ -130,7 +106,8 @@ function createConfigStore() {
|
||||
}
|
||||
|
||||
async function updateConfig(updates: Partial<HikariConfig>) {
|
||||
const currentConfig = getCurrentConfig();
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
const newConfig = { ...currentConfig, ...updates };
|
||||
await saveConfig(newConfig);
|
||||
}
|
||||
@@ -155,13 +132,15 @@ function createConfigStore() {
|
||||
updates.custom_theme_colors = customColors;
|
||||
}
|
||||
await updateConfig(updates);
|
||||
const currentConfig = getCurrentConfig();
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
applyTheme(theme, currentConfig.custom_theme_colors);
|
||||
},
|
||||
|
||||
setCustomThemeColors: async (colors: CustomThemeColors) => {
|
||||
await updateConfig({ custom_theme_colors: colors });
|
||||
const currentConfig = getCurrentConfig();
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
if (currentConfig.theme === "custom") {
|
||||
applyCustomThemeColors(colors);
|
||||
}
|
||||
@@ -174,14 +153,16 @@ function createConfigStore() {
|
||||
},
|
||||
|
||||
increaseFontSize: async () => {
|
||||
const currentConfig = getCurrentConfig();
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2);
|
||||
await updateConfig({ font_size: newSize });
|
||||
applyFontSize(newSize);
|
||||
},
|
||||
|
||||
decreaseFontSize: async () => {
|
||||
const currentConfig = getCurrentConfig();
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2);
|
||||
await updateConfig({ font_size: newSize });
|
||||
applyFontSize(newSize);
|
||||
@@ -193,7 +174,8 @@ function createConfigStore() {
|
||||
},
|
||||
|
||||
addAutoGrantedTool: async (tool: string) => {
|
||||
const currentConfig = getCurrentConfig();
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
if (!currentConfig.auto_granted_tools.includes(tool)) {
|
||||
const newTools = [...currentConfig.auto_granted_tools, tool];
|
||||
await updateConfig({ auto_granted_tools: newTools });
|
||||
@@ -201,22 +183,27 @@ function createConfigStore() {
|
||||
},
|
||||
|
||||
removeAutoGrantedTool: async (tool: string) => {
|
||||
const currentConfig = getCurrentConfig();
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
const newTools = currentConfig.auto_granted_tools.filter((t) => t !== tool);
|
||||
await updateConfig({ auto_granted_tools: newTools });
|
||||
},
|
||||
|
||||
getConfig: (): HikariConfig => {
|
||||
return getCurrentConfig();
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
return currentConfig;
|
||||
},
|
||||
|
||||
toggleStreamerMode: async () => {
|
||||
const currentConfig = getCurrentConfig();
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
await updateConfig({ streamer_mode: !currentConfig.streamer_mode });
|
||||
},
|
||||
|
||||
toggleCompactMode: async () => {
|
||||
const currentConfig = getCurrentConfig();
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
await updateConfig({ compact_mode: !currentConfig.compact_mode });
|
||||
},
|
||||
|
||||
@@ -300,10 +287,6 @@ export const shouldHidePaths = derived(
|
||||
configStore.config,
|
||||
($config) => $config.streamer_mode && $config.streamer_hide_paths
|
||||
);
|
||||
export const showThinkingBlocks = derived(
|
||||
configStore.config,
|
||||
($config) => $config.show_thinking_blocks
|
||||
);
|
||||
|
||||
/**
|
||||
* Masks file paths in text when streamer mode with hide paths is enabled.
|
||||
|
||||
@@ -10,14 +10,6 @@ import type { CharacterState } from "$lib/types/states";
|
||||
import { cleanupConversationTracking } from "$lib/tauri";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { sessionsStore } from "$lib/stores/sessions";
|
||||
import { agentStore } from "$lib/stores/agents";
|
||||
|
||||
export interface ConversationSummary {
|
||||
generatedAt: Date;
|
||||
content: string;
|
||||
messageCount: number;
|
||||
tokenEstimate: number;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
@@ -29,14 +21,12 @@ export interface Conversation {
|
||||
characterState: CharacterState;
|
||||
isProcessing: boolean;
|
||||
grantedTools: Set<string>;
|
||||
pendingPermissions: PermissionRequest[];
|
||||
pendingPermission: PermissionRequest | null;
|
||||
pendingQuestion: UserQuestionEvent | null;
|
||||
scrollPosition: number;
|
||||
createdAt: Date;
|
||||
lastActivityAt: Date;
|
||||
attachments: Attachment[];
|
||||
summary: ConversationSummary | null;
|
||||
startedAt: Date;
|
||||
}
|
||||
|
||||
function createConversationsStore() {
|
||||
@@ -67,14 +57,12 @@ function createConversationsStore() {
|
||||
characterState: "idle",
|
||||
isProcessing: false,
|
||||
grantedTools: new Set(),
|
||||
pendingPermissions: [],
|
||||
pendingPermission: null,
|
||||
pendingQuestion: null,
|
||||
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
attachments: [],
|
||||
summary: null,
|
||||
startedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,11 +109,7 @@ function createConversationsStore() {
|
||||
);
|
||||
const pendingPermission = derived(
|
||||
activeConversation,
|
||||
($conv) => $conv?.pendingPermissions[0] || null
|
||||
);
|
||||
const pendingPermissions = derived(
|
||||
activeConversation,
|
||||
($conv) => $conv?.pendingPermissions || []
|
||||
($conv) => $conv?.pendingPermission || null
|
||||
);
|
||||
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
|
||||
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
|
||||
@@ -138,7 +122,6 @@ function createConversationsStore() {
|
||||
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
|
||||
terminalLines: { subscribe: terminalLines.subscribe },
|
||||
pendingPermission: { subscribe: pendingPermission.subscribe },
|
||||
pendingPermissions: { subscribe: pendingPermissions.subscribe },
|
||||
pendingQuestion: { subscribe: pendingQuestion.subscribe },
|
||||
isProcessing: { subscribe: isProcessing.subscribe },
|
||||
grantedTools: { subscribe: grantedTools.subscribe },
|
||||
@@ -196,7 +179,7 @@ function createConversationsStore() {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.pendingPermissions.push(request);
|
||||
conv.pendingPermission = request;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
@@ -209,7 +192,7 @@ function createConversationsStore() {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.pendingPermissions = [];
|
||||
conv.pendingPermission = null;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
@@ -219,7 +202,7 @@ function createConversationsStore() {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.pendingPermissions.push(request);
|
||||
conv.pendingPermission = request;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
@@ -229,30 +212,7 @@ function createConversationsStore() {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.pendingPermissions = [];
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
removePermission: (id: string) => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.pendingPermissions = conv.pendingPermissions.filter((p) => p.id !== id);
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
removePermissionForConversation: (conversationId: string, id: string) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.pendingPermissions = conv.pendingPermissions.filter((p) => p.id !== id);
|
||||
conv.pendingPermission = null;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
@@ -334,10 +294,6 @@ function createConversationsStore() {
|
||||
// Clean up tracking for this conversation (including temp files)
|
||||
await cleanupConversationTracking(id);
|
||||
|
||||
// Clean up agent tracking for this conversation
|
||||
// This prevents the badge from persisting after tab close
|
||||
agentStore.clearConversation(id);
|
||||
|
||||
conversations.update((c) => {
|
||||
c.delete(id);
|
||||
return c;
|
||||
@@ -464,13 +420,7 @@ function createConversationsStore() {
|
||||
});
|
||||
},
|
||||
|
||||
addLine: (
|
||||
type: TerminalLine["type"],
|
||||
content: string,
|
||||
toolName?: string,
|
||||
cost?: TerminalLine["cost"],
|
||||
parentToolUseId?: string
|
||||
) => {
|
||||
addLine: (type: TerminalLine["type"], content: string, toolName?: string) => {
|
||||
ensureInitialized();
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return "";
|
||||
@@ -481,8 +431,6 @@ function createConversationsStore() {
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
toolName,
|
||||
cost,
|
||||
parentToolUseId,
|
||||
};
|
||||
|
||||
conversations.update((convs) => {
|
||||
@@ -503,9 +451,7 @@ function createConversationsStore() {
|
||||
conversationId: string,
|
||||
type: TerminalLine["type"],
|
||||
content: string,
|
||||
toolName?: string,
|
||||
cost?: TerminalLine["cost"],
|
||||
parentToolUseId?: string
|
||||
toolName?: string
|
||||
) => {
|
||||
ensureInitialized();
|
||||
|
||||
@@ -515,8 +461,6 @@ function createConversationsStore() {
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
toolName,
|
||||
cost,
|
||||
parentToolUseId,
|
||||
};
|
||||
|
||||
conversations.update((convs) => {
|
||||
@@ -692,130 +636,6 @@ function createConversationsStore() {
|
||||
return conv?.attachments || [];
|
||||
},
|
||||
|
||||
// Summary/compaction functions
|
||||
setSummary: (conversationId: string, summary: ConversationSummary) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.summary = summary;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
clearSummary: (conversationId: string) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.summary = null;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
getSummary: (conversationId: string): ConversationSummary | null => {
|
||||
const convs = get(conversations);
|
||||
const conv = convs.get(conversationId);
|
||||
return conv?.summary || null;
|
||||
},
|
||||
|
||||
// Estimate token count for a conversation (rough approximation: ~4 chars per token)
|
||||
estimateTokenCount: (conversationId: string): number => {
|
||||
const convs = get(conversations);
|
||||
const conv = convs.get(conversationId);
|
||||
if (!conv) return 0;
|
||||
|
||||
const relevantLines = conv.terminalLines.filter(
|
||||
(line) => line.type === "user" || line.type === "assistant"
|
||||
);
|
||||
|
||||
const totalChars = relevantLines.reduce((sum, line) => sum + line.content.length, 0);
|
||||
return Math.ceil(totalChars / 4);
|
||||
},
|
||||
|
||||
// Get conversation content suitable for summarisation
|
||||
getConversationForSummary: (conversationId: string): string => {
|
||||
const convs = get(conversations);
|
||||
const conv = convs.get(conversationId);
|
||||
if (!conv) return "";
|
||||
|
||||
const relevantLines = conv.terminalLines.filter(
|
||||
(line) => line.type === "user" || line.type === "assistant"
|
||||
);
|
||||
|
||||
return relevantLines
|
||||
.map((line) => {
|
||||
const role = line.type === "user" ? "User" : "Assistant";
|
||||
return `${role}: ${line.content}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
},
|
||||
|
||||
// Compact conversation by keeping only recent messages
|
||||
compactConversation: (conversationId: string, keepRecentCount: number = 10) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv && conv.terminalLines.length > keepRecentCount) {
|
||||
// Keep system messages and the most recent user/assistant messages
|
||||
const systemLines = conv.terminalLines.filter(
|
||||
(line) => line.type !== "user" && line.type !== "assistant"
|
||||
);
|
||||
const chatLines = conv.terminalLines.filter(
|
||||
(line) => line.type === "user" || line.type === "assistant"
|
||||
);
|
||||
|
||||
// Keep only the most recent chat messages
|
||||
const recentChatLines = chatLines.slice(-keepRecentCount);
|
||||
|
||||
// Combine: system lines at original positions + recent chat lines
|
||||
conv.terminalLines = [...systemLines.slice(-5), ...recentChatLines];
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
// Compact conversation with a summary - clears old messages and injects summary context
|
||||
compactWithSummary: (
|
||||
conversationId: string,
|
||||
summaryContent: string,
|
||||
messageCount: number,
|
||||
tokenEstimate: number
|
||||
) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
// Store the summary
|
||||
conv.summary = {
|
||||
generatedAt: new Date(),
|
||||
content: summaryContent,
|
||||
messageCount,
|
||||
tokenEstimate,
|
||||
};
|
||||
|
||||
// Clear all messages and add a context injection message
|
||||
conv.terminalLines = [
|
||||
{
|
||||
id: generateLineId(),
|
||||
type: "system",
|
||||
content: `[Conversation compacted] Previous session had ${messageCount} messages (~${tokenEstimate.toLocaleString()} tokens). Context preserved below.`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
{
|
||||
id: generateLineId(),
|
||||
type: "system",
|
||||
content: `Previous Session Context:\n${summaryContent}`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
];
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
// Add initialization helper
|
||||
initialize: () => {
|
||||
ensureInitialized();
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { notificationManager } from "$lib/notifications/notificationManager";
|
||||
|
||||
// Types matching Rust backend
|
||||
export interface DailyCost {
|
||||
date: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cost_usd: number;
|
||||
messages_sent: number;
|
||||
sessions_count: number;
|
||||
}
|
||||
|
||||
export interface CostSummary {
|
||||
period_days: number;
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_cost: number;
|
||||
total_messages: number;
|
||||
total_sessions: number;
|
||||
average_daily_cost: number;
|
||||
daily_breakdown: DailyCost[];
|
||||
}
|
||||
|
||||
export type AlertType = "Daily" | "Weekly" | "Monthly";
|
||||
|
||||
export interface CostAlert {
|
||||
alert_type: AlertType;
|
||||
threshold: number;
|
||||
current_cost: number;
|
||||
}
|
||||
|
||||
export interface CostAlertThresholds {
|
||||
daily: number | null;
|
||||
weekly: number | null;
|
||||
monthly: number | null;
|
||||
}
|
||||
|
||||
// Store state
|
||||
interface CostTrackingState {
|
||||
todayCost: number;
|
||||
weekCost: number;
|
||||
monthCost: number;
|
||||
summary: CostSummary | null;
|
||||
alerts: CostAlert[];
|
||||
thresholds: CostAlertThresholds;
|
||||
isLoading: boolean;
|
||||
lastUpdated: Date | null;
|
||||
}
|
||||
|
||||
const defaultState: CostTrackingState = {
|
||||
todayCost: 0,
|
||||
weekCost: 0,
|
||||
monthCost: 0,
|
||||
summary: null,
|
||||
alerts: [],
|
||||
thresholds: { daily: null, weekly: null, monthly: null },
|
||||
isLoading: false,
|
||||
lastUpdated: null,
|
||||
};
|
||||
|
||||
function createCostTrackingStore() {
|
||||
const { subscribe, set, update } = writable<CostTrackingState>(defaultState);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
async refresh() {
|
||||
update((s) => ({ ...s, isLoading: true }));
|
||||
|
||||
try {
|
||||
const [todayCost, weekCost, monthCost, alerts] = await Promise.all([
|
||||
invoke<number>("get_today_cost"),
|
||||
invoke<number>("get_week_cost"),
|
||||
invoke<number>("get_month_cost"),
|
||||
invoke<CostAlert[]>("get_cost_alerts"),
|
||||
]);
|
||||
|
||||
update((s) => ({
|
||||
...s,
|
||||
todayCost,
|
||||
weekCost,
|
||||
monthCost,
|
||||
alerts,
|
||||
isLoading: false,
|
||||
lastUpdated: new Date(),
|
||||
}));
|
||||
|
||||
// Trigger notifications for any new alerts
|
||||
if (alerts.length > 0) {
|
||||
for (const alert of alerts) {
|
||||
const message = getAlertMessage(alert);
|
||||
notificationManager.notifyCostAlert(message);
|
||||
}
|
||||
}
|
||||
|
||||
return alerts;
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh cost tracking:", error);
|
||||
update((s) => ({ ...s, isLoading: false }));
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async getSummary(days: number): Promise<CostSummary | null> {
|
||||
try {
|
||||
const summary = await invoke<CostSummary>("get_cost_summary", { days });
|
||||
update((s) => ({ ...s, summary }));
|
||||
return summary;
|
||||
} catch (error) {
|
||||
console.error("Failed to get cost summary:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async setAlertThresholds(thresholds: CostAlertThresholds) {
|
||||
try {
|
||||
await invoke("set_cost_alert_thresholds", {
|
||||
daily: thresholds.daily,
|
||||
weekly: thresholds.weekly,
|
||||
monthly: thresholds.monthly,
|
||||
});
|
||||
update((s) => ({ ...s, thresholds }));
|
||||
} catch (error) {
|
||||
console.error("Failed to set alert thresholds:", error);
|
||||
}
|
||||
},
|
||||
|
||||
async exportCsv(days: number): Promise<string | null> {
|
||||
try {
|
||||
return await invoke<string>("export_cost_csv", { days });
|
||||
} catch (error) {
|
||||
console.error("Failed to export CSV:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
set(defaultState);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const costTrackingStore = createCostTrackingStore();
|
||||
|
||||
// Derived stores for formatted values
|
||||
export const formattedCosts = derived(costTrackingStore, ($store) => ({
|
||||
today: formatCost($store.todayCost),
|
||||
week: formatCost($store.weekCost),
|
||||
month: formatCost($store.monthCost),
|
||||
todayRaw: $store.todayCost,
|
||||
weekRaw: $store.weekCost,
|
||||
monthRaw: $store.monthCost,
|
||||
}));
|
||||
|
||||
// Helper functions
|
||||
export function formatCost(cost: number): string {
|
||||
if (cost < 0.01) {
|
||||
return `$${cost.toFixed(4)}`;
|
||||
}
|
||||
if (cost < 1) {
|
||||
return `$${cost.toFixed(3)}`;
|
||||
}
|
||||
return `$${cost.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function formatAlertType(type: AlertType): string {
|
||||
switch (type) {
|
||||
case "Daily":
|
||||
return "Today";
|
||||
case "Weekly":
|
||||
return "This Week";
|
||||
case "Monthly":
|
||||
return "This Month";
|
||||
}
|
||||
}
|
||||
|
||||
export function getAlertMessage(alert: CostAlert): string {
|
||||
const period = formatAlertType(alert.alert_type);
|
||||
return `${period}'s spending (${formatCost(alert.current_cost)}) has exceeded your ${formatCost(alert.threshold)} threshold`;
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
export interface LogEntry {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
source: "frontend" | "backend";
|
||||
}
|
||||
|
||||
interface DebugConsoleState {
|
||||
logs: LogEntry[];
|
||||
isOpen: boolean;
|
||||
maxLogs: number;
|
||||
filterLevel: LogLevel | "all";
|
||||
autoScroll: boolean;
|
||||
}
|
||||
|
||||
const MAX_LOGS = 1000; // Circular buffer size
|
||||
|
||||
function createDebugConsoleStore() {
|
||||
const { subscribe, update } = writable<DebugConsoleState>({
|
||||
logs: [],
|
||||
isOpen: false,
|
||||
maxLogs: MAX_LOGS,
|
||||
filterLevel: "all",
|
||||
autoScroll: true,
|
||||
});
|
||||
|
||||
let logCounter = 0;
|
||||
|
||||
function addLog(level: LogLevel, message: string, source: "frontend" | "backend") {
|
||||
update((state) => {
|
||||
const newLog: LogEntry = {
|
||||
id: `log-${Date.now()}-${logCounter++}`,
|
||||
timestamp: new Date(),
|
||||
level,
|
||||
message,
|
||||
source,
|
||||
};
|
||||
|
||||
const updatedLogs = [...state.logs, newLog];
|
||||
|
||||
// Implement circular buffer - remove oldest if exceeding max
|
||||
if (updatedLogs.length > state.maxLogs) {
|
||||
updatedLogs.shift();
|
||||
}
|
||||
|
||||
return { ...state, logs: updatedLogs };
|
||||
});
|
||||
}
|
||||
|
||||
// Override console methods to capture frontend logs
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
info: console.info,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
debug: console.debug,
|
||||
};
|
||||
|
||||
function setupConsoleCapture() {
|
||||
console.log = (...args: unknown[]) => {
|
||||
originalConsole.log(...args);
|
||||
addLog("info", args.map((arg) => String(arg)).join(" "), "frontend");
|
||||
};
|
||||
|
||||
console.info = (...args: unknown[]) => {
|
||||
originalConsole.info(...args);
|
||||
addLog("info", args.map((arg) => String(arg)).join(" "), "frontend");
|
||||
};
|
||||
|
||||
console.warn = (...args: unknown[]) => {
|
||||
originalConsole.warn(...args);
|
||||
addLog("warn", args.map((arg) => String(arg)).join(" "), "frontend");
|
||||
};
|
||||
|
||||
console.error = (...args: unknown[]) => {
|
||||
originalConsole.error(...args);
|
||||
addLog("error", args.map((arg) => String(arg)).join(" "), "frontend");
|
||||
};
|
||||
|
||||
console.debug = (...args: unknown[]) => {
|
||||
originalConsole.debug(...args);
|
||||
addLog("debug", args.map((arg) => String(arg)).join(" "), "frontend");
|
||||
};
|
||||
|
||||
// Capture unhandled errors
|
||||
window.addEventListener("error", (event) => {
|
||||
addLog(
|
||||
"error",
|
||||
`[Unhandled Error] ${event.message} at ${event.filename}:${event.lineno}:${event.colno}`,
|
||||
"frontend"
|
||||
);
|
||||
});
|
||||
|
||||
// Capture unhandled promise rejections
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
addLog("error", `[Unhandled Promise Rejection] ${event.reason}`, "frontend");
|
||||
});
|
||||
}
|
||||
|
||||
function restoreConsole() {
|
||||
console.log = originalConsole.log;
|
||||
console.info = originalConsole.info;
|
||||
console.warn = originalConsole.warn;
|
||||
console.error = originalConsole.error;
|
||||
console.debug = originalConsole.debug;
|
||||
}
|
||||
|
||||
// Listen for backend logs
|
||||
async function setupBackendLogsListener() {
|
||||
await listen<{ level: LogLevel; message: string }>("debug:log", (event) => {
|
||||
addLog(event.payload.level, event.payload.message, "backend");
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
toggle: () => update((state) => ({ ...state, isOpen: !state.isOpen })),
|
||||
open: () => update((state) => ({ ...state, isOpen: true })),
|
||||
close: () => update((state) => ({ ...state, isOpen: false })),
|
||||
clear: () => update((state) => ({ ...state, logs: [] })),
|
||||
setFilterLevel: (level: LogLevel | "all") =>
|
||||
update((state) => ({ ...state, filterLevel: level })),
|
||||
setAutoScroll: (enabled: boolean) => update((state) => ({ ...state, autoScroll: enabled })),
|
||||
setupConsoleCapture,
|
||||
restoreConsole,
|
||||
setupBackendLogsListener,
|
||||
};
|
||||
}
|
||||
|
||||
export const debugConsoleStore = createDebugConsoleStore();
|
||||
|
||||
// Derived store for filtered logs
|
||||
export const filteredLogs = derived(debugConsoleStore, ($debugConsole) => {
|
||||
if ($debugConsole.filterLevel === "all") {
|
||||
return $debugConsole.logs;
|
||||
}
|
||||
|
||||
const levelPriority: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
const minPriority = levelPriority[$debugConsole.filterLevel];
|
||||
|
||||
return $debugConsole.logs.filter((log) => levelPriority[log.level] >= minPriority);
|
||||
});
|
||||
@@ -285,73 +285,6 @@ describe("snippetsStore", () => {
|
||||
expect(get(snippetsStore.selectedCategory)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("filteredSnippets", () => {
|
||||
it("returns all snippets when no category selected", async () => {
|
||||
const mockSnippets: Snippet[] = [
|
||||
{
|
||||
id: "snippet-1",
|
||||
name: "Git Status",
|
||||
content: "git status",
|
||||
category: "Git",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "snippet-2",
|
||||
name: "Docker PS",
|
||||
content: "docker ps",
|
||||
category: "Docker",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
setMockInvokeResult("list_snippets", mockSnippets);
|
||||
setMockInvokeResult("get_snippet_categories", ["Git", "Docker"]);
|
||||
|
||||
await snippetsStore.loadSnippets();
|
||||
snippetsStore.setSelectedCategory(null);
|
||||
|
||||
const filtered = get(snippetsStore.filteredSnippets);
|
||||
expect(filtered).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("filters snippets by selected category", async () => {
|
||||
const mockSnippets: Snippet[] = [
|
||||
{
|
||||
id: "snippet-1",
|
||||
name: "Git Status",
|
||||
content: "git status",
|
||||
category: "Git",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "snippet-2",
|
||||
name: "Docker PS",
|
||||
content: "docker ps",
|
||||
category: "Docker",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
setMockInvokeResult("list_snippets", mockSnippets);
|
||||
setMockInvokeResult("get_snippet_categories", ["Git", "Docker"]);
|
||||
|
||||
await snippetsStore.loadSnippets();
|
||||
snippetsStore.setSelectedCategory("Git");
|
||||
|
||||
const filtered = get(snippetsStore.filteredSnippets);
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].category).toBe("Git");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("snippet ID generation", () => {
|
||||
|
||||
+12
-269
@@ -1,25 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import {
|
||||
stats,
|
||||
formattedStats,
|
||||
resetSessionStats,
|
||||
contextWarning,
|
||||
getContextWarningMessage,
|
||||
estimateMessageCost,
|
||||
formatTokenCount,
|
||||
MODEL_PRICING,
|
||||
} from "./stats";
|
||||
import type { UsageStats, ToolTokenStats } from "./stats";
|
||||
|
||||
// Helper function to create ToolTokenStats for tests
|
||||
function toolStats(callCount: number, inputTokens = 0, outputTokens = 0): ToolTokenStats {
|
||||
return {
|
||||
call_count: callCount,
|
||||
estimated_input_tokens: inputTokens,
|
||||
estimated_output_tokens: outputTokens,
|
||||
};
|
||||
}
|
||||
import { stats, formattedStats, resetSessionStats } from "./stats";
|
||||
import type { UsageStats } from "./stats";
|
||||
|
||||
// Mock Tauri APIs
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
@@ -52,11 +34,6 @@ describe("stats store", () => {
|
||||
tools_usage: {},
|
||||
session_tools_usage: {},
|
||||
session_duration_seconds: 0,
|
||||
context_tokens_used: 0,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 0,
|
||||
potential_cache_hits: 0,
|
||||
potential_cache_savings_tokens: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,14 +63,9 @@ describe("stats store", () => {
|
||||
session_files_edited: 2,
|
||||
files_created: 1,
|
||||
session_files_created: 1,
|
||||
tools_usage: { Read: toolStats(5), Edit: toolStats(3) },
|
||||
session_tools_usage: { Read: toolStats(2), Edit: toolStats(1) },
|
||||
tools_usage: { Read: 5, Edit: 3 },
|
||||
session_tools_usage: { Read: 2, Edit: 1 },
|
||||
session_duration_seconds: 300,
|
||||
context_tokens_used: 500,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 0.25,
|
||||
potential_cache_hits: 0,
|
||||
potential_cache_savings_tokens: 0,
|
||||
};
|
||||
|
||||
stats.set(newStats);
|
||||
@@ -102,8 +74,7 @@ describe("stats store", () => {
|
||||
expect(currentStats.total_input_tokens).toBe(1000);
|
||||
expect(currentStats.total_output_tokens).toBe(2000);
|
||||
expect(currentStats.model).toBe("claude-sonnet-4");
|
||||
expect(currentStats.tools_usage.Read?.call_count).toBe(5);
|
||||
expect(currentStats.tools_usage.Edit?.call_count).toBe(3);
|
||||
expect(currentStats.tools_usage).toEqual({ Read: 5, Edit: 3 });
|
||||
});
|
||||
|
||||
it("can be updated with update function", () => {
|
||||
@@ -138,14 +109,9 @@ describe("stats store", () => {
|
||||
session_files_edited: 2,
|
||||
files_created: 1,
|
||||
session_files_created: 1,
|
||||
tools_usage: { Read: toolStats(5), Edit: toolStats(3) },
|
||||
session_tools_usage: { Read: toolStats(2), Edit: toolStats(1) },
|
||||
tools_usage: { Read: 5, Edit: 3 },
|
||||
session_tools_usage: { Read: 2, Edit: 1 },
|
||||
session_duration_seconds: 300,
|
||||
context_tokens_used: 500,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 0.25,
|
||||
potential_cache_hits: 0,
|
||||
potential_cache_savings_tokens: 0,
|
||||
});
|
||||
|
||||
// Reset session stats
|
||||
@@ -161,8 +127,7 @@ describe("stats store", () => {
|
||||
expect(currentStats.code_blocks_generated).toBe(3);
|
||||
expect(currentStats.files_edited).toBe(5);
|
||||
expect(currentStats.files_created).toBe(1);
|
||||
expect(currentStats.tools_usage.Read?.call_count).toBe(5);
|
||||
expect(currentStats.tools_usage.Edit?.call_count).toBe(3);
|
||||
expect(currentStats.tools_usage).toEqual({ Read: 5, Edit: 3 });
|
||||
expect(currentStats.model).toBe("claude-sonnet-4");
|
||||
|
||||
// Session stats should be reset
|
||||
@@ -312,8 +277,8 @@ describe("stats store", () => {
|
||||
});
|
||||
|
||||
it("exposes tools usage directly", () => {
|
||||
const toolsUsage = { Read: toolStats(10), Edit: toolStats(5), Write: toolStats(3) };
|
||||
const sessionToolsUsage = { Read: toolStats(2), Edit: toolStats(1) };
|
||||
const toolsUsage = { Read: 10, Edit: 5, Write: 3 };
|
||||
const sessionToolsUsage = { Read: 2, Edit: 1 };
|
||||
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
@@ -366,14 +331,9 @@ describe("stats store", () => {
|
||||
session_files_edited: 1,
|
||||
files_created: 1,
|
||||
session_files_created: 0,
|
||||
tools_usage: { Read: toolStats(3) },
|
||||
session_tools_usage: { Read: toolStats(1) },
|
||||
tools_usage: { Read: 3 },
|
||||
session_tools_usage: { Read: 1 },
|
||||
session_duration_seconds: 60,
|
||||
context_tokens_used: 50,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 0.025,
|
||||
potential_cache_hits: 0,
|
||||
potential_cache_savings_tokens: 0,
|
||||
};
|
||||
|
||||
stats.set(fullStats);
|
||||
@@ -383,221 +343,4 @@ describe("stats store", () => {
|
||||
expect(currentStats).toEqual(fullStats);
|
||||
});
|
||||
});
|
||||
|
||||
describe("context window tracking", () => {
|
||||
it("tracks context tokens used", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
context_tokens_used: 100000,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 50.0,
|
||||
}));
|
||||
|
||||
const currentStats = get(stats);
|
||||
expect(currentStats.context_tokens_used).toBe(100000);
|
||||
expect(currentStats.context_window_limit).toBe(200000);
|
||||
expect(currentStats.context_utilisation_percent).toBe(50.0);
|
||||
});
|
||||
|
||||
it("formats context stats correctly", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
context_tokens_used: 150000,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 75.5,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.contextUsed).toBe("150,000");
|
||||
expect(formatted.contextLimit).toBe("200,000");
|
||||
expect(formatted.contextRemaining).toBe("50,000");
|
||||
expect(formatted.contextUtilisation).toBe("75.5%");
|
||||
});
|
||||
|
||||
it("calculates remaining tokens correctly at limit", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
context_tokens_used: 200000,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 100.0,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.contextRemaining).toBe("0");
|
||||
});
|
||||
|
||||
it("handles over-limit gracefully", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
context_tokens_used: 250000,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 125.0,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.contextRemaining).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("contextWarning derived store", () => {
|
||||
it("returns null when under 50%", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
context_utilisation_percent: 40.0,
|
||||
}));
|
||||
|
||||
const warning = get(contextWarning);
|
||||
expect(warning).toBeNull();
|
||||
});
|
||||
|
||||
it("returns moderate when between 50-74%", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
context_utilisation_percent: 60.0,
|
||||
}));
|
||||
|
||||
const warning = get(contextWarning);
|
||||
expect(warning).toBe("moderate");
|
||||
});
|
||||
|
||||
it("returns high when between 75-89%", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
context_utilisation_percent: 80.0,
|
||||
}));
|
||||
|
||||
const warning = get(contextWarning);
|
||||
expect(warning).toBe("high");
|
||||
});
|
||||
|
||||
it("returns critical when 90%+", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
context_utilisation_percent: 95.0,
|
||||
}));
|
||||
|
||||
const warning = get(contextWarning);
|
||||
expect(warning).toBe("critical");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContextWarningMessage", () => {
|
||||
it("returns correct message for moderate warning", () => {
|
||||
const message = getContextWarningMessage("moderate");
|
||||
expect(message).toContain("50%+");
|
||||
expect(message).toContain("Consider starting a new conversation");
|
||||
});
|
||||
|
||||
it("returns correct message for high warning", () => {
|
||||
const message = getContextWarningMessage("high");
|
||||
expect(message).toContain("75%+");
|
||||
expect(message).toContain("Responses may degrade");
|
||||
});
|
||||
|
||||
it("returns correct message for critical warning", () => {
|
||||
const message = getContextWarningMessage("critical");
|
||||
expect(message).toContain("90%+");
|
||||
expect(message).toContain("Start a new conversation");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTokenCount", () => {
|
||||
it("formats small numbers directly", () => {
|
||||
expect(formatTokenCount(0)).toBe("0");
|
||||
expect(formatTokenCount(100)).toBe("100");
|
||||
expect(formatTokenCount(999)).toBe("999");
|
||||
});
|
||||
|
||||
it("formats thousands with K suffix", () => {
|
||||
expect(formatTokenCount(1000)).toBe("1.0K");
|
||||
expect(formatTokenCount(1500)).toBe("1.5K");
|
||||
expect(formatTokenCount(10000)).toBe("10.0K");
|
||||
expect(formatTokenCount(999999)).toBe("1000.0K");
|
||||
});
|
||||
|
||||
it("formats millions with M suffix", () => {
|
||||
expect(formatTokenCount(1000000)).toBe("1.0M");
|
||||
expect(formatTokenCount(1500000)).toBe("1.5M");
|
||||
expect(formatTokenCount(10000000)).toBe("10.0M");
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateMessageCost", () => {
|
||||
it("estimates tokens at ~4 chars per token", () => {
|
||||
const result = estimateMessageCost("test", 0, null); // 4 chars = 1 token
|
||||
expect(result.messageTokens).toBe(1);
|
||||
});
|
||||
|
||||
it("rounds up partial tokens", () => {
|
||||
const result = estimateMessageCost("a", 0, null); // 1 char rounds up to 1 token
|
||||
expect(result.messageTokens).toBe(1);
|
||||
|
||||
const result2 = estimateMessageCost("abcde", 0, null); // 5 chars = 2 tokens
|
||||
expect(result2.messageTokens).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 0 tokens for empty string", () => {
|
||||
const result = estimateMessageCost("", 0, null);
|
||||
expect(result.messageTokens).toBe(0);
|
||||
expect(result.estimatedCost).toBe(0);
|
||||
});
|
||||
|
||||
it("adds context tokens to total", () => {
|
||||
const result = estimateMessageCost("test", 1000, null); // 1 token + 1000 context
|
||||
expect(result.messageTokens).toBe(1);
|
||||
expect(result.totalInputTokens).toBe(1001);
|
||||
});
|
||||
|
||||
it("calculates cost using Sonnet pricing by default", () => {
|
||||
// 100 chars = 25 tokens, $3 per million input tokens
|
||||
const result = estimateMessageCost("a".repeat(100), 0, null);
|
||||
expect(result.messageTokens).toBe(25);
|
||||
const expectedCost = (25 / 1_000_000) * 3.0;
|
||||
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
|
||||
});
|
||||
|
||||
it("uses Opus pricing for Opus models", () => {
|
||||
const result = estimateMessageCost("a".repeat(100), 0, "claude-opus-4-5-20251101");
|
||||
expect(result.messageTokens).toBe(25);
|
||||
const expectedCost = (25 / 1_000_000) * 5.0; // Opus 4.5: $5 per million input
|
||||
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
|
||||
});
|
||||
|
||||
it("uses Haiku pricing for Haiku models", () => {
|
||||
const result = estimateMessageCost("a".repeat(100), 0, "claude-3-5-haiku-20241022");
|
||||
expect(result.messageTokens).toBe(25);
|
||||
const expectedCost = (25 / 1_000_000) * 1.0; // Haiku: $1 per million
|
||||
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
|
||||
});
|
||||
|
||||
it("falls back to Sonnet pricing for unknown models", () => {
|
||||
const result = estimateMessageCost("a".repeat(100), 0, "unknown-model");
|
||||
expect(result.messageTokens).toBe(25);
|
||||
const expectedCost = (25 / 1_000_000) * 3.0; // Default Sonnet: $3 per million
|
||||
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MODEL_PRICING", () => {
|
||||
it("contains expected Opus pricing", () => {
|
||||
// Opus 4.5 has reduced pricing
|
||||
expect(MODEL_PRICING["claude-opus-4-5-20251101"]).toEqual({ input: 5.0, output: 25.0 });
|
||||
// Previous Opus models have higher pricing
|
||||
expect(MODEL_PRICING["claude-opus-4-1-20250805"]).toEqual({ input: 15.0, output: 75.0 });
|
||||
expect(MODEL_PRICING["claude-opus-4-20250514"]).toEqual({ input: 15.0, output: 75.0 });
|
||||
});
|
||||
|
||||
it("contains expected Sonnet pricing", () => {
|
||||
expect(MODEL_PRICING["claude-sonnet-4-5-20250929"]).toEqual({ input: 3.0, output: 15.0 });
|
||||
expect(MODEL_PRICING["claude-sonnet-4-20250514"]).toEqual({ input: 3.0, output: 15.0 });
|
||||
expect(MODEL_PRICING["claude-3-7-sonnet-20250219"]).toEqual({ input: 3.0, output: 15.0 });
|
||||
expect(MODEL_PRICING["claude-3-5-sonnet-20241022"]).toEqual({ input: 3.0, output: 15.0 });
|
||||
});
|
||||
|
||||
it("contains expected Haiku pricing", () => {
|
||||
expect(MODEL_PRICING["claude-haiku-4-5-20251001"]).toEqual({ input: 1.0, output: 5.0 });
|
||||
expect(MODEL_PRICING["claude-3-5-haiku-20241022"]).toEqual({ input: 1.0, output: 5.0 });
|
||||
expect(MODEL_PRICING["claude-3-haiku-20240307"]).toEqual({ input: 0.25, output: 1.25 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+4
-220
@@ -1,70 +1,6 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { costTrackingStore } from "./costTracking";
|
||||
import { configStore } from "./config";
|
||||
|
||||
export type ContextWarning = "moderate" | "high" | "critical";
|
||||
export type BudgetType = "token" | "cost";
|
||||
|
||||
// Model pricing (per million tokens) - keep in sync with stats.rs
|
||||
// Source: https://platform.claude.com/docs/en/about-claude/models/overview
|
||||
export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
|
||||
// Current generation (Claude 4.6)
|
||||
"claude-opus-4-6": { input: 5.0, output: 25.0 },
|
||||
"claude-sonnet-4-6": { input: 3.0, output: 15.0 },
|
||||
// Previous generation (Claude 4.5)
|
||||
"claude-opus-4-5-20251101": { input: 5.0, output: 25.0 },
|
||||
"claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 },
|
||||
"claude-haiku-4-5-20251001": { input: 1.0, output: 5.0 },
|
||||
// Previous generation (Claude 4.x)
|
||||
"claude-opus-4-1-20250805": { input: 15.0, output: 75.0 },
|
||||
"claude-opus-4-20250514": { input: 15.0, output: 75.0 },
|
||||
"claude-sonnet-4-20250514": { input: 3.0, output: 15.0 },
|
||||
// Legacy (Claude 3.x)
|
||||
"claude-3-7-sonnet-20250219": { input: 3.0, output: 15.0 },
|
||||
"claude-3-5-sonnet-20241022": { input: 3.0, output: 15.0 },
|
||||
"claude-3-5-sonnet-20240620": { input: 3.0, output: 15.0 },
|
||||
"claude-3-5-haiku-20241022": { input: 1.0, output: 5.0 },
|
||||
"claude-3-opus-20240229": { input: 15.0, output: 75.0 },
|
||||
"claude-3-sonnet-20240229": { input: 3.0, output: 15.0 },
|
||||
"claude-3-haiku-20240307": { input: 0.25, output: 1.25 },
|
||||
};
|
||||
|
||||
const DEFAULT_PRICING = { input: 3.0, output: 15.0 }; // Default to Sonnet
|
||||
|
||||
export interface CostEstimate {
|
||||
messageTokens: number;
|
||||
totalInputTokens: number;
|
||||
estimatedCost: number;
|
||||
}
|
||||
|
||||
// Estimate cost for a message before sending
|
||||
export function estimateMessageCost(
|
||||
messageText: string,
|
||||
contextTokensUsed: number,
|
||||
model: string | null
|
||||
): CostEstimate {
|
||||
// Estimate tokens using ~4 chars per token heuristic
|
||||
const messageTokens = Math.ceil(messageText.length / 4);
|
||||
const totalInputTokens = contextTokensUsed + messageTokens;
|
||||
|
||||
const pricing = model ? (MODEL_PRICING[model] ?? DEFAULT_PRICING) : DEFAULT_PRICING;
|
||||
const estimatedCost = (totalInputTokens / 1_000_000) * pricing.input;
|
||||
|
||||
return { messageTokens, totalInputTokens, estimatedCost };
|
||||
}
|
||||
export type BudgetStatus =
|
||||
| { type: "ok" }
|
||||
| { type: "warning"; budget_type: BudgetType; percent_used: number }
|
||||
| { type: "exceeded"; budget_type: BudgetType };
|
||||
|
||||
// Per-tool token usage statistics
|
||||
export interface ToolTokenStats {
|
||||
call_count: number;
|
||||
estimated_input_tokens: number;
|
||||
estimated_output_tokens: number;
|
||||
}
|
||||
|
||||
export interface UsageStats {
|
||||
total_input_tokens: number;
|
||||
@@ -84,18 +20,9 @@ export interface UsageStats {
|
||||
session_files_edited: number;
|
||||
files_created: number;
|
||||
session_files_created: number;
|
||||
tools_usage: Record<string, ToolTokenStats>;
|
||||
session_tools_usage: Record<string, ToolTokenStats>;
|
||||
tools_usage: Record<string, number>;
|
||||
session_tools_usage: Record<string, number>;
|
||||
session_duration_seconds: number;
|
||||
|
||||
// Context window tracking
|
||||
context_tokens_used: number;
|
||||
context_window_limit: number;
|
||||
context_utilisation_percent: number;
|
||||
|
||||
// Cache analytics (tracks potential savings from repeated tool calls)
|
||||
potential_cache_hits: number;
|
||||
potential_cache_savings_tokens: number;
|
||||
}
|
||||
|
||||
// Main stats store
|
||||
@@ -118,26 +45,10 @@ export const stats = writable<UsageStats>({
|
||||
tools_usage: {},
|
||||
session_tools_usage: {},
|
||||
session_duration_seconds: 0,
|
||||
context_tokens_used: 0,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 0,
|
||||
potential_cache_hits: 0,
|
||||
potential_cache_savings_tokens: 0,
|
||||
});
|
||||
|
||||
// Format token count with K/M suffix
|
||||
export function formatTokenCount(tokens: number): string {
|
||||
if (tokens >= 1000000) {
|
||||
return `${(tokens / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return tokens.toString();
|
||||
}
|
||||
|
||||
// Derived store for formatted display values
|
||||
export const formattedStats = derived([stats, configStore.config], ([$stats, $config]) => {
|
||||
export const formattedStats = derived(stats, ($stats) => {
|
||||
const formatNumber = (num: number) => num.toLocaleString();
|
||||
const formatCost = (cost: number) => `$${cost.toFixed(4)}`;
|
||||
const formatDuration = (seconds: number) => {
|
||||
@@ -154,23 +65,6 @@ export const formattedStats = derived([stats, configStore.config], ([$stats, $co
|
||||
}
|
||||
};
|
||||
|
||||
// Format tool stats with token info
|
||||
const formatToolStats = (toolStats: Record<string, ToolTokenStats>) => {
|
||||
return Object.entries(toolStats).map(([name, stats]) => ({
|
||||
name,
|
||||
callCount: stats.call_count,
|
||||
totalTokens: stats.estimated_input_tokens + stats.estimated_output_tokens,
|
||||
formattedTokens: formatTokenCount(
|
||||
stats.estimated_input_tokens + stats.estimated_output_tokens
|
||||
),
|
||||
inputTokens: stats.estimated_input_tokens,
|
||||
outputTokens: stats.estimated_output_tokens,
|
||||
}));
|
||||
};
|
||||
|
||||
// Use the model from stats if available, otherwise fall back to the configured model
|
||||
const currentModel = $stats.model ?? $config.model ?? "No model selected";
|
||||
|
||||
return {
|
||||
totalTokens: formatNumber($stats.total_input_tokens + $stats.total_output_tokens),
|
||||
totalInputTokens: formatNumber($stats.total_input_tokens),
|
||||
@@ -180,7 +74,7 @@ export const formattedStats = derived([stats, configStore.config], ([$stats, $co
|
||||
sessionInputTokens: formatNumber($stats.session_input_tokens),
|
||||
sessionOutputTokens: formatNumber($stats.session_output_tokens),
|
||||
sessionCost: formatCost($stats.session_cost_usd),
|
||||
model: currentModel,
|
||||
model: $stats.model || "No model selected",
|
||||
|
||||
// New formatted fields
|
||||
messagesTotal: formatNumber($stats.messages_exchanged),
|
||||
@@ -194,116 +88,9 @@ export const formattedStats = derived([stats, configStore.config], ([$stats, $co
|
||||
sessionDuration: formatDuration($stats.session_duration_seconds),
|
||||
toolsUsage: $stats.tools_usage,
|
||||
sessionToolsUsage: $stats.session_tools_usage,
|
||||
// Formatted tool stats with token info
|
||||
sessionToolsFormatted: formatToolStats($stats.session_tools_usage),
|
||||
toolsFormatted: formatToolStats($stats.tools_usage),
|
||||
|
||||
// Context window tracking
|
||||
contextUsed: formatNumber($stats.context_tokens_used),
|
||||
contextLimit: formatNumber($stats.context_window_limit),
|
||||
contextRemaining: formatNumber(
|
||||
Math.max(0, $stats.context_window_limit - $stats.context_tokens_used)
|
||||
),
|
||||
contextUtilisation: `${$stats.context_utilisation_percent.toFixed(1)}%`,
|
||||
};
|
||||
});
|
||||
|
||||
// Derived store for context warning state
|
||||
export const contextWarning = derived(stats, ($stats): ContextWarning | null => {
|
||||
if ($stats.context_utilisation_percent >= 90) {
|
||||
return "critical";
|
||||
} else if ($stats.context_utilisation_percent >= 75) {
|
||||
return "high";
|
||||
} else if ($stats.context_utilisation_percent >= 50) {
|
||||
return "moderate";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Get warning message for context utilisation
|
||||
export function getContextWarningMessage(warning: ContextWarning): string {
|
||||
switch (warning) {
|
||||
case "moderate":
|
||||
return "Context window is 50%+ full. Consider starting a new conversation for better performance.";
|
||||
case "high":
|
||||
return "Context window is 75%+ full. Responses may degrade. Consider summarising or starting fresh.";
|
||||
case "critical":
|
||||
return "Context window is nearly full (90%+)! Start a new conversation to avoid errors.";
|
||||
}
|
||||
}
|
||||
|
||||
// Budget checking functions
|
||||
export function checkBudget(
|
||||
stats: UsageStats,
|
||||
budgetEnabled: boolean,
|
||||
tokenBudget: number | null,
|
||||
costBudget: number | null,
|
||||
warningThreshold: number
|
||||
): BudgetStatus {
|
||||
if (!budgetEnabled) {
|
||||
return { type: "ok" };
|
||||
}
|
||||
|
||||
const sessionTokens = stats.session_input_tokens + stats.session_output_tokens;
|
||||
|
||||
// Check token budget
|
||||
if (tokenBudget !== null) {
|
||||
if (sessionTokens >= tokenBudget) {
|
||||
return { type: "exceeded", budget_type: "token" };
|
||||
}
|
||||
const percentUsed = sessionTokens / tokenBudget;
|
||||
if (percentUsed >= warningThreshold) {
|
||||
return { type: "warning", budget_type: "token", percent_used: percentUsed * 100 };
|
||||
}
|
||||
}
|
||||
|
||||
// Check cost budget
|
||||
if (costBudget !== null) {
|
||||
if (stats.session_cost_usd >= costBudget) {
|
||||
return { type: "exceeded", budget_type: "cost" };
|
||||
}
|
||||
const percentUsed = stats.session_cost_usd / costBudget;
|
||||
if (percentUsed >= warningThreshold) {
|
||||
return { type: "warning", budget_type: "cost", percent_used: percentUsed * 100 };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "ok" };
|
||||
}
|
||||
|
||||
// Get budget status message
|
||||
export function getBudgetStatusMessage(status: BudgetStatus): string | null {
|
||||
if (status.type === "ok") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const budgetTypeLabel = status.budget_type === "token" ? "token" : "cost";
|
||||
|
||||
if (status.type === "exceeded") {
|
||||
return `Session ${budgetTypeLabel} budget exceeded! Consider starting a new session.`;
|
||||
}
|
||||
|
||||
return `Approaching ${budgetTypeLabel} budget limit (${status.percent_used.toFixed(0)}% used).`;
|
||||
}
|
||||
|
||||
// Get remaining budget values
|
||||
export function getRemainingTokenBudget(
|
||||
stats: UsageStats,
|
||||
tokenBudget: number | null
|
||||
): number | null {
|
||||
if (tokenBudget === null) return null;
|
||||
const used = stats.session_input_tokens + stats.session_output_tokens;
|
||||
return Math.max(0, tokenBudget - used);
|
||||
}
|
||||
|
||||
export function getRemainingCostBudget(
|
||||
stats: UsageStats,
|
||||
costBudget: number | null
|
||||
): number | null {
|
||||
if (costBudget === null) return null;
|
||||
return Math.max(0, costBudget - stats.session_cost_usd);
|
||||
}
|
||||
|
||||
// Note: Cost calculation is now done in the Rust backend
|
||||
|
||||
// Initialize stats listener
|
||||
@@ -315,9 +102,6 @@ export async function initStatsListener() {
|
||||
|
||||
// The backend already tracks all totals - just set the stats directly
|
||||
stats.set(newStats);
|
||||
|
||||
// Refresh cost tracking to check for alerts (debounced - won't spam)
|
||||
costTrackingStore.refresh();
|
||||
});
|
||||
|
||||
// Load initial persisted stats from backend (no bridge required)
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
export interface TodoItem {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
activeForm: string;
|
||||
}
|
||||
|
||||
interface TodoUpdatePayload {
|
||||
todos: TodoItem[];
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
// Create the writable store
|
||||
const { subscribe, set, update } = writable<TodoItem[]>([]);
|
||||
|
||||
// Listen for todo updates from the backend
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
export async function initializeTodoListener(): Promise<void> {
|
||||
if (unlisten) {
|
||||
return; // Already initialized
|
||||
}
|
||||
|
||||
unlisten = await listen<TodoUpdatePayload>("claude:todo-update", (event) => {
|
||||
set(event.payload.todos);
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanupTodoListener(): void {
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Export the store
|
||||
export const todos = {
|
||||
subscribe,
|
||||
set,
|
||||
update,
|
||||
clear: () => set([]),
|
||||
};
|
||||
+28
-173
@@ -12,9 +12,6 @@ import type {
|
||||
UserQuestionEvent,
|
||||
} from "$lib/types/messages";
|
||||
import type { CharacterState } from "$lib/types/states";
|
||||
import type { AgentStartPayload, AgentEndPayload } from "$lib/types/agents";
|
||||
import { agentStore } from "$lib/stores/agents";
|
||||
import { todos } from "$lib/stores/todos";
|
||||
import {
|
||||
initializeNotificationRules,
|
||||
cleanupNotificationRules,
|
||||
@@ -93,12 +90,6 @@ interface OutputPayload {
|
||||
content: string;
|
||||
tool_name: string | null;
|
||||
conversation_id?: string;
|
||||
cost?: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cost_usd: number;
|
||||
};
|
||||
parent_tool_use_id?: string;
|
||||
}
|
||||
|
||||
interface ConnectionPayload {
|
||||
@@ -179,15 +170,6 @@ export async function initializeTauriListeners() {
|
||||
} else if (status === "disconnected") {
|
||||
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||
|
||||
// Mark all running agents as errored on disconnect, but not during reconnects
|
||||
// (permission prompts trigger reconnects and agents may complete before reconnect)
|
||||
if (!skipNextGreeting && targetConversationId) {
|
||||
agentStore.markAllErrored(targetConversationId);
|
||||
// Clear the conversation's agents from the store on real disconnect
|
||||
// This prevents agents from persisting across sessions
|
||||
agentStore.clearConversation(targetConversationId);
|
||||
}
|
||||
|
||||
// Only remove from connected set if we're not about to reconnect
|
||||
if (!skipNextGreeting && targetConversationId) {
|
||||
connectedConversations.delete(targetConversationId);
|
||||
@@ -200,9 +182,6 @@ export async function initializeTauriListeners() {
|
||||
"system",
|
||||
"Disconnected from Claude Code"
|
||||
);
|
||||
|
||||
// Clear todos on real disconnect (not on reconnects for permissions)
|
||||
todos.clear();
|
||||
}
|
||||
|
||||
// Update character state for this conversation
|
||||
@@ -263,36 +242,22 @@ export async function initializeTauriListeners() {
|
||||
unlisteners.push(stateUnlisten);
|
||||
|
||||
const outputUnlisten = await listen<OutputPayload>("claude:output", (event) => {
|
||||
const { line_type, content, tool_name, conversation_id, cost, parent_tool_use_id } =
|
||||
event.payload;
|
||||
|
||||
// Convert snake_case cost to camelCase for TypeScript
|
||||
const costData = cost
|
||||
? {
|
||||
inputTokens: cost.input_tokens,
|
||||
outputTokens: cost.output_tokens,
|
||||
costUsd: cost.cost_usd,
|
||||
}
|
||||
: undefined;
|
||||
const { line_type, content, tool_name, conversation_id } = event.payload;
|
||||
|
||||
// Always store the output to the correct conversation
|
||||
if (conversation_id) {
|
||||
claudeStore.addLineToConversation(
|
||||
conversation_id,
|
||||
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
|
||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
||||
content,
|
||||
tool_name || undefined,
|
||||
costData,
|
||||
parent_tool_use_id
|
||||
tool_name || undefined
|
||||
);
|
||||
} else {
|
||||
// Fallback to active conversation if no conversation_id provided
|
||||
claudeStore.addLine(
|
||||
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
|
||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
||||
content,
|
||||
tool_name || undefined,
|
||||
costData,
|
||||
parent_tool_use_id
|
||||
tool_name || undefined
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -335,89 +300,35 @@ export async function initializeTauriListeners() {
|
||||
});
|
||||
unlisteners.push(cwdUnlisten);
|
||||
|
||||
console.log("[Tauri Listener] Setting up claude:permission listener");
|
||||
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
|
||||
const { permissions, conversation_id } = event.payload;
|
||||
const { id, tool_name, tool_input, description, conversation_id } = event.payload;
|
||||
|
||||
console.log(
|
||||
`[Permission] Event received: ${permissions.length} permission(s) for conversation ${conversation_id || "active"}`,
|
||||
{ permissions, conversation_id }
|
||||
);
|
||||
|
||||
// Store each permission request for the specific conversation
|
||||
for (const permission of permissions) {
|
||||
const { id, tool_name, tool_input, description } = permission;
|
||||
|
||||
if (conversation_id) {
|
||||
claudeStore.requestPermissionForConversation(conversation_id, {
|
||||
id,
|
||||
tool: tool_name,
|
||||
description,
|
||||
input: tool_input,
|
||||
});
|
||||
claudeStore.addLineToConversation(
|
||||
conversation_id,
|
||||
"system",
|
||||
`Permission requested for: ${tool_name}`
|
||||
);
|
||||
} else {
|
||||
// Fallback to active conversation if no conversation_id
|
||||
claudeStore.requestPermission({
|
||||
id,
|
||||
tool: tool_name,
|
||||
description,
|
||||
input: tool_input,
|
||||
});
|
||||
claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
|
||||
}
|
||||
// Store permission request for the specific conversation
|
||||
if (conversation_id) {
|
||||
claudeStore.requestPermissionForConversation(conversation_id, {
|
||||
id,
|
||||
tool: tool_name,
|
||||
description,
|
||||
input: tool_input,
|
||||
});
|
||||
claudeStore.addLineToConversation(
|
||||
conversation_id,
|
||||
"system",
|
||||
`Permission requested for: ${tool_name}`
|
||||
);
|
||||
} else {
|
||||
// Fallback to active conversation if no conversation_id
|
||||
claudeStore.requestPermission({
|
||||
id,
|
||||
tool: tool_name,
|
||||
description,
|
||||
input: tool_input,
|
||||
});
|
||||
claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
|
||||
}
|
||||
});
|
||||
unlisteners.push(permissionUnlisten);
|
||||
|
||||
const agentStartUnlisten = await listen<AgentStartPayload>("claude:agent-start", (event) => {
|
||||
const {
|
||||
tool_use_id,
|
||||
agent_id,
|
||||
description,
|
||||
subagent_type,
|
||||
started_at,
|
||||
conversation_id,
|
||||
parent_tool_use_id,
|
||||
} = event.payload;
|
||||
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||
if (targetConversationId) {
|
||||
agentStore.addAgent(targetConversationId, {
|
||||
toolUseId: tool_use_id,
|
||||
agentId: agent_id,
|
||||
description,
|
||||
subagentType: subagent_type,
|
||||
startedAt: started_at,
|
||||
status: "running",
|
||||
parentToolUseId: parent_tool_use_id,
|
||||
});
|
||||
}
|
||||
});
|
||||
unlisteners.push(agentStartUnlisten);
|
||||
|
||||
const agentUpdateUnlisten = await listen<{
|
||||
conversationId: string;
|
||||
toolUseId: string;
|
||||
agentId: string;
|
||||
}>("claude:agent-update", (event) => {
|
||||
const { conversationId, toolUseId, agentId } = event.payload;
|
||||
agentStore.updateAgentId(conversationId, toolUseId, agentId);
|
||||
});
|
||||
unlisteners.push(agentUpdateUnlisten);
|
||||
|
||||
const agentEndUnlisten = await listen<AgentEndPayload>("claude:agent-end", (event) => {
|
||||
const { tool_use_id, ended_at, is_error, conversation_id } = event.payload;
|
||||
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||
if (targetConversationId) {
|
||||
agentStore.endAgent(targetConversationId, tool_use_id, ended_at, is_error);
|
||||
}
|
||||
});
|
||||
unlisteners.push(agentEndUnlisten);
|
||||
|
||||
const questionUnlisten = await listen<UserQuestionEvent>("claude:question", (event) => {
|
||||
const questionEvent = event.payload;
|
||||
|
||||
@@ -446,59 +357,3 @@ export function cleanupTauriListeners() {
|
||||
// Cleanup notification rules
|
||||
cleanupNotificationRules();
|
||||
}
|
||||
|
||||
export async function initializeDiscordRpc() {
|
||||
const config = configStore.getConfig();
|
||||
if (config.discord_rpc_enabled) {
|
||||
try {
|
||||
const startedAt = new Date();
|
||||
const startedAtUnixSeconds = Math.floor(startedAt.getTime() / 1000);
|
||||
const model = config.model || "claude";
|
||||
|
||||
console.log("Initializing Discord RPC with initial activity:", {
|
||||
session_name: "Idle",
|
||||
model,
|
||||
started_at: startedAtUnixSeconds,
|
||||
});
|
||||
|
||||
await invoke("init_discord_rpc", {
|
||||
sessionName: "Idle",
|
||||
model,
|
||||
startedAt: startedAtUnixSeconds,
|
||||
});
|
||||
|
||||
console.log("Discord RPC initialized successfully with initial presence");
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize Discord RPC:", error);
|
||||
console.warn("Discord RPC will be unavailable. Make sure Discord is running.");
|
||||
}
|
||||
} else {
|
||||
console.log("Discord RPC is disabled in config, skipping initialization");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDiscordRpc(sessionName: string, model: string, startedAt: Date) {
|
||||
const config = configStore.getConfig();
|
||||
if (!config.discord_rpc_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const startedAtUnixSeconds = Math.floor(startedAt.getTime() / 1000);
|
||||
await invoke("update_discord_rpc", {
|
||||
sessionName: sessionName,
|
||||
model,
|
||||
startedAt: startedAtUnixSeconds,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update Discord RPC:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopDiscordRpc() {
|
||||
try {
|
||||
await invoke("stop_discord_rpc");
|
||||
} catch (error) {
|
||||
console.error("Failed to stop Discord RPC:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
export type AgentStatus = "running" | "completed" | "errored";
|
||||
|
||||
export interface AgentInfo {
|
||||
toolUseId: string;
|
||||
agentId?: string;
|
||||
description: string;
|
||||
subagentType: string;
|
||||
startedAt: number;
|
||||
endedAt?: number;
|
||||
status: AgentStatus;
|
||||
parentToolUseId?: string;
|
||||
durationMs?: number;
|
||||
characterName: string;
|
||||
characterAvatar: string;
|
||||
}
|
||||
|
||||
export interface AgentStartPayload {
|
||||
tool_use_id: string;
|
||||
agent_id?: string;
|
||||
description: string;
|
||||
subagent_type: string;
|
||||
started_at: number;
|
||||
conversation_id?: string;
|
||||
parent_tool_use_id?: string;
|
||||
}
|
||||
|
||||
export interface AgentEndPayload {
|
||||
tool_use_id: string;
|
||||
ended_at: number;
|
||||
is_error: boolean;
|
||||
conversation_id?: string;
|
||||
duration_ms?: number;
|
||||
num_turns?: number;
|
||||
}
|
||||
@@ -1,17 +1,9 @@
|
||||
export interface TerminalLine {
|
||||
id: string;
|
||||
type: "user" | "assistant" | "system" | "tool" | "error" | "thinking";
|
||||
type: "user" | "assistant" | "system" | "tool" | "error";
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolName?: string;
|
||||
// Cost tracking for this specific message
|
||||
cost?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
costUsd: number;
|
||||
};
|
||||
// Indicates if this message is from a subagent
|
||||
parentToolUseId?: string;
|
||||
}
|
||||
|
||||
export interface SystemInitMessage {
|
||||
@@ -126,15 +118,11 @@ export interface PermissionRequest {
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PermissionPromptEventItem {
|
||||
export interface PermissionPromptEvent {
|
||||
id: string;
|
||||
tool_name: string;
|
||||
tool_input: Record<string, unknown>;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface PermissionPromptEvent {
|
||||
permissions: PermissionPromptEventItem[];
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { CHARACTER_POOL, assignCharacter } from "./agentCharacters";
|
||||
|
||||
describe("agentCharacters", () => {
|
||||
describe("CHARACTER_POOL", () => {
|
||||
it("contains exactly 6 characters", () => {
|
||||
expect(CHARACTER_POOL).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("each character has a name, avatar, title, and description", () => {
|
||||
for (const character of CHARACTER_POOL) {
|
||||
expect(character.name).toBeTruthy();
|
||||
expect(character.avatar).toBeTruthy();
|
||||
expect(character.avatar).toMatch(/^https:\/\//u);
|
||||
expect(character.title).toBeTruthy();
|
||||
expect(character.description).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("all names are unique", () => {
|
||||
const names = CHARACTER_POOL.map((c) => c.name);
|
||||
const uniqueNames = new Set(names);
|
||||
expect(uniqueNames.size).toBe(CHARACTER_POOL.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assignCharacter", () => {
|
||||
it("returns a character from the pool", () => {
|
||||
const character = assignCharacter([]);
|
||||
const names = CHARACTER_POOL.map((c) => c.name);
|
||||
expect(names).toContain(character.name);
|
||||
});
|
||||
|
||||
it("avoids names already in use when possible", () => {
|
||||
const takenNames = ["Amari", "Keiko", "Minori", "Reina", "Tatsumi"];
|
||||
// Run many times to confirm we never get a taken name
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const character = assignCharacter(takenNames);
|
||||
expect(takenNames).not.toContain(character.name);
|
||||
expect(character.name).toBe("Yumiko");
|
||||
}
|
||||
});
|
||||
|
||||
it("picks from the full pool when all 6 names are taken", () => {
|
||||
const allNames = CHARACTER_POOL.map((c) => c.name);
|
||||
const seen = new Set<string>();
|
||||
// Run enough times that we'd statistically see variety
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const character = assignCharacter(allNames);
|
||||
seen.add(character.name);
|
||||
}
|
||||
// Should still pick valid characters
|
||||
for (const name of seen) {
|
||||
expect(allNames).toContain(name);
|
||||
}
|
||||
// With 100 runs and 6 characters, we should see at least 2 distinct names
|
||||
expect(seen.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("returns a character with name, avatar, title, and description", () => {
|
||||
const character = assignCharacter([]);
|
||||
expect(character.name).toBeTruthy();
|
||||
expect(character.avatar).toBeTruthy();
|
||||
expect(character.title).toBeTruthy();
|
||||
expect(character.description).toBeTruthy();
|
||||
});
|
||||
|
||||
it("works when the active list is empty", () => {
|
||||
const character = assignCharacter([]);
|
||||
expect(character).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
export interface AgentCharacter {
|
||||
name: string;
|
||||
avatar: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const CHARACTER_POOL: readonly AgentCharacter[] = [
|
||||
{
|
||||
name: "Amari",
|
||||
avatar: "https://cdn.nhcarrigan.com/amari.png",
|
||||
title: "Executive Assistant",
|
||||
description:
|
||||
"Fey-blooded PA and healer of the team. She always knows when you need a break — and makes sure you take one.",
|
||||
},
|
||||
{
|
||||
name: "Keiko",
|
||||
avatar: "https://cdn.nhcarrigan.com/keiko.png",
|
||||
title: "Chief Security Officer",
|
||||
description:
|
||||
"Bodyguard and shadow of the family. Conceals blades beneath evening gowns; always watching from the dark.",
|
||||
},
|
||||
{
|
||||
name: "Minori",
|
||||
avatar: "https://cdn.nhcarrigan.com/minori.png",
|
||||
title: "Chief Compliance Officer",
|
||||
description:
|
||||
"An ancient Automaton built to guard the Great Library. Perfect memory, perfect logic, perfect dedication.",
|
||||
},
|
||||
{
|
||||
name: "Reina",
|
||||
avatar: "https://cdn.nhcarrigan.com/reina.png",
|
||||
title: "Chief Legal Officer",
|
||||
description:
|
||||
"Demon of the Crossroads turned corporate lawyer. Her binding contracts have held for millennia.",
|
||||
},
|
||||
{
|
||||
name: "Tatsumi",
|
||||
avatar: "https://cdn.nhcarrigan.com/tatsumi.png",
|
||||
title: "Chief Design Officer",
|
||||
description:
|
||||
"A Siren who traded the ocean for a stylus. Uses her glamour to make every interface welcoming and beautiful.",
|
||||
},
|
||||
{
|
||||
name: "Yumiko",
|
||||
avatar: "https://cdn.nhcarrigan.com/yumiko.png",
|
||||
title: "Chief Technology Officer",
|
||||
description:
|
||||
"Technomancer and machine whisperer. She communes with machine spirits and keeps the digital world running.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Picks a character for a new subagent.
|
||||
* Avoids names already assigned to active agents unless all six are taken.
|
||||
*/
|
||||
export function assignCharacter(activeNames: readonly string[]): AgentCharacter {
|
||||
const available = CHARACTER_POOL.filter((c) => !activeNames.includes(c.name));
|
||||
const pool = available.length > 0 ? available : [...CHARACTER_POOL];
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
generateSummaryPrompt,
|
||||
generateContextInjection,
|
||||
estimateTokens,
|
||||
createSummary,
|
||||
shouldSuggestCompaction,
|
||||
formatTokenCount,
|
||||
sanitizeForJson,
|
||||
} from "./conversationUtils";
|
||||
import type { ConversationSummary } from "$lib/stores/conversations";
|
||||
|
||||
describe("conversationUtils", () => {
|
||||
describe("generateSummaryPrompt", () => {
|
||||
it("generates a prompt containing the conversation content", () => {
|
||||
const content = "User: Hello\n\nAssistant: Hi there!";
|
||||
const prompt = generateSummaryPrompt(content);
|
||||
|
||||
expect(prompt).toContain(content);
|
||||
expect(prompt).toContain("summary");
|
||||
expect(prompt).toContain("Key topics");
|
||||
});
|
||||
|
||||
it("handles empty content", () => {
|
||||
const prompt = generateSummaryPrompt("");
|
||||
expect(prompt).toContain("summary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateContextInjection", () => {
|
||||
it("creates context injection message from summary", () => {
|
||||
const summary: ConversationSummary = {
|
||||
generatedAt: new Date("2024-01-01"),
|
||||
content: "We discussed building a new feature",
|
||||
messageCount: 50,
|
||||
tokenEstimate: 10000,
|
||||
};
|
||||
|
||||
const injection = generateContextInjection(summary);
|
||||
|
||||
expect(injection).toContain("Previous Session Context");
|
||||
expect(injection).toContain("We discussed building a new feature");
|
||||
expect(injection).toContain("50 messages");
|
||||
expect(injection).toContain("10,000 tokens");
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateTokens", () => {
|
||||
it("estimates tokens at ~4 chars per token", () => {
|
||||
expect(estimateTokens("")).toBe(0);
|
||||
expect(estimateTokens("test")).toBe(1); // 4 chars = 1 token
|
||||
expect(estimateTokens("testing")).toBe(2); // 7 chars = 2 tokens
|
||||
expect(estimateTokens("a".repeat(100))).toBe(25); // 100 chars = 25 tokens
|
||||
});
|
||||
|
||||
it("rounds up partial tokens", () => {
|
||||
expect(estimateTokens("a")).toBe(1); // 1 char rounds up to 1 token
|
||||
expect(estimateTokens("ab")).toBe(1); // 2 chars rounds up to 1 token
|
||||
expect(estimateTokens("abc")).toBe(1); // 3 chars rounds up to 1 token
|
||||
expect(estimateTokens("abcde")).toBe(2); // 5 chars rounds up to 2 tokens
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSummary", () => {
|
||||
it("creates a valid ConversationSummary object", () => {
|
||||
const summary = createSummary("Test summary content", 25, 5000);
|
||||
|
||||
expect(summary.content).toBe("Test summary content");
|
||||
expect(summary.messageCount).toBe(25);
|
||||
expect(summary.tokenEstimate).toBe(5000);
|
||||
expect(summary.generatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("sets generatedAt to current time", () => {
|
||||
const before = new Date();
|
||||
const summary = createSummary("content", 10, 1000);
|
||||
const after = new Date();
|
||||
|
||||
expect(summary.generatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
expect(summary.generatedAt.getTime()).toBeLessThanOrEqual(after.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldSuggestCompaction", () => {
|
||||
it("returns false when under threshold", () => {
|
||||
expect(shouldSuggestCompaction(50000, 200000, 60)).toBe(false); // 25%
|
||||
expect(shouldSuggestCompaction(100000, 200000, 60)).toBe(false); // 50%
|
||||
expect(shouldSuggestCompaction(119000, 200000, 60)).toBe(false); // 59.5%
|
||||
});
|
||||
|
||||
it("returns true when at or above threshold", () => {
|
||||
expect(shouldSuggestCompaction(120000, 200000, 60)).toBe(true); // 60%
|
||||
expect(shouldSuggestCompaction(150000, 200000, 60)).toBe(true); // 75%
|
||||
expect(shouldSuggestCompaction(200000, 200000, 60)).toBe(true); // 100%
|
||||
});
|
||||
|
||||
it("handles zero context window limit", () => {
|
||||
expect(shouldSuggestCompaction(50000, 0, 60)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses default threshold of 60%", () => {
|
||||
expect(shouldSuggestCompaction(110000, 200000)).toBe(false); // 55%
|
||||
expect(shouldSuggestCompaction(130000, 200000)).toBe(true); // 65%
|
||||
});
|
||||
|
||||
it("respects custom threshold", () => {
|
||||
expect(shouldSuggestCompaction(70000, 200000, 40)).toBe(false); // 35%
|
||||
expect(shouldSuggestCompaction(90000, 200000, 40)).toBe(true); // 45%
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTokenCount", () => {
|
||||
it("formats small numbers directly", () => {
|
||||
expect(formatTokenCount(0)).toBe("0");
|
||||
expect(formatTokenCount(100)).toBe("100");
|
||||
expect(formatTokenCount(999)).toBe("999");
|
||||
});
|
||||
|
||||
it("formats thousands with K suffix", () => {
|
||||
expect(formatTokenCount(1000)).toBe("1.0K");
|
||||
expect(formatTokenCount(1500)).toBe("1.5K");
|
||||
expect(formatTokenCount(10000)).toBe("10.0K");
|
||||
expect(formatTokenCount(999999)).toBe("1000.0K");
|
||||
});
|
||||
|
||||
it("formats millions with M suffix", () => {
|
||||
expect(formatTokenCount(1000000)).toBe("1.0M");
|
||||
expect(formatTokenCount(1500000)).toBe("1.5M");
|
||||
expect(formatTokenCount(10000000)).toBe("10.0M");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeForJson", () => {
|
||||
it("returns normal text unchanged", () => {
|
||||
expect(sanitizeForJson("Hello world")).toBe("Hello world");
|
||||
expect(sanitizeForJson("Test 123")).toBe("Test 123");
|
||||
});
|
||||
|
||||
it("preserves common whitespace", () => {
|
||||
expect(sanitizeForJson("line1\nline2")).toBe("line1\nline2");
|
||||
expect(sanitizeForJson("col1\tcol2")).toBe("col1\tcol2");
|
||||
expect(sanitizeForJson("line\r\nend")).toBe("line\r\nend");
|
||||
});
|
||||
|
||||
it("removes null bytes", () => {
|
||||
expect(sanitizeForJson("hello\x00world")).toBe("helloworld");
|
||||
});
|
||||
|
||||
it("removes other control characters", () => {
|
||||
// Bell character
|
||||
expect(sanitizeForJson("alert\x07here")).toBe("alerthere");
|
||||
// Backspace
|
||||
expect(sanitizeForJson("back\x08space")).toBe("backspace");
|
||||
// Form feed is removed
|
||||
expect(sanitizeForJson("page\x0Cbreak")).toBe("pagebreak");
|
||||
// Escape character
|
||||
expect(sanitizeForJson("esc\x1Bhere")).toBe("eschere");
|
||||
});
|
||||
|
||||
it("preserves printable characters including backslashes", () => {
|
||||
const codeContent = '```rust\nfn main() {\n println!("Hello");\n}\n```';
|
||||
expect(sanitizeForJson(codeContent)).toBe(codeContent);
|
||||
});
|
||||
|
||||
it("handles mixed content with various characters", () => {
|
||||
const mixed = "User: Hello\n\nAssistant: Here's some code:\n```\nconst x = 42;\n```";
|
||||
expect(sanitizeForJson(mixed)).toBe(mixed);
|
||||
});
|
||||
|
||||
it("preserves backslash sequences", () => {
|
||||
// Backslashes followed by letters should be preserved as-is
|
||||
expect(sanitizeForJson("path\\to\\file")).toBe("path\\to\\file");
|
||||
expect(sanitizeForJson("color\\x1b")).toBe("color\\x1b");
|
||||
});
|
||||
|
||||
it("removes lone surrogates", () => {
|
||||
// Lone surrogates (U+D800-U+DFFF) can cause JSON parse errors
|
||||
// High surrogate without low
|
||||
expect(sanitizeForJson("test\uD800end")).toBe("testend");
|
||||
// Low surrogate without high
|
||||
expect(sanitizeForJson("test\uDC00end")).toBe("testend");
|
||||
// But valid surrogate pairs should remain (they form valid characters)
|
||||
// Actually, JavaScript represents emoji as surrogate pairs, so this is tricky
|
||||
// The regex will remove the surrogates, which may break emoji. That's acceptable
|
||||
// for a conversation summary where data integrity is more important.
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
import type { ConversationSummary } from "$lib/stores/conversations";
|
||||
|
||||
/**
|
||||
* Sanitises a string for safe JSON serialization through Tauri IPC.
|
||||
* Removes control characters and lone surrogates that could cause issues
|
||||
* during JSON serialization/deserialization.
|
||||
*/
|
||||
export function sanitizeForJson(text: string): string {
|
||||
// Remove control characters except for common whitespace (tab, newline, carriage return)
|
||||
// These can cause JSON parsing issues and are rarely meaningful in conversation summaries.
|
||||
// eslint-disable-next-line no-control-regex -- regex uses control character codes
|
||||
let sanitized = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
|
||||
|
||||
// Remove extended ASCII control chars (C1 control codes)
|
||||
sanitized = sanitized.replace(/[\x80-\x9F]/g, "");
|
||||
|
||||
// Remove lone surrogates (U+D800 to U+DFFF) which cause "unexpected end of hex escape"
|
||||
// errors in serde_json when they appear without proper pairing.
|
||||
// These are invalid in JSON and can cause parse failures.
|
||||
sanitized = sanitized.replace(/[\uD800-\uDFFF]/g, "");
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a prompt to ask Claude to summarise a conversation.
|
||||
* This can be sent as a user message to get a summary.
|
||||
*/
|
||||
export function generateSummaryPrompt(conversationContent: string): string {
|
||||
return `Please provide a concise summary of our conversation so far. Focus on:
|
||||
1. Key topics discussed
|
||||
2. Important decisions or conclusions made
|
||||
3. Any ongoing tasks or context that would be helpful to remember
|
||||
4. Code changes or files that were modified
|
||||
|
||||
Keep the summary brief but comprehensive enough to continue our work in a new session.
|
||||
|
||||
Here is our conversation:
|
||||
|
||||
${conversationContent}
|
||||
|
||||
Please provide the summary now:`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a context injection message to prepend to a new conversation.
|
||||
* This provides Claude with context from a previous session.
|
||||
*/
|
||||
export function generateContextInjection(summary: ConversationSummary): string {
|
||||
return `[Previous Session Context]
|
||||
The following is a summary from our previous conversation (${summary.messageCount} messages, approximately ${summary.tokenEstimate.toLocaleString()} tokens):
|
||||
|
||||
${summary.content}
|
||||
|
||||
[End of Previous Context]
|
||||
|
||||
Please continue from where we left off, or let me know if you need any clarification about the previous context.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates the token count for a given string.
|
||||
* Uses a rough approximation of ~4 characters per token.
|
||||
*/
|
||||
export function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ConversationSummary object from summary content.
|
||||
*/
|
||||
export function createSummary(
|
||||
content: string,
|
||||
messageCount: number,
|
||||
originalTokenEstimate: number
|
||||
): ConversationSummary {
|
||||
return {
|
||||
generatedAt: new Date(),
|
||||
content,
|
||||
messageCount,
|
||||
tokenEstimate: originalTokenEstimate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a conversation should be compacted based on token usage.
|
||||
* Returns true if the conversation is using more than the threshold percentage
|
||||
* of the context window.
|
||||
*/
|
||||
export function shouldSuggestCompaction(
|
||||
contextTokensUsed: number,
|
||||
contextWindowLimit: number,
|
||||
thresholdPercent: number = 60
|
||||
): boolean {
|
||||
if (contextWindowLimit === 0) return false;
|
||||
const utilisationPercent = (contextTokensUsed / contextWindowLimit) * 100;
|
||||
return utilisationPercent >= thresholdPercent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a token count for display.
|
||||
*/
|
||||
export function formatTokenCount(tokens: number): string {
|
||||
if (tokens >= 1000000) {
|
||||
return `${(tokens / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return tokens.toString();
|
||||
}
|
||||
@@ -135,151 +135,6 @@ describe("stateMapper", () => {
|
||||
};
|
||||
expect(mapMessageToState(message)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns typing for unknown tool", () => {
|
||||
const message: ClaudeStreamMessage = {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-1",
|
||||
name: "SomeUnknownTool",
|
||||
input: {},
|
||||
},
|
||||
],
|
||||
model: "claude-3",
|
||||
stop_reason: "tool_use",
|
||||
},
|
||||
};
|
||||
expect(mapMessageToState(message)).toBe("typing");
|
||||
});
|
||||
|
||||
it("returns thinking for thinking content block", () => {
|
||||
const message: ClaudeStreamMessage = {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "thinking", thinking: "Analyzing the problem..." }],
|
||||
model: "claude-3",
|
||||
stop_reason: "end_turn",
|
||||
},
|
||||
};
|
||||
expect(mapMessageToState(message)).toBe("thinking");
|
||||
});
|
||||
|
||||
it("returns null for assistant message with no recognizable content", () => {
|
||||
const message: ClaudeStreamMessage = {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [],
|
||||
model: "claude-3",
|
||||
stop_reason: "end_turn",
|
||||
},
|
||||
};
|
||||
expect(mapMessageToState(message)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns thinking for thinking_delta stream event", () => {
|
||||
const message: ClaudeStreamMessage = {
|
||||
type: "stream_event",
|
||||
event: {
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: {
|
||||
type: "thinking_delta",
|
||||
thinking: "Thinking...",
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(mapMessageToState(message)).toBe("thinking");
|
||||
});
|
||||
|
||||
it("returns typing for text_delta stream event", () => {
|
||||
const message: ClaudeStreamMessage = {
|
||||
type: "stream_event",
|
||||
event: {
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: {
|
||||
type: "text_delta",
|
||||
text: "Hello",
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(mapMessageToState(message)).toBe("typing");
|
||||
});
|
||||
|
||||
it("returns thinking for thinking content_block_start", () => {
|
||||
const message: ClaudeStreamMessage = {
|
||||
type: "stream_event",
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(mapMessageToState(message)).toBe("thinking");
|
||||
});
|
||||
|
||||
it("returns typing for text content_block_start", () => {
|
||||
const message: ClaudeStreamMessage = {
|
||||
type: "stream_event",
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "text",
|
||||
text: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(mapMessageToState(message)).toBe("typing");
|
||||
});
|
||||
|
||||
it("returns correct state for tool_use content_block_start", () => {
|
||||
const message: ClaudeStreamMessage = {
|
||||
type: "stream_event",
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "tool-1",
|
||||
name: "Read",
|
||||
input: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(mapMessageToState(message)).toBe("searching");
|
||||
});
|
||||
|
||||
it("returns null for stream_event with unrecognized type", () => {
|
||||
const message: ClaudeStreamMessage = {
|
||||
type: "stream_event",
|
||||
event: {
|
||||
type: "message_start",
|
||||
},
|
||||
};
|
||||
expect(mapMessageToState(message)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for result with unknown subtype", () => {
|
||||
const message = {
|
||||
type: "result",
|
||||
subtype: "unknown_type",
|
||||
} as unknown as ClaudeStreamMessage;
|
||||
expect(mapMessageToState(message)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unknown message type", () => {
|
||||
const message = {
|
||||
type: "unknown_type",
|
||||
} as unknown as ClaudeStreamMessage;
|
||||
expect(mapMessageToState(message)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTextFromMessage", () => {
|
||||
@@ -337,36 +192,6 @@ describe("stateMapper", () => {
|
||||
};
|
||||
expect(extractTextFromMessage(message)).toBe("Completed successfully");
|
||||
});
|
||||
|
||||
it("returns null for result without result field", () => {
|
||||
const message: ClaudeStreamMessage = {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
};
|
||||
expect(extractTextFromMessage(message)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for stream_event without delta text", () => {
|
||||
const message: ClaudeStreamMessage = {
|
||||
type: "stream_event",
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "text",
|
||||
text: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(extractTextFromMessage(message)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unknown message type", () => {
|
||||
const message = {
|
||||
type: "unknown",
|
||||
} as unknown as ClaudeStreamMessage;
|
||||
expect(extractTextFromMessage(message)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractToolInfo", () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import "../app.css";
|
||||
import DebugConsole from "$lib/components/DebugConsole.svelte";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -15,5 +14,4 @@
|
||||
|
||||
<div id="app">
|
||||
{@render children()}
|
||||
<DebugConsole />
|
||||
</div>
|
||||
|
||||
+1
-113
@@ -1,16 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { get } from "svelte/store";
|
||||
import {
|
||||
initializeTauriListeners,
|
||||
cleanupTauriListeners,
|
||||
initializeDiscordRpc,
|
||||
stopDiscordRpc,
|
||||
updateDiscordRpc,
|
||||
setSkipNextGreeting,
|
||||
} from "$lib/tauri";
|
||||
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
|
||||
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
|
||||
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
@@ -32,18 +24,12 @@
|
||||
import AchievementNotification from "$lib/components/AchievementNotification.svelte";
|
||||
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
|
||||
import UpdateNotification from "$lib/components/UpdateNotification.svelte";
|
||||
import CloseAppConfirmModal from "$lib/components/CloseAppConfirmModal.svelte";
|
||||
import MemoryBrowserPanel from "$lib/components/MemoryBrowserPanel.svelte";
|
||||
import { debugConsoleStore } from "$lib/stores/debugConsole";
|
||||
import { initializeTodoListener, cleanupTodoListener } from "$lib/stores/todos";
|
||||
|
||||
let initialized = false;
|
||||
let updateNotification: UpdateNotification | undefined = $state(undefined);
|
||||
let achievementPanelOpen = $state(false);
|
||||
let currentCharacterState: CharacterState = $state("idle");
|
||||
let compactModeActive = $state(false);
|
||||
let closeConfirmModalOpen = $state(false);
|
||||
let hasActiveConversation = $state(false);
|
||||
|
||||
// Editor state
|
||||
const isEditorVisible = editorStore.isEditorVisible;
|
||||
@@ -71,24 +57,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Get reactive references to conversation stores
|
||||
const activeConversationId = conversationsStore.activeConversationId;
|
||||
const conversations = conversationsStore.conversations;
|
||||
|
||||
// Update Discord RPC when active conversation or model changes
|
||||
$effect(() => {
|
||||
// Access stores directly (without get()) to create reactive dependencies
|
||||
const activeId = $activeConversationId;
|
||||
const convs = $conversations;
|
||||
const activeConv = activeId ? convs.get(activeId) : null;
|
||||
const config = configStore.getConfig();
|
||||
const model = config.model || "claude";
|
||||
|
||||
if (activeConv && config.discord_rpc_enabled) {
|
||||
updateDiscordRpc(activeConv.name, model, activeConv.startedAt);
|
||||
}
|
||||
});
|
||||
|
||||
// Window size constants
|
||||
const COMPACT_WIDTH = 280;
|
||||
const COMPACT_HEIGHT = 400;
|
||||
@@ -237,13 +205,6 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+` - Toggle debug console
|
||||
if (event.ctrlKey && event.key === "`") {
|
||||
event.preventDefault();
|
||||
debugConsoleStore.toggle();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+E - Toggle editor panel (only when connected)
|
||||
if (event.ctrlKey && event.key === "e") {
|
||||
event.preventDefault();
|
||||
@@ -293,9 +254,6 @@
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) return;
|
||||
|
||||
// Set flag to preserve stats/permissions (don't treat next connect as new session)
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
await invoke("interrupt_claude", { conversationId });
|
||||
claudeStore.addLine("system", "Process interrupted");
|
||||
} catch (error) {
|
||||
@@ -356,50 +314,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloseRequest() {
|
||||
// Check if there's an active conversation with Claude running
|
||||
const activeId = get(claudeStore.activeConversationId);
|
||||
if (activeId) {
|
||||
try {
|
||||
const isRunning = await invoke<boolean>("is_claude_running", {
|
||||
conversationId: activeId,
|
||||
});
|
||||
hasActiveConversation = isRunning;
|
||||
} catch (error) {
|
||||
console.error("Failed to check Claude status:", error);
|
||||
hasActiveConversation = false;
|
||||
}
|
||||
} else {
|
||||
hasActiveConversation = false;
|
||||
}
|
||||
|
||||
// Always show confirmation modal
|
||||
closeConfirmModalOpen = true;
|
||||
}
|
||||
|
||||
async function handleConfirmClose() {
|
||||
closeConfirmModalOpen = false;
|
||||
try {
|
||||
await invoke("close_application");
|
||||
} catch (error) {
|
||||
console.error("Failed to close application:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMinimizeToTray() {
|
||||
closeConfirmModalOpen = false;
|
||||
try {
|
||||
const window = getCurrentWindow();
|
||||
await window.hide();
|
||||
} catch (error) {
|
||||
console.error("Failed to minimize to tray:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancelClose() {
|
||||
closeConfirmModalOpen = false;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
@@ -442,22 +356,6 @@
|
||||
const window = getCurrentWindow();
|
||||
await window.setSize(new LogicalSize(COMPACT_WIDTH, COMPACT_HEIGHT));
|
||||
}
|
||||
|
||||
// Initialize Discord RPC
|
||||
await initializeDiscordRpc();
|
||||
|
||||
// Initialize todo listener
|
||||
await initializeTodoListener();
|
||||
|
||||
// Listen for window close requests
|
||||
const unlisten = await listen("window-close-requested", () => {
|
||||
handleCloseRequest();
|
||||
});
|
||||
|
||||
// Store the unlisten function for cleanup
|
||||
window.addEventListener("beforeunload", () => {
|
||||
unlisten();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -465,8 +363,6 @@
|
||||
if (initialized) {
|
||||
cleanupTauriListeners();
|
||||
cleanupNotificationSync();
|
||||
cleanupTodoListener();
|
||||
stopDiscordRpc();
|
||||
window.removeEventListener("keydown", handleGlobalKeydown);
|
||||
initialized = false;
|
||||
}
|
||||
@@ -519,20 +415,12 @@
|
||||
<PermissionModal />
|
||||
<UserQuestionModal />
|
||||
<ConfigSidebar />
|
||||
<MemoryBrowserPanel />
|
||||
<AchievementNotification />
|
||||
<AchievementsPanel
|
||||
bind:isOpen={achievementPanelOpen}
|
||||
onClose={() => (achievementPanelOpen = false)}
|
||||
/>
|
||||
<UpdateNotification bind:this={updateNotification} />
|
||||
<CloseAppConfirmModal
|
||||
isOpen={closeConfirmModalOpen}
|
||||
{hasActiveConversation}
|
||||
onClose={handleConfirmClose}
|
||||
onMinimize={handleMinimizeToTray}
|
||||
onCancel={handleCancelClose}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ vi.mock("@tauri-apps/api/core", () => ({
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: null,
|
||||
font_size: 14,
|
||||
minimize_to_tray: false,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
|
||||
Reference in New Issue
Block a user