generated from nhcarrigan/template
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de4d705e23 | |||
| 99d1162cb6 | |||
| 5eecb7a56f |
@@ -1,184 +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 sign commits** with Hikari's GPG key: `--gpg-sign=5380E4EE7307C808`
|
|
||||||
- **Never add `Co-Authored-By` lines** for Gitea commits
|
|
||||||
- **Always ask for confirmation** before committing
|
|
||||||
- **Always ask for confirmation** before pushing
|
|
||||||
|
|
||||||
Example commit command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git commit --author="Hikari <hikari@nhcarrigan.com>" --gpg-sign=5380E4EE7307C808 -m "your commit message"
|
|
||||||
```
|
|
||||||
|
|
||||||
Example push command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git push https://hikari:TOKEN@git.nhcarrigan.com/nhcarrigan/hikari-desktop.git <branch>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Requirements
|
|
||||||
|
|
||||||
**All changes MUST include tests.** This is non-negotiable — no feature, bug fix, or refactor should be committed without corresponding test coverage. If a change cannot be tested (e.g. pure UI layout, Tauri IPC calls that are impossible to mock), document why in a comment.
|
|
||||||
|
|
||||||
- **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 All Changes
|
|
||||||
|
|
||||||
Every change — features, bug fixes, refactors — must include 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)
|
|
||||||
|
|
||||||
**Do not commit changes without tests.** The goal is to maintain near-100% coverage as the codebase grows, so future refactoring can be done 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
|
#!/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
|
# Colors for output
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
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 lint" "pnpm lint" || failed=1
|
||||||
run_check "Frontend format check" "pnpm format:check" || failed=1
|
run_check "Frontend format check" "pnpm format:check" || failed=1
|
||||||
run_check "Frontend type check" "pnpm 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
|
# Backend checks
|
||||||
run_check "Backend clippy (strict)" "(cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings)" || failed=1
|
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 tests" "cargo test" || failed=1
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
echo -e "\n${YELLOW}========================================${NC}"
|
echo -e "\n${YELLOW}========================================${NC}"
|
||||||
|
|||||||
+61
-62
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hikari-desktop",
|
"name": "hikari-desktop",
|
||||||
"version": "1.10.0",
|
"version": "1.1.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -27,69 +27,68 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.10.2",
|
"@codemirror/commands": "6.8.1",
|
||||||
"@codemirror/lang-angular": "0.1.4",
|
"@codemirror/lang-angular": "^0.1.4",
|
||||||
"@codemirror/lang-cpp": "6.0.3",
|
"@codemirror/lang-cpp": "^6.0.3",
|
||||||
"@codemirror/lang-css": "6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-go": "6.0.1",
|
"@codemirror/lang-go": "^6.0.1",
|
||||||
"@codemirror/lang-html": "6.4.11",
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
"@codemirror/lang-java": "6.0.2",
|
"@codemirror/lang-java": "^6.0.2",
|
||||||
"@codemirror/lang-javascript": "6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
"@codemirror/lang-json": "6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@codemirror/lang-less": "6.0.2",
|
"@codemirror/lang-less": "^6.0.2",
|
||||||
"@codemirror/lang-markdown": "6.5.0",
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
"@codemirror/lang-php": "6.0.2",
|
"@codemirror/lang-php": "^6.0.2",
|
||||||
"@codemirror/lang-python": "6.2.1",
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
"@codemirror/lang-rust": "6.0.2",
|
"@codemirror/lang-rust": "^6.0.2",
|
||||||
"@codemirror/lang-sass": "6.0.2",
|
"@codemirror/lang-sass": "^6.0.2",
|
||||||
"@codemirror/lang-sql": "6.10.0",
|
"@codemirror/lang-sql": "^6.10.0",
|
||||||
"@codemirror/lang-vue": "0.1.3",
|
"@codemirror/lang-vue": "^0.1.3",
|
||||||
"@codemirror/lang-wast": "6.0.2",
|
"@codemirror/lang-wast": "^6.0.2",
|
||||||
"@codemirror/lang-xml": "6.1.0",
|
"@codemirror/lang-xml": "^6.1.0",
|
||||||
"@codemirror/lang-yaml": "6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
"@codemirror/language": "6.12.2",
|
"@codemirror/language": "^6.12.1",
|
||||||
"@codemirror/legacy-modes": "6.5.2",
|
"@codemirror/legacy-modes": "^6.5.2",
|
||||||
"@codemirror/state": "6.5.4",
|
"@codemirror/state": "^6.5.4",
|
||||||
"@codemirror/theme-one-dark": "6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "6.39.15",
|
"@codemirror/view": "^6.39.11",
|
||||||
"@lezer/highlight": "1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@tauri-apps/api": "2.10.1",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
|
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||||
"@tauri-apps/plugin-dialog": "2.6.0",
|
"@tauri-apps/plugin-dialog": "^2",
|
||||||
"@tauri-apps/plugin-fs": "2.4.5",
|
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||||
"@tauri-apps/plugin-notification": "2.3.3",
|
"@tauri-apps/plugin-notification": "^2",
|
||||||
"@tauri-apps/plugin-opener": "2.5.3",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-os": "2.3.2",
|
"@tauri-apps/plugin-os": "^2",
|
||||||
"@tauri-apps/plugin-shell": "2.3.5",
|
"@tauri-apps/plugin-shell": "^2.3.4",
|
||||||
"@tauri-apps/plugin-store": "2.4.2",
|
"@tauri-apps/plugin-store": "^2",
|
||||||
"codemirror": "6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"highlight.js": "11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"lucide-svelte": "0.575.0",
|
"marked": "^17.0.1"
|
||||||
"marked": "17.0.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "9.39.3",
|
"@eslint/js": "^9.39.2",
|
||||||
"@sveltejs/adapter-static": "3.0.10",
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
"@sveltejs/kit": "2.53.2",
|
"@sveltejs/kit": "^2.9.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "5.1.1",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tailwindcss/vite": "4.2.1",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tauri-apps/cli": "2.10.0",
|
"@tauri-apps/cli": "^2",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/svelte": "5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"@vitest/coverage-v8": "4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"eslint": "9.39.3",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "3.15.0",
|
"eslint-plugin-svelte": "^3.14.0",
|
||||||
"globals": "17.3.0",
|
"globals": "17.3.0",
|
||||||
"jsdom": "28.1.0",
|
"jsdom": "^27.4.0",
|
||||||
"prettier": "3.8.1",
|
"prettier": "^3.8.0",
|
||||||
"prettier-plugin-svelte": "3.5.0",
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
"svelte": "5.53.5",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "4.4.3",
|
"svelte-check": "^4.0.0",
|
||||||
"tailwindcss": "4.2.1",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "5.9.3",
|
"typescript": "~5.6.2",
|
||||||
"typescript-eslint": "8.56.1",
|
"typescript-eslint": "^8.53.0",
|
||||||
"vite": "6.4.1",
|
"vite": "^6.0.3",
|
||||||
"vitest": "4.0.18"
|
"vitest": "^4.0.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+671
-696
File diff suppressed because it is too large
Load Diff
Generated
+303
-694
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "hikari-desktop"
|
name = "hikari-desktop"
|
||||||
version = "1.10.0"
|
version = "1.1.1"
|
||||||
description = "Hikari - Claude Code Visual Assistant"
|
description = "Hikari - Claude Code Visual Assistant"
|
||||||
authors = ["Naomi Carrigan"]
|
authors = ["Naomi Carrigan"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -31,10 +31,6 @@ tauri-plugin-fs = "2"
|
|||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
semver = "1"
|
semver = "1"
|
||||||
chrono = { version = "0.4.43", features = ["serde"] }
|
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]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows = { version = "0.62", features = [
|
windows = { version = "0.62", features = [
|
||||||
|
|||||||
@@ -28,17 +28,8 @@
|
|||||||
"identifier": "fs:allow-write-file",
|
"identifier": "fs:allow-write-file",
|
||||||
"allow": [{ "path": "**" }]
|
"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-size",
|
||||||
"core:window:allow-set-always-on-top",
|
"core:window:allow-set-always-on-top",
|
||||||
"core:window:allow-inner-size",
|
"core:window:allow-inner-size"
|
||||||
"core:window:allow-hide"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1671,7 +1671,7 @@ pub fn check_message_achievements(
|
|||||||
let mut newly_unlocked = Vec::new();
|
let mut newly_unlocked = Vec::new();
|
||||||
let message_lower = message.to_lowercase();
|
let message_lower = message.to_lowercase();
|
||||||
|
|
||||||
tracing::info!("Checking message achievements for: {}", message);
|
println!("Checking message achievements for: {}", message);
|
||||||
|
|
||||||
// Relationship & Greetings
|
// Relationship & Greetings
|
||||||
if message_lower.contains("good morning") && progress.unlock(AchievementId::GoodMorning) {
|
if message_lower.contains("good morning") && progress.unlock(AchievementId::GoodMorning) {
|
||||||
@@ -1863,18 +1863,18 @@ pub fn check_achievements(
|
|||||||
) -> Vec<AchievementId> {
|
) -> Vec<AchievementId> {
|
||||||
let mut newly_unlocked = Vec::new();
|
let mut newly_unlocked = Vec::new();
|
||||||
|
|
||||||
tracing::info!(
|
println!(
|
||||||
"Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
|
"Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
|
||||||
stats.messages_exchanged,
|
stats.messages_exchanged,
|
||||||
stats.total_input_tokens + stats.total_output_tokens,
|
stats.total_input_tokens + stats.total_output_tokens,
|
||||||
stats.code_blocks_generated
|
stats.code_blocks_generated
|
||||||
);
|
);
|
||||||
tracing::info!("Currently unlocked: {:?}", progress.unlocked);
|
println!("Currently unlocked: {:?}", progress.unlocked);
|
||||||
|
|
||||||
// Token milestones
|
// Token milestones
|
||||||
let total_tokens = stats.total_input_tokens + stats.total_output_tokens;
|
let total_tokens = stats.total_input_tokens + stats.total_output_tokens;
|
||||||
if total_tokens >= 1_000 && progress.unlock(AchievementId::FirstSteps) {
|
if total_tokens >= 1_000 && progress.unlock(AchievementId::FirstSteps) {
|
||||||
tracing::info!("Unlocked FirstSteps achievement!");
|
println!("Unlocked FirstSteps achievement!");
|
||||||
newly_unlocked.push(AchievementId::FirstSteps);
|
newly_unlocked.push(AchievementId::FirstSteps);
|
||||||
}
|
}
|
||||||
if total_tokens >= 10_000 && progress.unlock(AchievementId::GrowingStrong) {
|
if total_tokens >= 10_000 && progress.unlock(AchievementId::GrowingStrong) {
|
||||||
@@ -1935,7 +1935,6 @@ pub fn check_achievements(
|
|||||||
let search_count: u64 = search_tools
|
let search_count: u64 = search_tools
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|tool| stats.tools_usage.get(*tool))
|
.filter_map(|tool| stats.tools_usage.get(*tool))
|
||||||
.map(|t| t.call_count)
|
|
||||||
.sum();
|
.sum();
|
||||||
if search_count >= 50 && progress.unlock(AchievementId::Explorer) {
|
if search_count >= 50 && progress.unlock(AchievementId::Explorer) {
|
||||||
newly_unlocked.push(AchievementId::Explorer);
|
newly_unlocked.push(AchievementId::Explorer);
|
||||||
@@ -1989,25 +1988,25 @@ pub fn check_achievements(
|
|||||||
// TODO: Track different Claude models used
|
// TODO: Track different Claude models used
|
||||||
|
|
||||||
// Tool mastery achievements
|
// Tool mastery achievements
|
||||||
if let Some(bash_stats) = stats.tools_usage.get("Bash") {
|
if let Some(bash_count) = stats.tools_usage.get("Bash") {
|
||||||
if bash_stats.call_count >= 50 && progress.unlock(AchievementId::BashMaster) {
|
if *bash_count >= 50 && progress.unlock(AchievementId::BashMaster) {
|
||||||
newly_unlocked.push(AchievementId::BashMaster);
|
newly_unlocked.push(AchievementId::BashMaster);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(read_stats) = stats.tools_usage.get("Read") {
|
if let Some(read_count) = stats.tools_usage.get("Read") {
|
||||||
if read_stats.call_count >= 100 && progress.unlock(AchievementId::FileExplorer) {
|
if *read_count >= 100 && progress.unlock(AchievementId::FileExplorer) {
|
||||||
newly_unlocked.push(AchievementId::FileExplorer);
|
newly_unlocked.push(AchievementId::FileExplorer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(grep_stats) = stats.tools_usage.get("Grep") {
|
if let Some(grep_count) = stats.tools_usage.get("Grep") {
|
||||||
if grep_stats.call_count >= 50 && progress.unlock(AchievementId::SearchExpert) {
|
if *grep_count >= 50 && progress.unlock(AchievementId::SearchExpert) {
|
||||||
newly_unlocked.push(AchievementId::SearchExpert);
|
newly_unlocked.push(AchievementId::SearchExpert);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Git Guru - check git command usage in Bash
|
// Git Guru - check git command usage in Bash
|
||||||
if let Some(bash_stats) = stats.tools_usage.get("Bash") {
|
if let Some(bash_count) = stats.tools_usage.get("Bash") {
|
||||||
if bash_stats.call_count >= 10 && progress.unlock(AchievementId::GitGuru) {
|
if *bash_count >= 10 && progress.unlock(AchievementId::GitGuru) {
|
||||||
// TODO: More specific git command tracking
|
// TODO: More specific git command tracking
|
||||||
newly_unlocked.push(AchievementId::GitGuru);
|
newly_unlocked.push(AchievementId::GitGuru);
|
||||||
}
|
}
|
||||||
@@ -2056,28 +2055,28 @@ pub fn check_achievements(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// More tool mastery achievements
|
// More tool mastery achievements
|
||||||
if let Some(edit_stats) = stats.tools_usage.get("Edit") {
|
if let Some(edit_count) = stats.tools_usage.get("Edit") {
|
||||||
if edit_stats.call_count >= 100 && progress.unlock(AchievementId::EditMaster) {
|
if *edit_count >= 100 && progress.unlock(AchievementId::EditMaster) {
|
||||||
newly_unlocked.push(AchievementId::EditMaster);
|
newly_unlocked.push(AchievementId::EditMaster);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(write_stats) = stats.tools_usage.get("Write") {
|
if let Some(write_count) = stats.tools_usage.get("Write") {
|
||||||
if write_stats.call_count >= 50 && progress.unlock(AchievementId::WriteMaster) {
|
if *write_count >= 50 && progress.unlock(AchievementId::WriteMaster) {
|
||||||
newly_unlocked.push(AchievementId::WriteMaster);
|
newly_unlocked.push(AchievementId::WriteMaster);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(glob_stats) = stats.tools_usage.get("Glob") {
|
if let Some(glob_count) = stats.tools_usage.get("Glob") {
|
||||||
if glob_stats.call_count >= 100 && progress.unlock(AchievementId::GlobMaster) {
|
if *glob_count >= 100 && progress.unlock(AchievementId::GlobMaster) {
|
||||||
newly_unlocked.push(AchievementId::GlobMaster);
|
newly_unlocked.push(AchievementId::GlobMaster);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(task_stats) = stats.tools_usage.get("Task") {
|
if let Some(task_count) = stats.tools_usage.get("Task") {
|
||||||
if task_stats.call_count >= 50 && progress.unlock(AchievementId::TaskMaster) {
|
if *task_count >= 50 && progress.unlock(AchievementId::TaskMaster) {
|
||||||
newly_unlocked.push(AchievementId::TaskMaster);
|
newly_unlocked.push(AchievementId::TaskMaster);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(web_stats) = stats.tools_usage.get("WebFetch") {
|
if let Some(web_count) = stats.tools_usage.get("WebFetch") {
|
||||||
if web_stats.call_count >= 20 && progress.unlock(AchievementId::WebFetcher) {
|
if *web_count >= 20 && progress.unlock(AchievementId::WebFetcher) {
|
||||||
newly_unlocked.push(AchievementId::WebFetcher);
|
newly_unlocked.push(AchievementId::WebFetcher);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2086,7 +2085,7 @@ pub fn check_achievements(
|
|||||||
.tools_usage
|
.tools_usage
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(name, _)| name.starts_with("mcp__"))
|
.filter(|(name, _)| name.starts_with("mcp__"))
|
||||||
.map(|(_, tool_stats)| tool_stats.call_count)
|
.map(|(_, count)| count)
|
||||||
.sum();
|
.sum();
|
||||||
if mcp_count >= 50 && progress.unlock(AchievementId::McpExplorer) {
|
if mcp_count >= 50 && progress.unlock(AchievementId::McpExplorer) {
|
||||||
newly_unlocked.push(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
|
// Create a serializable version with just the unlocked achievement IDs
|
||||||
let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect();
|
let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect();
|
||||||
|
|
||||||
tracing::info!("Saving achievements: {:?}", unlocked_list);
|
println!("Saving achievements: {:?}", unlocked_list);
|
||||||
|
|
||||||
store.set(
|
store.set(
|
||||||
"unlocked",
|
"unlocked",
|
||||||
@@ -2252,18 +2251,18 @@ pub async fn save_achievements(
|
|||||||
);
|
);
|
||||||
store.save().map_err(|e| e.to_string())?;
|
store.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
tracing::info!("Achievements saved successfully");
|
println!("Achievements saved successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load achievements from persistent store
|
// Load achievements from persistent store
|
||||||
pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
|
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") {
|
let store = match app.store("achievements.json") {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to open achievements store: {}", e);
|
println!("Failed to open achievements store: {}", e);
|
||||||
return AchievementProgress::new();
|
return AchievementProgress::new();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2272,19 +2271,19 @@ pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
|
|||||||
|
|
||||||
// Get unlocked achievements
|
// Get unlocked achievements
|
||||||
if let Some(unlocked_value) = store.get("unlocked") {
|
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) =
|
if let Ok(unlocked_list) =
|
||||||
serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone())
|
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 {
|
for achievement_id in unlocked_list {
|
||||||
progress.unlocked.insert(achievement_id);
|
progress.unlocked.insert(achievement_id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tracing::error!("Failed to parse unlocked achievements");
|
println!("Failed to parse unlocked achievements");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tracing::info!("No unlocked achievements found in store");
|
println!("No unlocked achievements found in store");
|
||||||
}
|
}
|
||||||
|
|
||||||
progress
|
progress
|
||||||
@@ -2324,15 +2323,6 @@ mod tests {
|
|||||||
morning_sessions: 0,
|
morning_sessions: 0,
|
||||||
night_sessions: 0,
|
night_sessions: 0,
|
||||||
last_session_date: None,
|
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(),
|
achievements: AchievementProgress::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2743,21 +2733,12 @@ mod tests {
|
|||||||
// check_achievements tests - Tool Usage
|
// 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]
|
#[test]
|
||||||
fn test_check_achievements_first_tool() {
|
fn test_check_achievements_first_tool() {
|
||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
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);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::FirstTool));
|
assert!(newly.contains(&AchievementId::FirstTool));
|
||||||
@@ -2768,11 +2749,11 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Read".to_string(), tool_stats(1));
|
stats.tools_usage.insert("Read".to_string(), 1);
|
||||||
stats.tools_usage.insert("Write".to_string(), tool_stats(1));
|
stats.tools_usage.insert("Write".to_string(), 1);
|
||||||
stats.tools_usage.insert("Edit".to_string(), tool_stats(1));
|
stats.tools_usage.insert("Edit".to_string(), 1);
|
||||||
stats.tools_usage.insert("Bash".to_string(), tool_stats(1));
|
stats.tools_usage.insert("Bash".to_string(), 1);
|
||||||
stats.tools_usage.insert("Grep".to_string(), tool_stats(1));
|
stats.tools_usage.insert("Grep".to_string(), 1);
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::Toolsmith));
|
assert!(newly.contains(&AchievementId::Toolsmith));
|
||||||
@@ -2784,7 +2765,7 @@ mod tests {
|
|||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
for i in 0..10 {
|
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);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
@@ -2796,7 +2777,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
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);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::BashMaster));
|
assert!(newly.contains(&AchievementId::BashMaster));
|
||||||
@@ -2807,7 +2788,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
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);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::FileExplorer));
|
assert!(newly.contains(&AchievementId::FileExplorer));
|
||||||
@@ -2818,7 +2799,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
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);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::SearchExpert));
|
assert!(newly.contains(&AchievementId::SearchExpert));
|
||||||
@@ -2829,7 +2810,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
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);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::EditMaster));
|
assert!(newly.contains(&AchievementId::EditMaster));
|
||||||
@@ -2840,7 +2821,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
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);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::WriteMaster));
|
assert!(newly.contains(&AchievementId::WriteMaster));
|
||||||
@@ -2851,7 +2832,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
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);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::GlobMaster));
|
assert!(newly.contains(&AchievementId::GlobMaster));
|
||||||
@@ -2862,7 +2843,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
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);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::TaskMaster));
|
assert!(newly.contains(&AchievementId::TaskMaster));
|
||||||
@@ -2873,7 +2854,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
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);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::WebFetcher));
|
assert!(newly.contains(&AchievementId::WebFetcher));
|
||||||
@@ -2884,8 +2865,8 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("mcp__github__create_issue".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(), tool_stats(25));
|
stats.tools_usage.insert("mcp__notion__search".to_string(), 25);
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::McpExplorer));
|
assert!(newly.contains(&AchievementId::McpExplorer));
|
||||||
@@ -2900,8 +2881,8 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Grep".to_string(), tool_stats(30));
|
stats.tools_usage.insert("Grep".to_string(), 30);
|
||||||
stats.tools_usage.insert("Glob".to_string(), tool_stats(20));
|
stats.tools_usage.insert("Glob".to_string(), 20);
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::Explorer));
|
assert!(newly.contains(&AchievementId::Explorer));
|
||||||
@@ -2912,9 +2893,9 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Grep".to_string(), tool_stats(200));
|
stats.tools_usage.insert("Grep".to_string(), 200);
|
||||||
stats.tools_usage.insert("Glob".to_string(), tool_stats(200));
|
stats.tools_usage.insert("Glob".to_string(), 200);
|
||||||
stats.tools_usage.insert("Task".to_string(), tool_stats(100));
|
stats.tools_usage.insert("Task".to_string(), 100);
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::MasterSearcher));
|
assert!(newly.contains(&AchievementId::MasterSearcher));
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ use std::collections::HashMap;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
|
|
||||||
use crate::commands::record_session;
|
|
||||||
use crate::config::ClaudeStartOptions;
|
use crate::config::ClaudeStartOptions;
|
||||||
use crate::stats::UsageStats;
|
use crate::stats::UsageStats;
|
||||||
use crate::wsl_bridge::WslBridge;
|
use crate::wsl_bridge::WslBridge;
|
||||||
@@ -54,12 +53,7 @@ impl BridgeManager {
|
|||||||
.or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string()));
|
.or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string()));
|
||||||
|
|
||||||
// Start the Claude process
|
// Start the Claude process
|
||||||
bridge.start(app.clone(), options)?;
|
bridge.start(app, options)?;
|
||||||
|
|
||||||
// Record session start for cost tracking
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
record_session(&app).await;
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -173,127 +167,3 @@ pub type SharedBridgeManager = Arc<Mutex<BridgeManager>>;
|
|||||||
pub fn create_shared_bridge_manager() -> SharedBridgeManager {
|
pub fn create_shared_bridge_manager() -> SharedBridgeManager {
|
||||||
Arc::new(Mutex::new(BridgeManager::new()))
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+33
-2241
File diff suppressed because it is too large
Load Diff
+6
-137
@@ -25,19 +25,9 @@ pub struct ClaudeStartOptions {
|
|||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub resume_session_id: Option<String>,
|
pub resume_session_id: Option<String>,
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub use_worktree: bool,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub disable_1m_context: bool,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub max_output_tokens: Option<u64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
|
||||||
pub struct HikariConfig {
|
pub struct HikariConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
@@ -81,6 +71,9 @@ pub struct HikariConfig {
|
|||||||
#[serde(default = "default_font_size")]
|
#[serde(default = "default_font_size")]
|
||||||
pub font_size: u32,
|
pub font_size: u32,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub minimize_to_tray: bool,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub streamer_mode: bool,
|
pub streamer_mode: bool,
|
||||||
|
|
||||||
@@ -103,61 +96,6 @@ pub struct HikariConfig {
|
|||||||
// Custom theme colors
|
// Custom theme colors
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub custom_theme_colors: CustomThemeColors,
|
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,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub use_worktree: bool,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub disable_1m_context: bool,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub max_output_tokens: Option<u64>,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub trusted_workspaces: Vec<String>,
|
|
||||||
|
|
||||||
// Background image settings
|
|
||||||
#[serde(default)]
|
|
||||||
pub background_image_path: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default = "default_background_image_opacity")]
|
|
||||||
pub background_image_opacity: f32,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub show_thinking_blocks: bool,
|
|
||||||
|
|
||||||
// Custom terminal font settings
|
|
||||||
#[serde(default)]
|
|
||||||
pub custom_font_path: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub custom_font_family: Option<String>,
|
|
||||||
|
|
||||||
// Custom UI font settings
|
|
||||||
#[serde(default)]
|
|
||||||
pub custom_ui_font_path: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub custom_ui_font_family: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HikariConfig {
|
impl Default for HikariConfig {
|
||||||
@@ -177,6 +115,7 @@ impl Default for HikariConfig {
|
|||||||
update_checks_enabled: true,
|
update_checks_enabled: true,
|
||||||
character_panel_width: None,
|
character_panel_width: None,
|
||||||
font_size: 14,
|
font_size: 14,
|
||||||
|
minimize_to_tray: false,
|
||||||
streamer_mode: false,
|
streamer_mode: false,
|
||||||
streamer_hide_paths: false,
|
streamer_hide_paths: false,
|
||||||
compact_mode: false,
|
compact_mode: false,
|
||||||
@@ -184,23 +123,6 @@ impl Default for HikariConfig {
|
|||||||
profile_avatar_path: None,
|
profile_avatar_path: None,
|
||||||
profile_bio: None,
|
profile_bio: None,
|
||||||
custom_theme_colors: CustomThemeColors::default(),
|
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,
|
|
||||||
use_worktree: false,
|
|
||||||
disable_1m_context: false,
|
|
||||||
max_output_tokens: None,
|
|
||||||
trusted_workspaces: Vec::new(),
|
|
||||||
background_image_path: None,
|
|
||||||
background_image_opacity: 0.3,
|
|
||||||
show_thinking_blocks: false,
|
|
||||||
custom_font_path: None,
|
|
||||||
custom_font_family: None,
|
|
||||||
custom_ui_font_path: None,
|
|
||||||
custom_ui_font_family: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,30 +147,6 @@ fn default_font_size() -> u32 {
|
|||||||
14
|
14
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_budget_action() -> BudgetAction {
|
|
||||||
BudgetAction::Warn
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_budget_warning_threshold() -> f32 {
|
|
||||||
0.8
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_discord_rpc_enabled() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_background_image_opacity() -> f32 {
|
|
||||||
0.3
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum BudgetAction {
|
|
||||||
#[default]
|
|
||||||
Warn,
|
|
||||||
Block,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum Theme {
|
pub enum Theme {
|
||||||
@@ -299,6 +197,7 @@ mod tests {
|
|||||||
assert!(config.update_checks_enabled);
|
assert!(config.update_checks_enabled);
|
||||||
assert!(config.character_panel_width.is_none());
|
assert!(config.character_panel_width.is_none());
|
||||||
assert_eq!(config.font_size, 14);
|
assert_eq!(config.font_size, 14);
|
||||||
|
assert!(!config.minimize_to_tray);
|
||||||
assert!(!config.streamer_mode);
|
assert!(!config.streamer_mode);
|
||||||
assert!(!config.streamer_hide_paths);
|
assert!(!config.streamer_hide_paths);
|
||||||
assert!(!config.compact_mode);
|
assert!(!config.compact_mode);
|
||||||
@@ -306,20 +205,6 @@ mod tests {
|
|||||||
assert!(config.profile_avatar_path.is_none());
|
assert!(config.profile_avatar_path.is_none());
|
||||||
assert!(config.profile_bio.is_none());
|
assert!(config.profile_bio.is_none());
|
||||||
assert_eq!(config.custom_theme_colors, CustomThemeColors::default());
|
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);
|
|
||||||
assert!(!config.use_worktree);
|
|
||||||
assert!(!config.disable_1m_context);
|
|
||||||
assert!(config.trusted_workspaces.is_empty());
|
|
||||||
assert!(!config.show_thinking_blocks);
|
|
||||||
assert!(config.custom_font_path.is_none());
|
|
||||||
assert!(config.custom_font_family.is_none());
|
|
||||||
assert!(config.custom_ui_font_path.is_none());
|
|
||||||
assert!(config.custom_ui_font_family.is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -339,6 +224,7 @@ mod tests {
|
|||||||
update_checks_enabled: true,
|
update_checks_enabled: true,
|
||||||
character_panel_width: Some(400),
|
character_panel_width: Some(400),
|
||||||
font_size: 16,
|
font_size: 16,
|
||||||
|
minimize_to_tray: true,
|
||||||
streamer_mode: false,
|
streamer_mode: false,
|
||||||
streamer_hide_paths: false,
|
streamer_hide_paths: false,
|
||||||
compact_mode: false,
|
compact_mode: false,
|
||||||
@@ -346,23 +232,6 @@ mod tests {
|
|||||||
profile_avatar_path: None,
|
profile_avatar_path: None,
|
||||||
profile_bio: Some("A test bio".to_string()),
|
profile_bio: Some("A test bio".to_string()),
|
||||||
custom_theme_colors: CustomThemeColors::default(),
|
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,
|
|
||||||
use_worktree: true,
|
|
||||||
disable_1m_context: false,
|
|
||||||
max_output_tokens: Some(32000),
|
|
||||||
trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()],
|
|
||||||
background_image_path: Some("/home/naomi/bg.png".to_string()),
|
|
||||||
background_image_opacity: 0.25,
|
|
||||||
show_thinking_blocks: true,
|
|
||||||
custom_font_path: Some("/home/naomi/.fonts/MyFont.ttf".to_string()),
|
|
||||||
custom_font_family: Some("MyFont".to_string()),
|
|
||||||
custom_ui_font_path: None,
|
|
||||||
custom_ui_font_family: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
use chrono::Utc;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tauri::AppHandle;
|
|
||||||
use tauri_plugin_store::StoreExt;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
const DRAFTS_STORE_FILE: &str = "hikari-drafts.json";
|
|
||||||
const DRAFTS_STORE_KEY: &str = "drafts";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Draft {
|
|
||||||
pub id: String,
|
|
||||||
pub content: String,
|
|
||||||
pub saved_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_all_drafts(app: &AppHandle) -> Result<Vec<Draft>, String> {
|
|
||||||
let store = app
|
|
||||||
.store(DRAFTS_STORE_FILE)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
match store.get(DRAFTS_STORE_KEY) {
|
|
||||||
Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()),
|
|
||||||
None => Ok(vec![]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_all_drafts(app: &AppHandle, drafts: &[Draft]) -> Result<(), String> {
|
|
||||||
let store = app
|
|
||||||
.store(DRAFTS_STORE_FILE)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let value = serde_json::to_value(drafts).map_err(|e| e.to_string())?;
|
|
||||||
store.set(DRAFTS_STORE_KEY, value);
|
|
||||||
store.save().map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn list_drafts(app: AppHandle) -> Result<Vec<Draft>, String> {
|
|
||||||
let mut drafts = load_all_drafts(&app)?;
|
|
||||||
// Sort newest first — ISO 8601 timestamps sort lexicographically
|
|
||||||
drafts.sort_by(|a, b| b.saved_at.cmp(&a.saved_at));
|
|
||||||
Ok(drafts)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn save_draft(app: AppHandle, content: String) -> Result<Draft, String> {
|
|
||||||
let mut drafts = load_all_drafts(&app)?;
|
|
||||||
|
|
||||||
let draft = Draft {
|
|
||||||
id: Uuid::new_v4().to_string(),
|
|
||||||
content,
|
|
||||||
saved_at: Utc::now().to_rfc3339(),
|
|
||||||
};
|
|
||||||
|
|
||||||
drafts.push(draft.clone());
|
|
||||||
save_all_drafts(&app, &drafts)?;
|
|
||||||
|
|
||||||
Ok(draft)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn delete_draft(app: AppHandle, draft_id: String) -> Result<(), String> {
|
|
||||||
let mut drafts = load_all_drafts(&app)?;
|
|
||||||
drafts.retain(|d| d.id != draft_id);
|
|
||||||
save_all_drafts(&app, &drafts)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn delete_all_drafts(app: AppHandle) -> Result<(), String> {
|
|
||||||
save_all_drafts(&app, &[])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn make_draft(id: &str, content: &str, saved_at: &str) -> Draft {
|
|
||||||
Draft {
|
|
||||||
id: id.to_string(),
|
|
||||||
content: content.to_string(),
|
|
||||||
saved_at: saved_at.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_draft_serialization() {
|
|
||||||
let draft = make_draft("test-id", "Hello world", "2026-01-01T00:00:00+00:00");
|
|
||||||
let json = serde_json::to_string(&draft).expect("Failed to serialize");
|
|
||||||
let parsed: Draft = serde_json::from_str(&json).expect("Failed to deserialize");
|
|
||||||
|
|
||||||
assert_eq!(parsed.id, draft.id);
|
|
||||||
assert_eq!(parsed.content, draft.content);
|
|
||||||
assert_eq!(parsed.saved_at, draft.saved_at);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_draft_clone() {
|
|
||||||
let original = make_draft("clone-id", "Clone me", "2026-01-01T00:00:00+00:00");
|
|
||||||
let cloned = original.clone();
|
|
||||||
|
|
||||||
assert_eq!(original.id, cloned.id);
|
|
||||||
assert_eq!(original.content, cloned.content);
|
|
||||||
assert_eq!(original.saved_at, cloned.saved_at);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sort_newest_first() {
|
|
||||||
let mut drafts = [
|
|
||||||
make_draft("a", "First", "2026-01-01T00:00:00+00:00"),
|
|
||||||
make_draft("b", "Third", "2026-01-03T00:00:00+00:00"),
|
|
||||||
make_draft("c", "Second", "2026-01-02T00:00:00+00:00"),
|
|
||||||
];
|
|
||||||
|
|
||||||
drafts.sort_by(|a, b| b.saved_at.cmp(&a.saved_at));
|
|
||||||
|
|
||||||
assert_eq!(drafts[0].id, "b");
|
|
||||||
assert_eq!(drafts[1].id, "c");
|
|
||||||
assert_eq!(drafts[2].id, "a");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_retain_excludes_deleted() {
|
|
||||||
let mut drafts = vec![
|
|
||||||
make_draft("keep-1", "Keep me", "2026-01-01T00:00:00+00:00"),
|
|
||||||
make_draft("delete-me", "Delete me", "2026-01-02T00:00:00+00:00"),
|
|
||||||
make_draft("keep-2", "Keep me too", "2026-01-03T00:00:00+00:00"),
|
|
||||||
];
|
|
||||||
|
|
||||||
let target_id = "delete-me".to_string();
|
|
||||||
drafts.retain(|d| d.id != target_id);
|
|
||||||
|
|
||||||
assert_eq!(drafts.len(), 2);
|
|
||||||
assert!(drafts.iter().all(|d| d.id != "delete-me"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_find_by_id() {
|
|
||||||
let drafts = [
|
|
||||||
make_draft("draft-1", "First draft", "2026-01-01T00:00:00+00:00"),
|
|
||||||
make_draft("draft-2", "Second draft", "2026-01-02T00:00:00+00:00"),
|
|
||||||
make_draft("draft-3", "Third draft", "2026-01-03T00:00:00+00:00"),
|
|
||||||
];
|
|
||||||
|
|
||||||
let found = drafts.iter().find(|d| d.id == "draft-2");
|
|
||||||
assert!(found.is_some());
|
|
||||||
assert_eq!(found.unwrap().content, "Second draft");
|
|
||||||
|
|
||||||
let not_found = drafts.iter().find(|d| d.id == "draft-999");
|
|
||||||
assert!(not_found.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_multiline_content() {
|
|
||||||
let content = "Line 1\nLine 2\nLine 3";
|
|
||||||
let draft = make_draft("multi", content, "2026-01-01T00:00:00+00:00");
|
|
||||||
|
|
||||||
assert!(draft.content.contains('\n'));
|
|
||||||
assert_eq!(draft.content.split('\n').count(), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_after_delete_all() {
|
|
||||||
let mut drafts = vec![
|
|
||||||
make_draft("a", "A", "2026-01-01T00:00:00+00:00"),
|
|
||||||
make_draft("b", "B", "2026-01-02T00:00:00+00:00"),
|
|
||||||
];
|
|
||||||
|
|
||||||
drafts.clear();
|
|
||||||
|
|
||||||
assert!(drafts.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_uuid_format() {
|
|
||||||
// UUIDs should be non-empty and contain hyphens
|
|
||||||
let id = Uuid::new_v4().to_string();
|
|
||||||
assert!(!id.is_empty());
|
|
||||||
assert!(id.contains('-'));
|
|
||||||
assert_eq!(id.len(), 36);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_timestamp_is_rfc3339() {
|
|
||||||
let ts = Utc::now().to_rfc3339();
|
|
||||||
// RFC 3339 timestamps contain T and + or Z
|
|
||||||
assert!(ts.contains('T'));
|
|
||||||
assert!(ts.ends_with("+00:00") || ts.ends_with('Z'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use crate::process_ext::HideWindow;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct GitStatus {
|
pub struct GitStatus {
|
||||||
pub is_repo: bool,
|
pub is_repo: bool,
|
||||||
@@ -39,7 +37,6 @@ pub struct GitLogEntry {
|
|||||||
|
|
||||||
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
|
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
.hide_window()
|
|
||||||
.args(args)
|
.args(args)
|
||||||
.current_dir(working_dir)
|
.current_dir(working_dir)
|
||||||
.output()
|
.output()
|
||||||
|
|||||||
+8
-69
@@ -3,19 +3,13 @@ mod bridge_manager;
|
|||||||
mod clipboard;
|
mod clipboard;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
mod cost_tracking;
|
|
||||||
mod debug_logger;
|
|
||||||
mod discord_rpc;
|
|
||||||
mod drafts;
|
|
||||||
mod git;
|
mod git;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
mod process_ext;
|
|
||||||
mod quick_actions;
|
mod quick_actions;
|
||||||
mod sessions;
|
mod sessions;
|
||||||
mod snippets;
|
mod snippets;
|
||||||
mod stats;
|
mod stats;
|
||||||
mod temp_manager;
|
mod temp_manager;
|
||||||
mod tool_cache;
|
|
||||||
mod tray;
|
mod tray;
|
||||||
mod types;
|
mod types;
|
||||||
mod vbs_notification;
|
mod vbs_notification;
|
||||||
@@ -27,20 +21,14 @@ use bridge_manager::create_shared_bridge_manager;
|
|||||||
use clipboard::*;
|
use clipboard::*;
|
||||||
use commands::load_saved_achievements;
|
use commands::load_saved_achievements;
|
||||||
use commands::*;
|
use commands::*;
|
||||||
use debug_logger::TauriLogLayer;
|
|
||||||
use discord_rpc::DiscordRpcManager;
|
|
||||||
use drafts::*;
|
|
||||||
use git::*;
|
use git::*;
|
||||||
use notifications::*;
|
use notifications::*;
|
||||||
use quick_actions::*;
|
use quick_actions::*;
|
||||||
use sessions::*;
|
use sessions::*;
|
||||||
use snippets::*;
|
use snippets::*;
|
||||||
use std::sync::Arc;
|
use tauri::Manager;
|
||||||
use tauri::{Emitter, Manager};
|
|
||||||
use temp_manager::create_shared_temp_manager;
|
use temp_manager::create_shared_temp_manager;
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
use tray::{setup_tray, should_minimize_to_tray};
|
||||||
use tracing_subscriber::util::SubscriberInitExt;
|
|
||||||
use tray::setup_tray;
|
|
||||||
use vbs_notification::*;
|
use vbs_notification::*;
|
||||||
use windows_toast::*;
|
use windows_toast::*;
|
||||||
use wsl_notifications::*;
|
use wsl_notifications::*;
|
||||||
@@ -49,7 +37,6 @@ use wsl_notifications::*;
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
let bridge_manager = create_shared_bridge_manager();
|
let bridge_manager = create_shared_bridge_manager();
|
||||||
let temp_manager = create_shared_temp_manager().expect("Failed to create temp file 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()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
@@ -63,45 +50,33 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.manage(bridge_manager.clone())
|
.manage(bridge_manager.clone())
|
||||||
.manage(temp_manager.clone())
|
.manage(temp_manager.clone())
|
||||||
.manage(discord_rpc.clone())
|
|
||||||
.setup(move |app| {
|
.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
|
// Initialize the app handle in the bridge manager
|
||||||
bridge_manager.lock().set_app_handle(app.handle().clone());
|
bridge_manager.lock().set_app_handle(app.handle().clone());
|
||||||
|
|
||||||
// Clean up any orphaned temp files from previous sessions
|
// Clean up any orphaned temp files from previous sessions
|
||||||
if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() {
|
if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() {
|
||||||
if count > 0 {
|
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
|
// Set up system tray
|
||||||
if let Err(e) = setup_tray(app.handle()) {
|
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();
|
let main_window = app.get_webview_window("main").unwrap();
|
||||||
main_window.on_window_event({
|
main_window.on_window_event({
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
move |event| {
|
move |event| {
|
||||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
// Always prevent default close - let frontend handle it
|
if should_minimize_to_tray(&app_handle) {
|
||||||
api.prevent_close();
|
api.prevent_close();
|
||||||
|
|
||||||
// Emit event to frontend to show confirmation modal
|
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
let _ = window.emit("window-close-requested", ());
|
let _ = window.hide();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,7 +98,6 @@ pub fn run() {
|
|||||||
get_persisted_stats,
|
get_persisted_stats,
|
||||||
load_saved_achievements,
|
load_saved_achievements,
|
||||||
answer_question,
|
answer_question,
|
||||||
check_workspace_hooks,
|
|
||||||
send_windows_notification,
|
send_windows_notification,
|
||||||
send_simple_notification,
|
send_simple_notification,
|
||||||
send_windows_toast,
|
send_windows_toast,
|
||||||
@@ -185,41 +159,6 @@ pub fn run() {
|
|||||||
delete_file,
|
delete_file,
|
||||||
delete_directory,
|
delete_directory,
|
||||||
rename_path,
|
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,
|
|
||||||
get_auth_status,
|
|
||||||
auth_login,
|
|
||||||
auth_logout,
|
|
||||||
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,
|
|
||||||
list_drafts,
|
|
||||||
save_draft,
|
|
||||||
delete_draft,
|
|
||||||
delete_all_drafts,
|
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
+29
-324
@@ -1,11 +1,34 @@
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use tauri::command;
|
use tauri::command;
|
||||||
|
|
||||||
use crate::process_ext::HideWindow;
|
#[command]
|
||||||
|
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
|
||||||
/// Generate PowerShell script for Windows Toast Notification
|
// Use notify-send for Linux/WSL
|
||||||
fn generate_powershell_toast_script(title: &str, body: &str) -> String {
|
let output = Command::new("notify-send")
|
||||||
|
.arg(&title)
|
||||||
|
.arg(&body)
|
||||||
|
.arg("--urgency=normal")
|
||||||
|
.arg("--app-name=Hikari Desktop")
|
||||||
|
.output()
|
||||||
|
.map_err(|e| {
|
||||||
format!(
|
format!(
|
||||||
|
"Failed to execute notify-send: {}. Make sure libnotify-bin is installed.",
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let error = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!("notify-send failed: {}", error));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn send_windows_notification(title: String, body: String) -> Result<(), String> {
|
||||||
|
// Create PowerShell script for Windows Toast Notification
|
||||||
|
let ps_script = format!(
|
||||||
r#"
|
r#"
|
||||||
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
|
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
|
||||||
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
|
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
|
||||||
@@ -32,87 +55,10 @@ $toast = New-Object Windows.UI.Notifications.ToastNotification $xml
|
|||||||
"#,
|
"#,
|
||||||
title.replace("\"", "`\""),
|
title.replace("\"", "`\""),
|
||||||
body.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
|
|
||||||
let output = Command::new("notify-send")
|
|
||||||
.hide_window()
|
|
||||||
.arg(&title)
|
|
||||||
.arg(&body)
|
|
||||||
.arg("--urgency=normal")
|
|
||||||
.arg("--app-name=Hikari Desktop")
|
|
||||||
.output()
|
|
||||||
.map_err(|e| {
|
|
||||||
format!(
|
|
||||||
"Failed to execute notify-send: {}. Make sure libnotify-bin is installed.",
|
|
||||||
e
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let error = String::from_utf8_lossy(&output.stderr);
|
|
||||||
return Err(format!("notify-send failed: {}", error));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[command]
|
|
||||||
pub async fn send_windows_notification(title: String, body: String) -> Result<(), String> {
|
|
||||||
// Create PowerShell script for Windows Toast Notification
|
|
||||||
let ps_script = generate_powershell_toast_script(&title, &body);
|
|
||||||
|
|
||||||
// Try PowerShell Core first (pwsh), then fall back to Windows PowerShell
|
// Try PowerShell Core first (pwsh), then fall back to Windows PowerShell
|
||||||
let output = Command::new("pwsh.exe")
|
let output = Command::new("pwsh.exe")
|
||||||
.hide_window()
|
|
||||||
.arg("-NoProfile")
|
.arg("-NoProfile")
|
||||||
.arg("-WindowStyle")
|
.arg("-WindowStyle")
|
||||||
.arg("Hidden")
|
.arg("Hidden")
|
||||||
@@ -121,7 +67,6 @@ pub async fn send_windows_notification(title: String, body: String) -> Result<()
|
|||||||
.output()
|
.output()
|
||||||
.or_else(|_| {
|
.or_else(|_| {
|
||||||
Command::new("powershell.exe")
|
Command::new("powershell.exe")
|
||||||
.hide_window()
|
|
||||||
.arg("-NoProfile")
|
.arg("-NoProfile")
|
||||||
.arg("-WindowStyle")
|
.arg("-WindowStyle")
|
||||||
.arg("Hidden")
|
.arg("Hidden")
|
||||||
@@ -142,10 +87,9 @@ pub async fn send_windows_notification(title: String, body: String) -> Result<()
|
|||||||
// Alternative: Use Windows built-in MSG command for simple notifications
|
// Alternative: Use Windows built-in MSG command for simple notifications
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn send_simple_notification(title: String, body: String) -> Result<(), String> {
|
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")
|
Command::new("cmd.exe")
|
||||||
.hide_window()
|
|
||||||
.arg("/c")
|
.arg("/c")
|
||||||
.arg("msg")
|
.arg("msg")
|
||||||
.arg("*")
|
.arg("*")
|
||||||
@@ -155,242 +99,3 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(),
|
|||||||
|
|
||||||
Ok(())
|
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
use std::process::Command;
|
|
||||||
|
|
||||||
/// Extension trait for `Command` that hides the console window on Windows.
|
|
||||||
///
|
|
||||||
/// On non-Windows platforms this is a no-op, so callers can unconditionally
|
|
||||||
/// chain `.hide_window()` without any `#[cfg]` guards at the call sites.
|
|
||||||
pub trait HideWindow {
|
|
||||||
fn hide_window(&mut self) -> &mut Self;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HideWindow for Command {
|
|
||||||
fn hide_window(&mut self) -> &mut Self {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
||||||
self.creation_flags(CREATE_NO_WINDOW);
|
|
||||||
}
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+49
-738
@@ -5,113 +5,6 @@ use std::collections::HashMap;
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tauri_plugin_store::StoreExt;
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct UsageStats {
|
pub struct UsageStats {
|
||||||
pub total_input_tokens: u64,
|
pub total_input_tokens: u64,
|
||||||
@@ -131,8 +24,8 @@ pub struct UsageStats {
|
|||||||
pub session_files_edited: u64,
|
pub session_files_edited: u64,
|
||||||
pub files_created: u64,
|
pub files_created: u64,
|
||||||
pub session_files_created: u64,
|
pub session_files_created: u64,
|
||||||
pub tools_usage: HashMap<String, ToolTokenStats>,
|
pub tools_usage: HashMap<String, u64>,
|
||||||
pub session_tools_usage: HashMap<String, ToolTokenStats>,
|
pub session_tools_usage: HashMap<String, u64>,
|
||||||
pub session_duration_seconds: u64,
|
pub session_duration_seconds: u64,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub session_start: Option<Instant>,
|
pub session_start: Option<Instant>,
|
||||||
@@ -145,28 +38,9 @@ pub struct UsageStats {
|
|||||||
pub night_sessions: u64, // Sessions started after 10 PM
|
pub night_sessions: u64, // Sessions started after 10 PM
|
||||||
pub last_session_date: Option<String>, // ISO date string for streak tracking
|
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
|
// Achievement tracking
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub achievements: AchievementProgress,
|
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 {
|
impl UsageStats {
|
||||||
@@ -176,138 +50,17 @@ impl UsageStats {
|
|||||||
stats
|
stats
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_usage(
|
pub fn add_usage(&mut self, input_tokens: u64, output_tokens: u64, model: &str) {
|
||||||
&mut self,
|
|
||||||
input_tokens: u64,
|
|
||||||
output_tokens: u64,
|
|
||||||
model: &str,
|
|
||||||
cache_creation_tokens: Option<u64>,
|
|
||||||
cache_read_tokens: Option<u64>,
|
|
||||||
) {
|
|
||||||
self.total_input_tokens += input_tokens;
|
self.total_input_tokens += input_tokens;
|
||||||
self.total_output_tokens += output_tokens;
|
self.total_output_tokens += output_tokens;
|
||||||
self.session_input_tokens += input_tokens;
|
self.session_input_tokens += input_tokens;
|
||||||
self.session_output_tokens += output_tokens;
|
self.session_output_tokens += output_tokens;
|
||||||
|
|
||||||
let cost = calculate_cost(
|
let cost = calculate_cost(input_tokens, output_tokens, model);
|
||||||
input_tokens,
|
|
||||||
output_tokens,
|
|
||||||
model,
|
|
||||||
cache_creation_tokens,
|
|
||||||
cache_read_tokens,
|
|
||||||
);
|
|
||||||
self.total_cost_usd += cost;
|
self.total_cost_usd += cost;
|
||||||
self.session_cost_usd += cost;
|
self.session_cost_usd += cost;
|
||||||
|
|
||||||
self.model = Some(model.to_string());
|
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) {
|
pub fn reset_session(&mut self) {
|
||||||
@@ -323,13 +76,6 @@ impl UsageStats {
|
|||||||
self.session_start = Some(Instant::now());
|
self.session_start = Some(Instant::now());
|
||||||
self.achievements.start_session();
|
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
|
// Track session start for achievements
|
||||||
self.track_session_start();
|
self.track_session_start();
|
||||||
}
|
}
|
||||||
@@ -393,32 +139,11 @@ impl UsageStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn increment_tool_usage(&mut self, tool_name: &str) {
|
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())
|
.entry(tool_name.to_string())
|
||||||
.or_default()
|
.or_insert(0) += 1;
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_session_duration(&mut self) -> u64 {
|
pub fn get_session_duration(&mut self) -> u64 {
|
||||||
@@ -459,16 +184,7 @@ impl UsageStats {
|
|||||||
morning_sessions: self.morning_sessions,
|
morning_sessions: self.morning_sessions,
|
||||||
night_sessions: self.night_sessions,
|
night_sessions: self.night_sessions,
|
||||||
last_session_date: self.last_session_date.clone(),
|
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
|
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)
|
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
|
// Pricing as of January 2025
|
||||||
// https://platform.claude.com/docs/en/about-claude/models/overview
|
// https://www.anthropic.com/pricing
|
||||||
// Cache pricing: https://platform.claude.com/docs/en/build-with-claude/prompt-caching
|
fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
|
||||||
pub fn calculate_cost(
|
|
||||||
input_tokens: u64,
|
|
||||||
output_tokens: u64,
|
|
||||||
model: &str,
|
|
||||||
cache_creation_tokens: Option<u64>,
|
|
||||||
cache_read_tokens: Option<u64>,
|
|
||||||
) -> f64 {
|
|
||||||
let (input_price_per_million, output_price_per_million) = match model {
|
let (input_price_per_million, output_price_per_million) = match model {
|
||||||
// Current generation (Claude 4.6)
|
// Opus 4.5
|
||||||
"claude-opus-4-6" => (5.0, 25.0),
|
"claude-opus-4-5-20251101" => (15.0, 75.0),
|
||||||
"claude-sonnet-4-6" => (3.0, 15.0),
|
|
||||||
|
|
||||||
// Previous generation (Claude 4.5)
|
// Opus 4
|
||||||
"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),
|
|
||||||
"claude-opus-4-20250514" => (15.0, 75.0),
|
"claude-opus-4-20250514" => (15.0, 75.0),
|
||||||
|
|
||||||
|
// Sonnet 4
|
||||||
"claude-sonnet-4-20250514" => (3.0, 15.0),
|
"claude-sonnet-4-20250514" => (3.0, 15.0),
|
||||||
|
|
||||||
// Legacy (Claude 3.x)
|
// Previous generation models
|
||||||
"claude-3-7-sonnet-20250219" => (3.0, 15.0),
|
|
||||||
"claude-3-5-sonnet-20241022" => (3.0, 15.0),
|
"claude-3-5-sonnet-20241022" => (3.0, 15.0),
|
||||||
"claude-3-5-sonnet-20240620" => (3.0, 15.0),
|
"claude-3-5-sonnet-20240620" => (3.0, 15.0),
|
||||||
"claude-3-5-haiku-20241022" => (1.0, 5.0),
|
"claude-3-5-haiku-20241022" => (1.0, 5.0),
|
||||||
@@ -528,25 +231,10 @@ pub fn calculate_cost(
|
|||||||
_ => (3.0, 15.0),
|
_ => (3.0, 15.0),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Regular input/output tokens
|
|
||||||
let input_cost = (input_tokens as f64 / 1_000_000.0) * input_price_per_million;
|
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;
|
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
|
input_cost + output_cost
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -564,7 +252,7 @@ pub struct PersistedStats {
|
|||||||
pub code_blocks_generated: u64,
|
pub code_blocks_generated: u64,
|
||||||
pub files_edited: u64,
|
pub files_edited: u64,
|
||||||
pub files_created: u64,
|
pub files_created: u64,
|
||||||
pub tools_usage: HashMap<String, ToolTokenStats>,
|
pub tools_usage: HashMap<String, u64>,
|
||||||
pub sessions_started: u64,
|
pub sessions_started: u64,
|
||||||
pub consecutive_days: u64,
|
pub consecutive_days: u64,
|
||||||
pub total_days_used: 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);
|
let persisted = PersistedStats::from(stats);
|
||||||
|
|
||||||
tracing::info!("Saving stats: {:?}", persisted);
|
println!("Saving stats: {:?}", persisted);
|
||||||
|
|
||||||
store.set(
|
store.set(
|
||||||
"lifetime_stats",
|
"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())?;
|
store.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
tracing::info!("Stats saved successfully");
|
println!("Stats saved successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load lifetime stats from persistent store
|
/// Load lifetime stats from persistent store
|
||||||
pub async fn load_stats(app: &tauri::AppHandle) -> Option<PersistedStats> {
|
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") {
|
let store = match app.store("stats.json") {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to open stats store: {}", e);
|
println!("Failed to open stats store: {}", e);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(stats_value) = store.get("lifetime_stats") {
|
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()) {
|
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);
|
return Some(persisted);
|
||||||
} else {
|
} else {
|
||||||
tracing::error!("Failed to parse lifetime stats");
|
println!("Failed to parse lifetime stats");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tracing::info!("No lifetime stats found in store");
|
println!("No lifetime stats found in store");
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
@@ -665,7 +353,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cost_calculation_sonnet() {
|
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
|
// 1000 input * $3/M = $0.003
|
||||||
// 2000 output * $15/M = $0.030
|
// 2000 output * $15/M = $0.030
|
||||||
// Total = $0.033
|
// Total = $0.033
|
||||||
@@ -674,7 +362,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cost_calculation_opus() {
|
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
|
// 1000 input * $15/M = $0.015
|
||||||
// 2000 output * $75/M = $0.150
|
// 2000 output * $75/M = $0.150
|
||||||
// Total = $0.165
|
// Total = $0.165
|
||||||
@@ -683,16 +371,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cost_calculation_opus_45() {
|
fn test_cost_calculation_opus_45() {
|
||||||
let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101", None, None);
|
let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101");
|
||||||
// Opus 4.5 pricing: $5/MTok input, $25/MTok output
|
// Same pricing as Opus 4
|
||||||
// 1000 input tokens = $0.005, 2000 output tokens = $0.05
|
assert!((cost - 0.165).abs() < 0.0001);
|
||||||
// Total = $0.055
|
|
||||||
assert!((cost - 0.055).abs() < 0.0001);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cost_calculation_haiku() {
|
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
|
// 1000 input * $1/M = $0.001
|
||||||
// 2000 output * $5/M = $0.010
|
// 2000 output * $5/M = $0.010
|
||||||
// Total = $0.011
|
// Total = $0.011
|
||||||
@@ -701,14 +387,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cost_calculation_unknown_defaults_to_sonnet() {
|
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
|
// Should default to Sonnet pricing
|
||||||
assert!((cost - 0.033).abs() < 0.0001);
|
assert!((cost - 0.033).abs() < 0.0001);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cost_calculation_legacy_sonnet() {
|
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
|
// Same as Sonnet 4 pricing
|
||||||
assert!((cost - 0.033).abs() < 0.0001);
|
assert!((cost - 0.033).abs() < 0.0001);
|
||||||
}
|
}
|
||||||
@@ -716,7 +402,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_usage_stats_accumulation() {
|
fn test_usage_stats_accumulation() {
|
||||||
let mut stats = UsageStats::new();
|
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_input_tokens, 1000);
|
||||||
assert_eq!(stats.total_output_tokens, 2000);
|
assert_eq!(stats.total_output_tokens, 2000);
|
||||||
@@ -728,8 +414,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_usage_stats_multiple_accumulations() {
|
fn test_usage_stats_multiple_accumulations() {
|
||||||
let mut stats = UsageStats::new();
|
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");
|
||||||
stats.add_usage(500, 500, "claude-sonnet-4-20250514", None, None);
|
stats.add_usage(500, 500, "claude-sonnet-4-20250514");
|
||||||
|
|
||||||
assert_eq!(stats.total_input_tokens, 1500);
|
assert_eq!(stats.total_input_tokens, 1500);
|
||||||
assert_eq!(stats.total_output_tokens, 1500);
|
assert_eq!(stats.total_output_tokens, 1500);
|
||||||
@@ -740,17 +426,17 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_usage_stats_model_updated() {
|
fn test_usage_stats_model_updated() {
|
||||||
let mut stats = UsageStats::new();
|
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()));
|
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()));
|
assert_eq!(stats.model, Some("claude-opus-4-20250514".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_session_reset() {
|
fn test_session_reset() {
|
||||||
let mut stats = UsageStats::new();
|
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();
|
stats.reset_session();
|
||||||
|
|
||||||
assert_eq!(stats.total_input_tokens, 1000);
|
assert_eq!(stats.total_input_tokens, 1000);
|
||||||
@@ -826,33 +512,10 @@ mod tests {
|
|||||||
stats.increment_tool_usage("Read");
|
stats.increment_tool_usage("Read");
|
||||||
stats.increment_tool_usage("Write");
|
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("Read"), Some(&2));
|
||||||
assert_eq!(stats.tools_usage.get("Write").map(|t| t.call_count), Some(1));
|
assert_eq!(stats.tools_usage.get("Write"), Some(&1));
|
||||||
assert_eq!(stats.session_tools_usage.get("Read").map(|t| t.call_count), Some(2));
|
assert_eq!(stats.session_tools_usage.get("Read"), Some(&2));
|
||||||
assert_eq!(stats.session_tools_usage.get("Write").map(|t| t.call_count), Some(1));
|
assert_eq!(stats.session_tools_usage.get("Write"), 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -927,11 +590,7 @@ mod tests {
|
|||||||
files_created: 5,
|
files_created: 5,
|
||||||
tools_usage: {
|
tools_usage: {
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
map.insert("Read".to_string(), ToolTokenStats {
|
map.insert("Read".to_string(), 50);
|
||||||
call_count: 50,
|
|
||||||
estimated_input_tokens: 5000,
|
|
||||||
estimated_output_tokens: 2500,
|
|
||||||
});
|
|
||||||
map
|
map
|
||||||
},
|
},
|
||||||
sessions_started: 10,
|
sessions_started: 10,
|
||||||
@@ -949,8 +608,7 @@ mod tests {
|
|||||||
assert_eq!(stats.total_output_tokens, 20000);
|
assert_eq!(stats.total_output_tokens, 20000);
|
||||||
assert_eq!(stats.total_cost_usd, 5.50);
|
assert_eq!(stats.total_cost_usd, 5.50);
|
||||||
assert_eq!(stats.messages_exchanged, 100);
|
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"), Some(&50));
|
||||||
assert_eq!(stats.tools_usage.get("Read").map(|t| t.estimated_input_tokens), Some(5000));
|
|
||||||
assert_eq!(stats.consecutive_days, 7);
|
assert_eq!(stats.consecutive_days, 7);
|
||||||
assert_eq!(stats.morning_sessions, 3);
|
assert_eq!(stats.morning_sessions, 3);
|
||||||
assert_eq!(stats.last_session_date, Some("2024-06-15".to_string()));
|
assert_eq!(stats.last_session_date, Some("2024-06-15".to_string()));
|
||||||
@@ -977,7 +635,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_usage_stats_serialization() {
|
fn test_usage_stats_serialization() {
|
||||||
let mut stats = UsageStats::new();
|
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();
|
stats.increment_messages();
|
||||||
|
|
||||||
// UsageStats should be serializable (for events)
|
// UsageStats should be serializable (for events)
|
||||||
@@ -1006,7 +664,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_stats_update_event_serialization() {
|
fn test_stats_update_event_serialization() {
|
||||||
let mut stats = UsageStats::new();
|
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 event = StatsUpdateEvent { stats };
|
||||||
let json = serde_json::to_string(&event).expect("Failed to serialize");
|
let json = serde_json::to_string(&event).expect("Failed to serialize");
|
||||||
@@ -1014,351 +672,4 @@ mod tests {
|
|||||||
assert!(json.contains("stats"));
|
assert!(json.contains("stats"));
|
||||||
assert!(json.contains("total_input_tokens"));
|
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 {
|
for file_path in files {
|
||||||
if file_path.exists() {
|
if file_path.exists() {
|
||||||
if let Err(e) = fs::remove_file(&file_path) {
|
if let Err(e) = fs::remove_file(&file_path) {
|
||||||
tracing::warn!(
|
eprintln!(
|
||||||
"Failed to remove temp file {:?}: {}",
|
"Warning: Failed to remove temp file {:?}: {}",
|
||||||
file_path, e
|
file_path, e
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ impl TempFileManager {
|
|||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.is_file() && !tracked_files.contains(&path) {
|
if path.is_file() && !tracked_files.contains(&path) {
|
||||||
if let Err(e) = fs::remove_file(&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 {
|
} else {
|
||||||
cleaned_count += 1;
|
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,
|
AppHandle, Manager,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::config::HikariConfig;
|
||||||
|
|
||||||
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
|
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||||
let show_item = MenuItem::with_id(app, "show", "Show Hikari", true, None::<&str>)?;
|
let show_item = MenuItem::with_id(app, "show", "Show Hikari", true, None::<&str>)?;
|
||||||
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||||
@@ -46,3 +48,21 @@ pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
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
-190
@@ -4,10 +4,6 @@ use serde::{Deserialize, Serialize};
|
|||||||
pub struct UsageInfo {
|
pub struct UsageInfo {
|
||||||
pub input_tokens: u64,
|
pub input_tokens: u64,
|
||||||
pub output_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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
@@ -63,26 +59,6 @@ pub struct PermissionDenial {
|
|||||||
pub tool_input: serde_json::Value,
|
pub tool_input: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rate limit information from a `rate_limit_event` message.
|
|
||||||
/// All fields are optional to ensure forward-compatibility as the Claude CLI evolves.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct RateLimitInfo {
|
|
||||||
#[serde(default)]
|
|
||||||
pub requests_limit: Option<u64>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub requests_remaining: Option<u64>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub requests_reset: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub tokens_limit: Option<u64>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub tokens_remaining: Option<u64>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub tokens_reset: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub retry_after_ms: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum ClaudeMessage {
|
pub enum ClaudeMessage {
|
||||||
@@ -120,11 +96,6 @@ pub enum ClaudeMessage {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
usage: Option<UsageInfo>,
|
usage: Option<UsageInfo>,
|
||||||
},
|
},
|
||||||
#[serde(rename = "rate_limit_event")]
|
|
||||||
RateLimitEvent {
|
|
||||||
#[serde(default)]
|
|
||||||
rate_limit_info: RateLimitInfo,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -205,14 +176,6 @@ pub struct StateChangeEvent {
|
|||||||
pub conversation_id: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct OutputEvent {
|
pub struct OutputEvent {
|
||||||
pub line_type: String,
|
pub line_type: String,
|
||||||
@@ -220,23 +183,14 @@ pub struct OutputEvent {
|
|||||||
pub tool_name: Option<String>,
|
pub tool_name: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub conversation_id: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PermissionPromptEvent {
|
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")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub conversation_id: Option<String>,
|
pub conversation_id: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -280,50 +234,6 @@ pub struct UserQuestionEvent {
|
|||||||
pub conversation_id: Option<String>,
|
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>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub last_assistant_message: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -444,106 +354,10 @@ mod tests {
|
|||||||
content: "Test output".to_string(),
|
content: "Test output".to_string(),
|
||||||
tool_name: None,
|
tool_name: None,
|
||||||
conversation_id: None,
|
conversation_id: None,
|
||||||
cost: None,
|
|
||||||
parent_tool_use_id: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let serialized = serde_json::to_string(&event).unwrap();
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
assert!(serialized.contains("\"line_type\":\"assistant\""));
|
assert!(serialized.contains("\"line_type\":\"assistant\""));
|
||||||
assert!(serialized.contains("\"content\":\"Test output\""));
|
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"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_rate_limit_info_default() {
|
|
||||||
let info = RateLimitInfo::default();
|
|
||||||
assert!(info.requests_limit.is_none());
|
|
||||||
assert!(info.requests_remaining.is_none());
|
|
||||||
assert!(info.requests_reset.is_none());
|
|
||||||
assert!(info.tokens_limit.is_none());
|
|
||||||
assert!(info.tokens_remaining.is_none());
|
|
||||||
assert!(info.tokens_reset.is_none());
|
|
||||||
assert!(info.retry_after_ms.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_rate_limit_event_deserialization_empty_info() {
|
|
||||||
let json = r#"{"type":"rate_limit_event","rate_limit_info":{}}"#;
|
|
||||||
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
|
||||||
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_rate_limit_event_deserialization_no_info() {
|
|
||||||
// rate_limit_info field is optional via #[serde(default)]
|
|
||||||
let json = r#"{"type":"rate_limit_event"}"#;
|
|
||||||
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
|
||||||
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_rate_limit_event_deserialization_with_data() {
|
|
||||||
let json = r#"{
|
|
||||||
"type": "rate_limit_event",
|
|
||||||
"rate_limit_info": {
|
|
||||||
"requests_limit": 1000,
|
|
||||||
"requests_remaining": 0,
|
|
||||||
"requests_reset": "2024-01-01T00:01:00Z",
|
|
||||||
"tokens_limit": 50000,
|
|
||||||
"tokens_remaining": 0,
|
|
||||||
"tokens_reset": "2024-01-01T00:01:00Z",
|
|
||||||
"retry_after_ms": 60000
|
|
||||||
}
|
|
||||||
}"#;
|
|
||||||
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
|
||||||
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
|
|
||||||
assert_eq!(rate_limit_info.requests_limit, Some(1000));
|
|
||||||
assert_eq!(rate_limit_info.requests_remaining, Some(0));
|
|
||||||
assert_eq!(
|
|
||||||
rate_limit_info.requests_reset,
|
|
||||||
Some("2024-01-01T00:01:00Z".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(rate_limit_info.retry_after_ms, Some(60000));
|
|
||||||
} else {
|
|
||||||
panic!("Expected RateLimitEvent variant");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_rate_limit_event_ignores_unknown_fields() {
|
|
||||||
// Ensures forward-compat: unknown fields in rate_limit_info are silently ignored
|
|
||||||
let json = r#"{
|
|
||||||
"type": "rate_limit_event",
|
|
||||||
"rate_limit_info": {
|
|
||||||
"requests_remaining": 0,
|
|
||||||
"some_future_field": "some_value"
|
|
||||||
}
|
|
||||||
}"#;
|
|
||||||
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
|
||||||
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
|
|
||||||
assert_eq!(rate_limit_info.requests_remaining, Some(0));
|
|
||||||
} else {
|
|
||||||
panic!("Expected RateLimitEvent variant");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ use std::process::Command;
|
|||||||
use tauri::command;
|
use tauri::command;
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
use crate::process_ext::HideWindow;
|
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> {
|
pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> {
|
||||||
// Create a VBScript that shows a Windows notification
|
// Create a VBScript that shows a Windows notification
|
||||||
@@ -42,7 +40,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
|
|||||||
} else if temp_path.starts_with("/tmp/") {
|
} else if temp_path.starts_with("/tmp/") {
|
||||||
// WSL temp files might be in a different location
|
// WSL temp files might be in a different location
|
||||||
// Try to use wslpath to convert
|
// Try to use wslpath to convert
|
||||||
let output = Command::new("wslpath").hide_window().arg("-w").arg(&temp_path).output();
|
let output = Command::new("wslpath").arg("-w").arg(&temp_path).output();
|
||||||
|
|
||||||
if let Ok(result) = output {
|
if let Ok(result) = output {
|
||||||
if result.status.success() {
|
if result.status.success() {
|
||||||
@@ -59,7 +57,6 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
|
|||||||
|
|
||||||
// Execute the VBScript using wscript.exe
|
// Execute the VBScript using wscript.exe
|
||||||
let output = Command::new("/mnt/c/Windows/System32/wscript.exe")
|
let output = Command::new("/mnt/c/Windows/System32/wscript.exe")
|
||||||
.hide_window()
|
|
||||||
.arg("//NoLogo")
|
.arg("//NoLogo")
|
||||||
.arg(&windows_path)
|
.arg(&windows_path)
|
||||||
.output()
|
.output()
|
||||||
|
|||||||
+90
-1387
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,6 @@
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use tauri::command;
|
use tauri::command;
|
||||||
|
|
||||||
use crate::process_ext::HideWindow;
|
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn send_wsl_notification(title: String, body: String) -> Result<(), String> {
|
pub async fn send_wsl_notification(title: String, body: String) -> Result<(), String> {
|
||||||
// Method 1: Try Windows 10/11 toast notification using PowerShell
|
// Method 1: Try Windows 10/11 toast notification using PowerShell
|
||||||
@@ -38,7 +36,6 @@ $notifier.Show($toast)
|
|||||||
|
|
||||||
// Try PowerShell.exe through WSL
|
// Try PowerShell.exe through WSL
|
||||||
let output = Command::new("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe")
|
let output = Command::new("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe")
|
||||||
.hide_window()
|
|
||||||
.arg("-NoProfile")
|
.arg("-NoProfile")
|
||||||
.arg("-ExecutionPolicy")
|
.arg("-ExecutionPolicy")
|
||||||
.arg("Bypass")
|
.arg("Bypass")
|
||||||
@@ -51,15 +48,15 @@ $notifier.Show($toast)
|
|||||||
match output {
|
match output {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
if result.status.success() {
|
if result.status.success() {
|
||||||
tracing::info!("WSL notification sent successfully");
|
println!("WSL notification sent successfully");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
} else {
|
} else {
|
||||||
let stderr = String::from_utf8_lossy(&result.stderr);
|
let stderr = String::from_utf8_lossy(&result.stderr);
|
||||||
tracing::error!("PowerShell toast failed: {}", stderr);
|
println!("PowerShell toast failed: {}", stderr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to run PowerShell: {}", e);
|
println!("Failed to run PowerShell: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +65,6 @@ $notifier.Show($toast)
|
|||||||
|
|
||||||
// Method 3: Try wsl-notify-send if available
|
// Method 3: Try wsl-notify-send if available
|
||||||
let notify_result = Command::new("wsl-notify-send")
|
let notify_result = Command::new("wsl-notify-send")
|
||||||
.hide_window()
|
|
||||||
.arg("--appId")
|
.arg("--appId")
|
||||||
.arg("HikariDesktop")
|
.arg("HikariDesktop")
|
||||||
.arg("--category")
|
.arg("--category")
|
||||||
@@ -78,7 +74,7 @@ $notifier.Show($toast)
|
|||||||
|
|
||||||
if let Ok(result) = notify_result {
|
if let Ok(result) = notify_result {
|
||||||
if result.status.success() {
|
if result.status.success() {
|
||||||
tracing::info!("Notification sent via wsl-notify-send");
|
println!("Notification sent via wsl-notify-send");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "hikari-desktop",
|
"productName": "hikari-desktop",
|
||||||
"version": "1.10.0",
|
"version": "1.1.1",
|
||||||
"identifier": "com.naomi.hikari-desktop",
|
"identifier": "com.naomi.hikari-desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
|||||||
+5
-1
@@ -154,7 +154,11 @@ body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-family: var(--ui-font-family, "Segoe UI", system-ui, -apple-system, sans-serif);
|
font-family:
|
||||||
|
"Segoe UI",
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { get } from "svelte/store";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { claudeStore } from "$lib/stores/claude";
|
|
||||||
import { searchState } from "$lib/stores/search";
|
|
||||||
import { characterState } from "$lib/stores/character";
|
|
||||||
import {
|
import {
|
||||||
slashCommands,
|
slashCommands,
|
||||||
parseSlashCommand,
|
parseSlashCommand,
|
||||||
@@ -13,13 +8,9 @@ import {
|
|||||||
} from "./slashCommands";
|
} from "./slashCommands";
|
||||||
|
|
||||||
// Mock all external dependencies
|
// Mock all external dependencies
|
||||||
vi.mock("svelte/store", async (importOriginal) => {
|
vi.mock("svelte/store", () => ({
|
||||||
const actual = await importOriginal<typeof import("svelte/store")>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/core", () => ({
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
@@ -45,28 +36,6 @@ vi.mock("$lib/stores/character", () => ({
|
|||||||
|
|
||||||
vi.mock("$lib/tauri", () => ({
|
vi.mock("$lib/tauri", () => ({
|
||||||
setSkipNextGreeting: vi.fn(),
|
setSkipNextGreeting: vi.fn(),
|
||||||
updateDiscordRpc: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("$lib/stores/conversations", () => ({
|
|
||||||
conversationsStore: {
|
|
||||||
activeConversation: { subscribe: vi.fn() },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("$lib/stores/config", () => ({
|
|
||||||
configStore: {
|
|
||||||
getConfig: vi.fn().mockReturnValue({
|
|
||||||
auto_granted_tools: [],
|
|
||||||
model: "claude-sonnet",
|
|
||||||
api_key: null,
|
|
||||||
custom_instructions: null,
|
|
||||||
mcp_servers_json: null,
|
|
||||||
use_worktree: false,
|
|
||||||
disable_1m_context: false,
|
|
||||||
max_output_tokens: null,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("$lib/stores/search", () => ({
|
vi.mock("$lib/stores/search", () => ({
|
||||||
@@ -442,425 +411,4 @@ describe("slashCommands", () => {
|
|||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("command execute functions", () => {
|
|
||||||
let getMock: ReturnType<typeof vi.fn>;
|
|
||||||
let invokeMock: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
getMock = vi.mocked(get);
|
|
||||||
invokeMock = vi.mocked(invoke);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/clear execute", () => {
|
|
||||||
it("clears terminal and shows confirmation message", () => {
|
|
||||||
const clearCmd = slashCommands.find((cmd) => cmd.name === "clear")!;
|
|
||||||
clearCmd.execute("");
|
|
||||||
expect(claudeStore.clearTerminal).toHaveBeenCalledWith();
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Terminal cleared");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/help execute", () => {
|
|
||||||
it("shows available commands header", () => {
|
|
||||||
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
|
|
||||||
helpCmd.execute("");
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
||||||
"system",
|
|
||||||
expect.stringContaining("Available commands:")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes all command usages in help text", () => {
|
|
||||||
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
|
|
||||||
helpCmd.execute("");
|
|
||||||
const callArgs = vi.mocked(claudeStore.addLine).mock.calls[0];
|
|
||||||
const helpText = callArgs[1] as string;
|
|
||||||
expect(helpText).toContain("/cd");
|
|
||||||
expect(helpText).toContain("/clear");
|
|
||||||
expect(helpText).toContain("/help");
|
|
||||||
expect(helpText).toContain("/search");
|
|
||||||
expect(helpText).toContain("/new");
|
|
||||||
expect(helpText).toContain("/summarise");
|
|
||||||
expect(helpText).toContain("/skill");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes command descriptions in help text", () => {
|
|
||||||
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
|
|
||||||
helpCmd.execute("");
|
|
||||||
const callArgs = vi.mocked(claudeStore.addLine).mock.calls[0];
|
|
||||||
const helpText = callArgs[1] as string;
|
|
||||||
expect(helpText).toContain("Change the working directory");
|
|
||||||
expect(helpText).toContain("Show available slash commands");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/search execute", () => {
|
|
||||||
it("clears search when called with empty args", () => {
|
|
||||||
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
|
||||||
searchCmd.execute("");
|
|
||||||
expect(searchState.clear).toHaveBeenCalledWith();
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Search cleared");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears search when called with whitespace-only args", () => {
|
|
||||||
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
|
||||||
searchCmd.execute(" ");
|
|
||||||
expect(searchState.clear).toHaveBeenCalledWith();
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Search cleared");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets query when called with a search term", () => {
|
|
||||||
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
|
||||||
searchCmd.execute("hello world");
|
|
||||||
expect(searchState.setQuery).toHaveBeenCalledWith("hello world");
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", 'Searching for: "hello world"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("trims whitespace from query before setting", () => {
|
|
||||||
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
|
||||||
searchCmd.execute(" hello ");
|
|
||||||
expect(searchState.setQuery).toHaveBeenCalledWith("hello");
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", 'Searching for: "hello"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/cd execute", () => {
|
|
||||||
it("shows error when no active conversation", async () => {
|
|
||||||
getMock.mockReturnValue(null);
|
|
||||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
|
||||||
await cdCmd.execute("/some/path");
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows current directory when called with empty args", async () => {
|
|
||||||
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce("/home/naomi/code");
|
|
||||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
|
||||||
await cdCmd.execute("");
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
||||||
"system",
|
|
||||||
"Current directory: /home/naomi/code"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows current directory when called with whitespace-only args", async () => {
|
|
||||||
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce("/home/naomi/code");
|
|
||||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
|
||||||
await cdCmd.execute(" ");
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
||||||
"system",
|
|
||||||
"Current directory: /home/naomi/code"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("validates path and changes directory on success", async () => {
|
|
||||||
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(null);
|
|
||||||
invokeMock.mockResolvedValue("/validated/path");
|
|
||||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
|
||||||
await cdCmd.execute("/new/path");
|
|
||||||
expect(invokeMock).toHaveBeenCalledWith(
|
|
||||||
"validate_directory",
|
|
||||||
expect.objectContaining({ path: "/new/path" })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows error when directory change fails", async () => {
|
|
||||||
getMock.mockReturnValueOnce("conv-123");
|
|
||||||
invokeMock.mockRejectedValueOnce(new Error("invalid path"));
|
|
||||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
|
||||||
await cdCmd.execute("/bad/path");
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
||||||
"error",
|
|
||||||
expect.stringContaining("Failed to change directory:")
|
|
||||||
);
|
|
||||||
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/new execute", () => {
|
|
||||||
it("shows error when no active conversation", async () => {
|
|
||||||
getMock.mockReturnValue(null);
|
|
||||||
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
|
||||||
await newCmd.execute("");
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows error when starting new conversation fails", async () => {
|
|
||||||
getMock.mockReturnValueOnce("conv-123");
|
|
||||||
invokeMock.mockRejectedValueOnce(new Error("invoke failed"));
|
|
||||||
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
|
||||||
await newCmd.execute("");
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
||||||
"error",
|
|
||||||
expect.stringContaining("Failed to start new conversation:")
|
|
||||||
);
|
|
||||||
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/summarise execute", () => {
|
|
||||||
it("shows error when no active conversation", async () => {
|
|
||||||
getMock.mockReturnValue(null);
|
|
||||||
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
|
|
||||||
await summariseCmd.execute("");
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends a summary prompt when there is an active conversation", async () => {
|
|
||||||
getMock.mockReturnValue("conv-123");
|
|
||||||
invokeMock.mockResolvedValue(undefined);
|
|
||||||
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
|
|
||||||
await summariseCmd.execute("");
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
||||||
"system",
|
|
||||||
"Requesting conversation summary..."
|
|
||||||
);
|
|
||||||
expect(invokeMock).toHaveBeenCalledWith(
|
|
||||||
"send_prompt",
|
|
||||||
expect.objectContaining({ conversationId: "conv-123" })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows error when send_prompt invoke fails", async () => {
|
|
||||||
getMock.mockReturnValue("conv-123");
|
|
||||||
invokeMock.mockRejectedValue(new Error("network error"));
|
|
||||||
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
|
|
||||||
await summariseCmd.execute("");
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
||||||
"error",
|
|
||||||
expect.stringContaining("Failed to request summary:")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/skill execute", () => {
|
|
||||||
it("shows error when no active conversation", async () => {
|
|
||||||
getMock.mockReturnValue(null);
|
|
||||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
|
||||||
await skillCmd.execute("onboard-mentee");
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lists available skills when called with no name", async () => {
|
|
||||||
getMock.mockReturnValue("conv-123");
|
|
||||||
invokeMock.mockResolvedValue(["onboard-mentee", "other-skill"]);
|
|
||||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
|
||||||
await skillCmd.execute("");
|
|
||||||
expect(invokeMock).toHaveBeenCalledWith("list_skills");
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
||||||
"system",
|
|
||||||
expect.stringContaining("onboard-mentee")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows empty message when no skills are found", async () => {
|
|
||||||
getMock.mockReturnValue("conv-123");
|
|
||||||
invokeMock.mockResolvedValue([]);
|
|
||||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
|
||||||
await skillCmd.execute("");
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
||||||
"system",
|
|
||||||
expect.stringContaining("No skills found")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("invokes skill when called with a name and no data", async () => {
|
|
||||||
getMock.mockReturnValue("conv-123");
|
|
||||||
invokeMock.mockResolvedValue(undefined);
|
|
||||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
|
||||||
await skillCmd.execute("onboard-mentee");
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
||||||
"system",
|
|
||||||
"Invoking skill: onboard-mentee"
|
|
||||||
);
|
|
||||||
expect(invokeMock).toHaveBeenCalledWith(
|
|
||||||
"send_prompt",
|
|
||||||
expect.objectContaining({ conversationId: "conv-123" })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("invokes skill with additional data in the prompt", async () => {
|
|
||||||
getMock.mockReturnValue("conv-123");
|
|
||||||
invokeMock.mockResolvedValue(undefined);
|
|
||||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
|
||||||
await skillCmd.execute("onboard-mentee some extra data");
|
|
||||||
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
|
||||||
conversationId: "conv-123",
|
|
||||||
message: expect.stringContaining("some extra data"),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows error when listing skills fails", async () => {
|
|
||||||
getMock.mockReturnValue("conv-123");
|
|
||||||
invokeMock.mockRejectedValue(new Error("list error"));
|
|
||||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
|
||||||
await skillCmd.execute("");
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
||||||
"error",
|
|
||||||
expect.stringContaining("Failed to list skills:")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows error and resets character state when invoking skill fails", async () => {
|
|
||||||
getMock.mockReturnValue("conv-123");
|
|
||||||
invokeMock.mockRejectedValue(new Error("invoke error"));
|
|
||||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
|
||||||
await skillCmd.execute("onboard-mentee");
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
||||||
"error",
|
|
||||||
expect.stringContaining("Failed to invoke skill:")
|
|
||||||
);
|
|
||||||
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/cd success path", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("changes directory and shows success message", async () => {
|
|
||||||
getMock
|
|
||||||
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
|
||||||
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
|
||||||
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
|
||||||
vi.mocked(claudeStore.getConversationHistory).mockReturnValue("");
|
|
||||||
invokeMock
|
|
||||||
.mockResolvedValueOnce("/new/path") // validate_directory
|
|
||||||
.mockResolvedValueOnce(undefined) // stop_claude
|
|
||||||
.mockResolvedValueOnce(undefined); // start_claude
|
|
||||||
|
|
||||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
|
||||||
const promise = cdCmd.execute("/new/path");
|
|
||||||
await vi.runAllTimersAsync();
|
|
||||||
await promise;
|
|
||||||
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
||||||
"system",
|
|
||||||
"Changed directory to: /new/path"
|
|
||||||
);
|
|
||||||
expect(characterState.setState).toHaveBeenCalledWith("idle");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends context restoration message when conversation history exists", async () => {
|
|
||||||
getMock
|
|
||||||
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
|
||||||
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
|
||||||
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
|
||||||
vi.mocked(claudeStore.getConversationHistory).mockReturnValue(
|
|
||||||
"previous conversation history"
|
|
||||||
);
|
|
||||||
invokeMock
|
|
||||||
.mockResolvedValueOnce("/new/path") // validate_directory
|
|
||||||
.mockResolvedValueOnce(undefined) // stop_claude
|
|
||||||
.mockResolvedValueOnce(undefined) // start_claude
|
|
||||||
.mockResolvedValueOnce(undefined); // send_prompt
|
|
||||||
|
|
||||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
|
||||||
const promise = cdCmd.execute("/new/path");
|
|
||||||
await vi.runAllTimersAsync();
|
|
||||||
await promise;
|
|
||||||
|
|
||||||
expect(invokeMock).toHaveBeenCalledWith(
|
|
||||||
"send_prompt",
|
|
||||||
expect.objectContaining({
|
|
||||||
message: expect.stringContaining("previous conversation history"),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
||||||
"system",
|
|
||||||
"Changed directory to: /new/path"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls updateDiscordRpc when activeConversation is available", async () => {
|
|
||||||
const activeConv = {
|
|
||||||
name: "Test Conversation",
|
|
||||||
model: "claude-sonnet",
|
|
||||||
startedAt: new Date("2026-03-03T12:00:00Z"),
|
|
||||||
grantedTools: new Set<string>(),
|
|
||||||
};
|
|
||||||
getMock
|
|
||||||
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
|
||||||
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
|
||||||
.mockReturnValueOnce(activeConv); // get(conversationsStore.activeConversation)
|
|
||||||
vi.mocked(claudeStore.getConversationHistory).mockReturnValue("");
|
|
||||||
invokeMock
|
|
||||||
.mockResolvedValueOnce("/new/path")
|
|
||||||
.mockResolvedValueOnce(undefined)
|
|
||||||
.mockResolvedValueOnce(undefined);
|
|
||||||
const { updateDiscordRpc } = await import("$lib/tauri");
|
|
||||||
|
|
||||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
|
||||||
const promise = cdCmd.execute("/new/path");
|
|
||||||
await vi.runAllTimersAsync();
|
|
||||||
await promise;
|
|
||||||
|
|
||||||
expect(updateDiscordRpc).toHaveBeenCalledWith(
|
|
||||||
"Test Conversation",
|
|
||||||
expect.any(String),
|
|
||||||
expect.any(Date)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/new success path", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts a new conversation and shows success message", async () => {
|
|
||||||
getMock
|
|
||||||
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
|
||||||
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
|
||||||
invokeMock
|
|
||||||
.mockResolvedValueOnce("/working/dir") // get_working_directory
|
|
||||||
.mockResolvedValueOnce(undefined) // interrupt_claude
|
|
||||||
.mockResolvedValueOnce(undefined); // start_claude
|
|
||||||
|
|
||||||
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
|
||||||
const promise = newCmd.execute("");
|
|
||||||
await vi.runAllTimersAsync();
|
|
||||||
await promise;
|
|
||||||
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "New conversation started!");
|
|
||||||
expect(characterState.setState).toHaveBeenCalledWith("idle");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls updateDiscordRpc when activeConversation is available", async () => {
|
|
||||||
const activeConv = {
|
|
||||||
name: "My Conv",
|
|
||||||
model: "claude-sonnet",
|
|
||||||
startedAt: new Date("2026-03-03T12:00:00Z"),
|
|
||||||
grantedTools: new Set<string>(["tool1"]),
|
|
||||||
};
|
|
||||||
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(activeConv);
|
|
||||||
invokeMock
|
|
||||||
.mockResolvedValueOnce("/working/dir")
|
|
||||||
.mockResolvedValueOnce(undefined)
|
|
||||||
.mockResolvedValueOnce(undefined);
|
|
||||||
const { updateDiscordRpc } = await import("$lib/tauri");
|
|
||||||
|
|
||||||
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
|
||||||
const promise = newCmd.execute("");
|
|
||||||
await vi.runAllTimersAsync();
|
|
||||||
await promise;
|
|
||||||
|
|
||||||
expect(updateDiscordRpc).toHaveBeenCalledWith(
|
|
||||||
"My Conv",
|
|
||||||
expect.any(String),
|
|
||||||
expect.any(Date)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ import { get } from "svelte/store";
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { claudeStore } from "$lib/stores/claude";
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri";
|
import { setSkipNextGreeting } from "$lib/tauri";
|
||||||
import { searchState } from "$lib/stores/search";
|
import { searchState } from "$lib/stores/search";
|
||||||
import { conversationsStore } from "$lib/stores/conversations";
|
|
||||||
import { configStore } from "$lib/stores/config";
|
|
||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -37,12 +35,6 @@ async function changeDirectory(path: string): Promise<void> {
|
|||||||
// Capture conversation history before disconnecting
|
// Capture conversation history before disconnecting
|
||||||
const conversationHistory = claudeStore.getConversationHistory();
|
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 });
|
await invoke("stop_claude", { conversationId });
|
||||||
|
|
||||||
// Wait for clean shutdown
|
// Wait for clean shutdown
|
||||||
@@ -56,26 +48,9 @@ async function changeDirectory(path: string): Promise<void> {
|
|||||||
conversationId,
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
working_dir: validatedPath,
|
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,
|
|
||||||
use_worktree: config.use_worktree ?? false,
|
|
||||||
disable_1m_context: config.disable_1m_context ?? false,
|
|
||||||
max_output_tokens: config.max_output_tokens ?? null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update Discord RPC when reconnecting after directory change
|
|
||||||
if (activeConversation) {
|
|
||||||
await updateDiscordRpc(
|
|
||||||
activeConversation.name,
|
|
||||||
config.model || "claude",
|
|
||||||
activeConversation.startedAt
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for connection to establish
|
// Wait for connection to establish
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
@@ -114,12 +89,6 @@ async function startNewConversation(): Promise<void> {
|
|||||||
conversationId,
|
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...");
|
claudeStore.addLine("system", "Starting new conversation...");
|
||||||
characterState.setState("thinking");
|
characterState.setState("thinking");
|
||||||
|
|
||||||
@@ -133,26 +102,9 @@ async function startNewConversation(): Promise<void> {
|
|||||||
conversationId,
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
working_dir: workingDir,
|
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,
|
|
||||||
use_worktree: config.use_worktree ?? false,
|
|
||||||
disable_1m_context: config.disable_1m_context ?? false,
|
|
||||||
max_output_tokens: config.max_output_tokens ?? null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update Discord RPC when starting new conversation
|
|
||||||
if (activeConversation) {
|
|
||||||
await updateDiscordRpc(
|
|
||||||
activeConversation.name,
|
|
||||||
config.model || "claude",
|
|
||||||
activeConversation.startedAt
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
claudeStore.addLine("system", "New conversation started!");
|
claudeStore.addLine("system", "New conversation started!");
|
||||||
characterState.setState("idle");
|
characterState.setState("idle");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -161,7 +161,7 @@
|
|||||||
|
|
||||||
<!-- Celebration confetti effect (CSS only) -->
|
<!-- Celebration confetti effect (CSS only) -->
|
||||||
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
<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
|
<div
|
||||||
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
|
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
|
||||||
getAchievementRarity(currentAchievement.achievement.id)
|
getAchievementRarity(currentAchievement.achievement.id)
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
/**
|
|
||||||
* AchievementNotification Component Tests
|
|
||||||
*
|
|
||||||
* Tests the rarity classification and colour mapping logic used by the
|
|
||||||
* AchievementNotification component.
|
|
||||||
*
|
|
||||||
* What this component does:
|
|
||||||
* - Listens for "achievement:unlocked" Tauri events
|
|
||||||
* - Queues and displays achievement notifications one at a time
|
|
||||||
* - Each notification shows the achievement's name, icon, description, and rarity
|
|
||||||
* - A gradient border and badge colour correspond to the achievement's rarity
|
|
||||||
*
|
|
||||||
* Manual testing checklist:
|
|
||||||
* - [ ] Achievement notification slides in from the right
|
|
||||||
* - [ ] Notification auto-dismisses after 5 seconds
|
|
||||||
* - [ ] Dismiss button works immediately
|
|
||||||
* - [ ] Multiple achievements queue and display sequentially
|
|
||||||
* - [ ] Legendary achievements have a yellow-orange gradient
|
|
||||||
* - [ ] Epic achievements have a purple-pink gradient
|
|
||||||
* - [ ] Rare achievements have a blue-indigo gradient
|
|
||||||
* - [ ] Common achievements have a green-emerald gradient
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
|
|
||||||
function getAchievementRarity(id: string): string {
|
|
||||||
if (id === "TokenMaster") return "legendary";
|
|
||||||
if (["CodeMachine", "Unstoppable"].includes(id)) return "epic";
|
|
||||||
if (
|
|
||||||
[
|
|
||||||
"BlossomingCoder",
|
|
||||||
"CodeWizard",
|
|
||||||
"MasterBuilder",
|
|
||||||
"EnduranceChamp",
|
|
||||||
"DeepDive",
|
|
||||||
"CreativeCoder",
|
|
||||||
].includes(id)
|
|
||||||
)
|
|
||||||
return "rare";
|
|
||||||
return "common";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRarityColor(rarity: string): string {
|
|
||||||
switch (rarity) {
|
|
||||||
case "legendary":
|
|
||||||
return "from-yellow-400 to-orange-500";
|
|
||||||
case "epic":
|
|
||||||
return "from-purple-400 to-pink-500";
|
|
||||||
case "rare":
|
|
||||||
return "from-blue-400 to-indigo-500";
|
|
||||||
default:
|
|
||||||
return "from-green-400 to-emerald-500";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---
|
|
||||||
|
|
||||||
describe("getAchievementRarity", () => {
|
|
||||||
describe("legendary tier", () => {
|
|
||||||
it("classifies TokenMaster as legendary", () => {
|
|
||||||
expect(getAchievementRarity("TokenMaster")).toBe("legendary");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("epic tier", () => {
|
|
||||||
it("classifies CodeMachine as epic", () => {
|
|
||||||
expect(getAchievementRarity("CodeMachine")).toBe("epic");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies Unstoppable as epic", () => {
|
|
||||||
expect(getAchievementRarity("Unstoppable")).toBe("epic");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("rare tier", () => {
|
|
||||||
it("classifies BlossomingCoder as rare", () => {
|
|
||||||
expect(getAchievementRarity("BlossomingCoder")).toBe("rare");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies CodeWizard as rare", () => {
|
|
||||||
expect(getAchievementRarity("CodeWizard")).toBe("rare");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies MasterBuilder as rare", () => {
|
|
||||||
expect(getAchievementRarity("MasterBuilder")).toBe("rare");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies EnduranceChamp as rare", () => {
|
|
||||||
expect(getAchievementRarity("EnduranceChamp")).toBe("rare");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies DeepDive as rare", () => {
|
|
||||||
expect(getAchievementRarity("DeepDive")).toBe("rare");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies CreativeCoder as rare", () => {
|
|
||||||
expect(getAchievementRarity("CreativeCoder")).toBe("rare");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("common tier", () => {
|
|
||||||
it("classifies unknown IDs as common", () => {
|
|
||||||
expect(getAchievementRarity("FirstChat")).toBe("common");
|
|
||||||
expect(getAchievementRarity("SomeNewAchievement")).toBe("common");
|
|
||||||
expect(getAchievementRarity("")).toBe("common");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getRarityColor", () => {
|
|
||||||
it("returns yellow-to-orange gradient for legendary", () => {
|
|
||||||
expect(getRarityColor("legendary")).toBe("from-yellow-400 to-orange-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns purple-to-pink gradient for epic", () => {
|
|
||||||
expect(getRarityColor("epic")).toBe("from-purple-400 to-pink-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns blue-to-indigo gradient for rare", () => {
|
|
||||||
expect(getRarityColor("rare")).toBe("from-blue-400 to-indigo-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns green-to-emerald gradient for common", () => {
|
|
||||||
expect(getRarityColor("common")).toBe("from-green-400 to-emerald-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to green-to-emerald gradient for unknown rarities", () => {
|
|
||||||
expect(getRarityColor("mythic")).toBe("from-green-400 to-emerald-500");
|
|
||||||
expect(getRarityColor("")).toBe("from-green-400 to-emerald-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("end-to-end rarity pipeline", () => {
|
|
||||||
it("produces the correct colour for a legendary achievement", () => {
|
|
||||||
const color = getRarityColor(getAchievementRarity("TokenMaster"));
|
|
||||||
expect(color).toBe("from-yellow-400 to-orange-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("produces the correct colour for an epic achievement", () => {
|
|
||||||
const color = getRarityColor(getAchievementRarity("CodeMachine"));
|
|
||||||
expect(color).toBe("from-purple-400 to-pink-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("produces the correct colour for a rare achievement", () => {
|
|
||||||
const color = getRarityColor(getAchievementRarity("CodeWizard"));
|
|
||||||
expect(color).toBe("from-blue-400 to-indigo-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("produces the correct colour for a common achievement", () => {
|
|
||||||
const color = getRarityColor(getAchievementRarity("FirstChat"));
|
|
||||||
expect(color).toBe("from-green-400 to-emerald-500");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,348 +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>
|
|
||||||
|
|
||||||
<!-- Last assistant message snippet -->
|
|
||||||
{#if agent.lastAssistantMessage}
|
|
||||||
<p
|
|
||||||
class="mt-1 text-[10px] text-[var(--text-secondary)] italic truncate"
|
|
||||||
title={agent.lastAssistantMessage}
|
|
||||||
>
|
|
||||||
{agent.lastAssistantMessage}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/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,160 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
|
|
||||||
const SUPPORTED_CLI_VERSION = "2.1.53";
|
|
||||||
|
|
||||||
let installedVersion = $state("Loading...");
|
|
||||||
|
|
||||||
function compareVersions(a: string, b: string): number {
|
|
||||||
const aParts = a.split(".").map(Number);
|
|
||||||
const bParts = b.split(".").map(Number);
|
|
||||||
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
|
||||||
const aVal = aParts[i] ?? 0;
|
|
||||||
const bVal = bParts[i] ?? 0;
|
|
||||||
if (aVal > bVal) return 1;
|
|
||||||
if (aVal < bVal) return -1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let displayVersion = $derived(installedVersion.split(" (")[0]);
|
|
||||||
|
|
||||||
let supportedBadgeState = $derived.by(() => {
|
|
||||||
if (installedVersion === "Loading..." || installedVersion === "Unknown") {
|
|
||||||
return "neutral";
|
|
||||||
}
|
|
||||||
const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion);
|
|
||||||
if (!semverMatch) return "neutral";
|
|
||||||
const cmp = compareVersions(semverMatch[1], SUPPORTED_CLI_VERSION);
|
|
||||||
if (cmp > 0) return "ahead";
|
|
||||||
if (cmp < 0) return "behind";
|
|
||||||
return "current";
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fetchVersion() {
|
|
||||||
try {
|
|
||||||
const result = await invoke<string>("get_claude_version");
|
|
||||||
installedVersion = result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to get Claude CLI version:", error);
|
|
||||||
installedVersion = "Unknown";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
fetchVersion();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="cli-versions">
|
|
||||||
<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 {displayVersion}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cli-version supported {supportedBadgeState}" title="Highest audited CLI version">
|
|
||||||
<svg
|
|
||||||
class="terminal-icon"
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
|
||||||
</svg>
|
|
||||||
<span class="version-text">Supported {SUPPORTED_CLI_VERSION}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if supportedBadgeState === "ahead"}
|
|
||||||
<span class="version-warning ahead"
|
|
||||||
>Your version is newer, some features may not be supported</span
|
|
||||||
>
|
|
||||||
{:else if supportedBadgeState === "behind"}
|
|
||||||
<span class="version-warning behind"
|
|
||||||
>Your version is out of date, please update to ensure compatibility</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.cli-versions {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cli-version.supported.current {
|
|
||||||
border-color: var(--success-color, #4caf50);
|
|
||||||
color: var(--success-color, #4caf50);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cli-version.supported.ahead {
|
|
||||||
border-color: var(--warning-color, #ff9800);
|
|
||||||
color: var(--warning-color, #ff9800);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cli-version.supported.behind {
|
|
||||||
border-color: var(--error-color, #f44336);
|
|
||||||
color: var(--error-color, #f44336);
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-text {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-warning {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-style: italic;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-warning.ahead {
|
|
||||||
color: var(--warning-color, #ff9800);
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-warning.behind {
|
|
||||||
color: var(--error-color, #f44336);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
/**
|
|
||||||
* CliVersion Component Tests
|
|
||||||
*
|
|
||||||
* Tests the version comparison logic used by the CliVersion component,
|
|
||||||
* which compares the installed CLI version against the supported version.
|
|
||||||
*
|
|
||||||
* What this component does:
|
|
||||||
* - Displays the installed Claude CLI version
|
|
||||||
* - Displays the highest audited/supported CLI version
|
|
||||||
* - Shows a warning when the installed version is ahead of or behind supported
|
|
||||||
*
|
|
||||||
* Manual testing checklist:
|
|
||||||
* - [ ] Installed version is fetched and displayed on mount
|
|
||||||
* - [ ] "current" badge shows in green when versions match
|
|
||||||
* - [ ] "ahead" badge shows in amber when installed is newer than supported
|
|
||||||
* - [ ] "behind" badge shows in red when installed is older than supported
|
|
||||||
* - [ ] Warning message appears for "ahead" and "behind" states
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
|
|
||||||
const SUPPORTED_CLI_VERSION = "2.1.53";
|
|
||||||
|
|
||||||
function compareVersions(a: string, b: string): number {
|
|
||||||
const aParts = a.split(".").map(Number);
|
|
||||||
const bParts = b.split(".").map(Number);
|
|
||||||
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
|
||||||
const aVal = aParts[i] ?? 0;
|
|
||||||
const bVal = bParts[i] ?? 0;
|
|
||||||
if (aVal > bVal) return 1;
|
|
||||||
if (aVal < bVal) return -1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---
|
|
||||||
|
|
||||||
describe("SUPPORTED_CLI_VERSION", () => {
|
|
||||||
it("is defined and non-empty", () => {
|
|
||||||
expect(SUPPORTED_CLI_VERSION).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches the expected audited version", () => {
|
|
||||||
expect(SUPPORTED_CLI_VERSION).toBe("2.1.53");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("compareVersions", () => {
|
|
||||||
describe("equal versions", () => {
|
|
||||||
it("returns 0 for identical versions", () => {
|
|
||||||
expect(compareVersions("1.0.0", "1.0.0")).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 0 for the supported CLI version against itself", () => {
|
|
||||||
expect(compareVersions(SUPPORTED_CLI_VERSION, SUPPORTED_CLI_VERSION)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 0 for 0.0.0 vs 0.0.0", () => {
|
|
||||||
expect(compareVersions("0.0.0", "0.0.0")).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("major version differences", () => {
|
|
||||||
it("returns 1 when a has a higher major version", () => {
|
|
||||||
expect(compareVersions("2.0.0", "1.0.0")).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns -1 when a has a lower major version", () => {
|
|
||||||
expect(compareVersions("1.0.0", "2.0.0")).toBe(-1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("minor version differences", () => {
|
|
||||||
it("returns 1 when a has a higher minor version", () => {
|
|
||||||
expect(compareVersions("1.2.0", "1.1.0")).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns -1 when a has a lower minor version", () => {
|
|
||||||
expect(compareVersions("1.1.0", "1.2.0")).toBe(-1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("patch version differences", () => {
|
|
||||||
it("returns 1 when a has a higher patch version", () => {
|
|
||||||
expect(compareVersions("1.0.2", "1.0.1")).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns -1 when a has a lower patch version", () => {
|
|
||||||
expect(compareVersions("1.0.1", "1.0.2")).toBe(-1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("major version takes precedence", () => {
|
|
||||||
it("returns 1 when a has a higher major but lower minor", () => {
|
|
||||||
expect(compareVersions("2.0.0", "1.9.9")).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns -1 when a has a lower major but higher minor", () => {
|
|
||||||
expect(compareVersions("1.9.9", "2.0.0")).toBe(-1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("unequal segment counts", () => {
|
|
||||||
it("treats missing segments as 0 (a shorter than b)", () => {
|
|
||||||
expect(compareVersions("1.0", "1.0.0")).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats missing segments as 0 (a longer than b)", () => {
|
|
||||||
expect(compareVersions("1.0.0", "1.0")).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("correctly compares when a has an extra non-zero segment", () => {
|
|
||||||
expect(compareVersions("1.0.1", "1.0")).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("correctly compares when b has an extra non-zero segment", () => {
|
|
||||||
expect(compareVersions("1.0", "1.0.1")).toBe(-1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("supported CLI version comparisons", () => {
|
|
||||||
it("returns 1 for a version ahead of supported", () => {
|
|
||||||
expect(compareVersions("2.2.0", SUPPORTED_CLI_VERSION)).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns -1 for a version behind supported", () => {
|
|
||||||
expect(compareVersions("2.1.0", SUPPORTED_CLI_VERSION)).toBe(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 0 for exactly the supported version", () => {
|
|
||||||
expect(compareVersions("2.1.53", SUPPORTED_CLI_VERSION)).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
@@ -3,9 +3,8 @@
|
|||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||||
import { characterState, characterInfo } from "$lib/stores/character";
|
import { characterState, characterInfo } from "$lib/stores/character";
|
||||||
import { isStreamerMode, configStore } from "$lib/stores/config";
|
import { isStreamerMode } from "$lib/stores/config";
|
||||||
import { handleNewUserMessage } from "$lib/notifications/rules";
|
import { handleNewUserMessage } from "$lib/notifications/rules";
|
||||||
import { setSkipNextGreeting } from "$lib/tauri";
|
|
||||||
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
|
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -14,9 +13,6 @@
|
|||||||
|
|
||||||
let { onExpand }: Props = $props();
|
let { onExpand }: Props = $props();
|
||||||
|
|
||||||
const configValues = configStore.config;
|
|
||||||
const hasBackgroundImage = $derived($configValues.background_image_path !== null);
|
|
||||||
|
|
||||||
let inputValue = $state("");
|
let inputValue = $state("");
|
||||||
let isSubmitting = $state(false);
|
let isSubmitting = $state(false);
|
||||||
let isConnected = $state(false);
|
let isConnected = $state(false);
|
||||||
@@ -131,11 +127,8 @@
|
|||||||
const conversationId = get(claudeStore.activeConversationId);
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
if (!conversationId) return;
|
if (!conversationId) return;
|
||||||
|
|
||||||
// Set flag to preserve stats/permissions (don't treat next connect as new session)
|
|
||||||
setSkipNextGreeting(true);
|
|
||||||
|
|
||||||
await invoke("interrupt_claude", { conversationId });
|
await invoke("interrupt_claude", { conversationId });
|
||||||
claudeStore.addLine("system", "Process interrupted via stop button");
|
claudeStore.addLine("system", "Interrupted");
|
||||||
characterState.setState("idle");
|
characterState.setState("idle");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to interrupt:", error);
|
console.error("Failed to interrupt:", error);
|
||||||
@@ -153,10 +146,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="compact-container {getStateGlow()}">
|
||||||
class="compact-container {getStateGlow()}"
|
|
||||||
style={hasBackgroundImage ? "background: transparent !important;" : ""}
|
|
||||||
>
|
|
||||||
<!-- Character sprite (smaller) -->
|
<!-- Character sprite (smaller) -->
|
||||||
<div class="compact-character">
|
<div class="compact-character">
|
||||||
<div class="sprite-wrapper {getAnimationClass()}">
|
<div class="sprite-wrapper {getAnimationClass()}">
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
type Theme,
|
type Theme,
|
||||||
type CustomThemeColors,
|
type CustomThemeColors,
|
||||||
applyFontSize,
|
applyFontSize,
|
||||||
applyCustomFont,
|
|
||||||
applyCustomUiFont,
|
|
||||||
applyCustomThemeColors,
|
applyCustomThemeColors,
|
||||||
MIN_FONT_SIZE,
|
MIN_FONT_SIZE,
|
||||||
MAX_FONT_SIZE,
|
MAX_FONT_SIZE,
|
||||||
@@ -14,9 +12,6 @@
|
|||||||
} from "$lib/stores/config";
|
} from "$lib/stores/config";
|
||||||
import { claudeStore } from "$lib/stores/claude";
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
|
||||||
import CostSummary from "./CostSummary.svelte";
|
|
||||||
|
|
||||||
let config: HikariConfig = $state({
|
let config: HikariConfig = $state({
|
||||||
model: null,
|
model: null,
|
||||||
@@ -30,6 +25,7 @@
|
|||||||
notifications_enabled: true,
|
notifications_enabled: true,
|
||||||
notification_volume: 0.7,
|
notification_volume: 0.7,
|
||||||
always_on_top: false,
|
always_on_top: false,
|
||||||
|
minimize_to_tray: false,
|
||||||
update_checks_enabled: true,
|
update_checks_enabled: true,
|
||||||
character_panel_width: null,
|
character_panel_width: null,
|
||||||
font_size: 14,
|
font_size: 14,
|
||||||
@@ -49,47 +45,9 @@
|
|||||||
text_secondary: null,
|
text_secondary: null,
|
||||||
border_color: 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,
|
|
||||||
use_worktree: false,
|
|
||||||
disable_1m_context: false,
|
|
||||||
max_output_tokens: null,
|
|
||||||
trusted_workspaces: [],
|
|
||||||
background_image_path: null,
|
|
||||||
background_image_opacity: 0.3,
|
|
||||||
custom_font_path: null,
|
|
||||||
custom_font_family: null,
|
|
||||||
custom_ui_font_path: null,
|
|
||||||
custom_ui_font_family: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let showCustomThemeEditor = $state(false);
|
let showCustomThemeEditor = $state(false);
|
||||||
let customFontPathInput = $state("");
|
|
||||||
let customFontFamilyInput = $state("");
|
|
||||||
let customFontStatus: string | null = $state(null);
|
|
||||||
let customUiFontPathInput = $state("");
|
|
||||||
let customUiFontFamilyInput = $state("");
|
|
||||||
let customUiFontStatus: string | null = $state(null);
|
|
||||||
|
|
||||||
interface AuthStatus {
|
|
||||||
is_logged_in: boolean;
|
|
||||||
email: string | null;
|
|
||||||
org_id: string | null;
|
|
||||||
org_name: string | null;
|
|
||||||
api_key_source: string | null;
|
|
||||||
api_provider: string | null;
|
|
||||||
subscription_type: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let authStatus: AuthStatus | null = $state(null);
|
|
||||||
let authLoading = $state(false);
|
|
||||||
let authActionLoading = $state(false);
|
|
||||||
let authError: string | null = $state(null);
|
|
||||||
|
|
||||||
let isOpen = $state(false);
|
let isOpen = $state(false);
|
||||||
let isSaving = $state(false);
|
let isSaving = $state(false);
|
||||||
@@ -100,17 +58,10 @@
|
|||||||
|
|
||||||
configStore.config.subscribe((c) => {
|
configStore.config.subscribe((c) => {
|
||||||
config = { ...c };
|
config = { ...c };
|
||||||
customFontPathInput = c.custom_font_path ?? "";
|
|
||||||
customFontFamilyInput = c.custom_font_family ?? "";
|
|
||||||
customUiFontPathInput = c.custom_ui_font_path ?? "";
|
|
||||||
customUiFontFamilyInput = c.custom_ui_font_family ?? "";
|
|
||||||
});
|
});
|
||||||
|
|
||||||
configStore.isSidebarOpen.subscribe((open) => {
|
configStore.isSidebarOpen.subscribe((open) => {
|
||||||
isOpen = open;
|
isOpen = open;
|
||||||
if (open && authStatus === null) {
|
|
||||||
void refreshAuthStatus();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
configStore.saveError.subscribe((error) => {
|
configStore.saveError.subscribe((error) => {
|
||||||
@@ -123,22 +74,8 @@
|
|||||||
|
|
||||||
const availableModels = [
|
const availableModels = [
|
||||||
{ value: "", label: "Default (from ~/.claude)" },
|
{ 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-sonnet-4-20250514", label: "Claude Sonnet 4" },
|
||||||
{ value: "claude-opus-4-20250514", label: "Claude Opus 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 = [
|
const commonTools = [
|
||||||
@@ -153,44 +90,6 @@
|
|||||||
"Task",
|
"Task",
|
||||||
];
|
];
|
||||||
|
|
||||||
async function refreshAuthStatus() {
|
|
||||||
authLoading = true;
|
|
||||||
authError = null;
|
|
||||||
try {
|
|
||||||
authStatus = await invoke<AuthStatus>("get_auth_status");
|
|
||||||
} catch (e) {
|
|
||||||
authError = String(e);
|
|
||||||
} finally {
|
|
||||||
authLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAuthLogin() {
|
|
||||||
authActionLoading = true;
|
|
||||||
authError = null;
|
|
||||||
try {
|
|
||||||
await invoke<string>("auth_login");
|
|
||||||
await refreshAuthStatus();
|
|
||||||
} catch (e) {
|
|
||||||
authError = String(e);
|
|
||||||
} finally {
|
|
||||||
authActionLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAuthLogout() {
|
|
||||||
authActionLoading = true;
|
|
||||||
authError = null;
|
|
||||||
try {
|
|
||||||
await invoke<string>("auth_logout");
|
|
||||||
await refreshAuthStatus();
|
|
||||||
} catch (e) {
|
|
||||||
authError = String(e);
|
|
||||||
} finally {
|
|
||||||
authActionLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
saveError = null;
|
saveError = null;
|
||||||
@@ -262,20 +161,6 @@
|
|||||||
await window.setAlwaysOnTop(enabled);
|
await window.setAlwaysOnTop(enabled);
|
||||||
await configStore.updateConfig({ always_on_top: enabled });
|
await configStore.updateConfig({ always_on_top: enabled });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pickBackgroundImage() {
|
|
||||||
const selected = await open({
|
|
||||||
multiple: false,
|
|
||||||
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "gif", "avif"] }],
|
|
||||||
});
|
|
||||||
if (selected) {
|
|
||||||
config.background_image_path = selected;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearBackgroundImage() {
|
|
||||||
config.background_image_path = null;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Backdrop -->
|
<!-- Backdrop -->
|
||||||
@@ -322,109 +207,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Account Section -->
|
|
||||||
<section class="mb-6">
|
|
||||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
|
||||||
Account
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{#if authLoading}
|
|
||||||
<div class="text-sm text-[var(--text-secondary)] py-2">Checking auth status...</div>
|
|
||||||
{:else if authStatus}
|
|
||||||
<div class="flex items-center gap-2 mb-3">
|
|
||||||
<span
|
|
||||||
class="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 {authStatus.is_logged_in
|
|
||||||
? 'bg-green-500'
|
|
||||||
: 'bg-red-500'}"
|
|
||||||
></span>
|
|
||||||
<span class="text-sm font-medium text-[var(--text-primary)]">
|
|
||||||
{authStatus.is_logged_in ? "Logged in" : "Not logged in"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{#if authStatus.email || authStatus.org_name || authStatus.api_key_source || config.api_key}
|
|
||||||
<dl class="text-xs space-y-1 mb-3">
|
|
||||||
{#if authStatus.email}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Email</dt>
|
|
||||||
<dd class="text-[var(--text-primary)] break-all">{authStatus.email}</dd>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if authStatus.org_name}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Org</dt>
|
|
||||||
<dd class="text-[var(--text-primary)]">{authStatus.org_name}</dd>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if authStatus.org_id}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Org UUID</dt>
|
|
||||||
<dd class="text-[var(--text-secondary)] font-mono text-[10px] break-all">
|
|
||||||
{authStatus.org_id}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if authStatus.api_key_source}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">API key</dt>
|
|
||||||
<dd class="text-[var(--text-primary)]">{authStatus.api_key_source}</dd>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if authStatus.subscription_type}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Plan</dt>
|
|
||||||
<dd class="text-[var(--text-primary)]">{authStatus.subscription_type}</dd>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Override</dt>
|
|
||||||
<dd class="text-[var(--text-primary)]">
|
|
||||||
{#if config.api_key}
|
|
||||||
{config.streamer_mode ? "Custom key set 🔒" : "Custom key set"}
|
|
||||||
{:else}
|
|
||||||
None
|
|
||||||
{/if}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<div class="text-sm text-[var(--text-secondary)] py-2">Auth status unavailable</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if authError}
|
|
||||||
<div class="mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-red-400 text-xs">
|
|
||||||
{authError}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
onclick={refreshAuthStatus}
|
|
||||||
disabled={authLoading || authActionLoading}
|
|
||||||
class="px-3 py-1.5 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-secondary)] hover:border-[var(--accent-primary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
{#if authStatus && !authStatus.is_logged_in}
|
|
||||||
<button
|
|
||||||
onclick={handleAuthLogin}
|
|
||||||
disabled={authActionLoading}
|
|
||||||
class="btn-trans-gradient px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{authActionLoading ? "Logging in..." : "Login"}
|
|
||||||
</button>
|
|
||||||
{:else if authStatus && authStatus.is_logged_in}
|
|
||||||
<button
|
|
||||||
onclick={handleAuthLogout}
|
|
||||||
disabled={authActionLoading}
|
|
||||||
class="px-3 py-1.5 text-sm bg-red-500/20 border border-red-500/50 rounded-lg text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{authActionLoading ? "Logging out..." : "Logout"}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Agent Settings Section -->
|
<!-- Agent Settings Section -->
|
||||||
<section class="mb-6">
|
<section class="mb-6">
|
||||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
@@ -519,56 +301,6 @@
|
|||||||
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none"
|
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Worktree Isolation -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="flex items-center gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
bind:checked={config.use_worktree}
|
|
||||||
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-[var(--text-primary)]">Worktree isolation</span>
|
|
||||||
</label>
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
|
||||||
Launch sessions with <code class="font-mono">--worktree</code> for isolated git worktree environments
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Disable 1M Context Window -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="flex items-center gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
bind:checked={config.disable_1m_context}
|
|
||||||
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-[var(--text-primary)]">Disable 1M context window</span>
|
|
||||||
</label>
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
|
||||||
Sets <code class="font-mono">CLAUDE_CODE_DISABLE_1M_CONTEXT=1</code> to opt out of the extended
|
|
||||||
context window
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Max Output Tokens -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm text-[var(--text-primary)] mb-1" for="max-output-tokens">
|
|
||||||
Max output tokens
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="max-output-tokens"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
placeholder="Default (32000)"
|
|
||||||
bind:value={config.max_output_tokens}
|
|
||||||
class="w-full px-3 py-2 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
|
||||||
Sets <code class="font-mono">CLAUDE_CODE_MAX_OUTPUT_TOKENS</code> — increase if responses are
|
|
||||||
being cut off mid-reply
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Greeting Section -->
|
<!-- Greeting Section -->
|
||||||
@@ -952,192 +684,6 @@
|
|||||||
Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset
|
Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Custom Terminal Font -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<span class="block text-sm text-[var(--text-secondary)] mb-2">Custom Terminal Font</span>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={customFontPathInput}
|
|
||||||
placeholder="URL or local file path (e.g. /path/to/font.ttf)"
|
|
||||||
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={customFontFamilyInput}
|
|
||||||
placeholder="Font family name (e.g. FiraCode)"
|
|
||||||
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
|
|
||||||
/>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
onclick={async () => {
|
|
||||||
customFontStatus = null;
|
|
||||||
try {
|
|
||||||
await configStore.setCustomFont(
|
|
||||||
customFontPathInput || null,
|
|
||||||
customFontFamilyInput || null
|
|
||||||
);
|
|
||||||
customFontStatus = "Font applied!";
|
|
||||||
} catch (e) {
|
|
||||||
customFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={async () => {
|
|
||||||
customFontStatus = null;
|
|
||||||
customFontPathInput = "";
|
|
||||||
customFontFamilyInput = "";
|
|
||||||
try {
|
|
||||||
await configStore.setCustomFont(null, null);
|
|
||||||
await applyCustomFont(null, null);
|
|
||||||
customFontStatus = "Font reset to default.";
|
|
||||||
} catch (e) {
|
|
||||||
customFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{#if customFontStatus}
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)]">{customFontStatus}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
|
||||||
Supports Google Fonts URLs, direct font file URLs, or local file paths. Family name is
|
|
||||||
required to apply the font.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Custom UI Font -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<span class="block text-sm text-[var(--text-secondary)] mb-2">Custom UI Font</span>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={customUiFontPathInput}
|
|
||||||
placeholder="URL or local file path (e.g. /path/to/font.ttf)"
|
|
||||||
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={customUiFontFamilyInput}
|
|
||||||
placeholder="Font family name (e.g. Inter)"
|
|
||||||
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
|
|
||||||
/>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
onclick={async () => {
|
|
||||||
customUiFontStatus = null;
|
|
||||||
try {
|
|
||||||
await configStore.setCustomUiFont(
|
|
||||||
customUiFontPathInput || null,
|
|
||||||
customUiFontFamilyInput || null
|
|
||||||
);
|
|
||||||
customUiFontStatus = "Font applied!";
|
|
||||||
} catch (e) {
|
|
||||||
customUiFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
Apply UI Font
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={async () => {
|
|
||||||
customUiFontStatus = null;
|
|
||||||
customUiFontPathInput = "";
|
|
||||||
customUiFontFamilyInput = "";
|
|
||||||
try {
|
|
||||||
await configStore.setCustomUiFont(null, null);
|
|
||||||
await applyCustomUiFont(null, null);
|
|
||||||
customUiFontStatus = "Font reset to default.";
|
|
||||||
} catch (e) {
|
|
||||||
customUiFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{#if customUiFontStatus}
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)]">{customUiFontStatus}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
|
||||||
Applies to the entire app interface (menus, labels, buttons). Supports Google Fonts URLs,
|
|
||||||
direct font file URLs, or local file paths.
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<!-- Background Image -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<span class="block text-sm text-[var(--text-secondary)] mb-2">Background Image</span>
|
|
||||||
{#if config.background_image_path}
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)] font-mono mb-2 truncate">
|
|
||||||
{config.background_image_path.split("/").pop()}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
onclick={pickBackgroundImage}
|
|
||||||
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
{config.background_image_path ? "Change Image" : "Choose Image"}
|
|
||||||
</button>
|
|
||||||
{#if config.background_image_path}
|
|
||||||
<button
|
|
||||||
onclick={clearBackgroundImage}
|
|
||||||
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
|
|
||||||
title="Remove background image"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if config.background_image_path}
|
|
||||||
<div class="mt-3">
|
|
||||||
<div class="flex items-center justify-between mb-1">
|
|
||||||
<label for="bg-opacity" class="text-xs text-[var(--text-secondary)]"> Opacity </label>
|
|
||||||
<span class="text-xs text-[var(--text-tertiary)]">
|
|
||||||
{Math.round(config.background_image_opacity * 100)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="bg-opacity"
|
|
||||||
type="range"
|
|
||||||
bind:value={config.background_image_opacity}
|
|
||||||
min="0.05"
|
|
||||||
max="1"
|
|
||||||
step="0.05"
|
|
||||||
class="w-full h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Window Section -->
|
<!-- Window Section -->
|
||||||
@@ -1162,6 +708,21 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 -->
|
<!-- Update Checks Toggle -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="flex items-center gap-3 cursor-pointer">
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
@@ -1217,135 +778,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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 -->
|
<!-- Notifications Section -->
|
||||||
<section class="mb-6">
|
<section class="mb-6">
|
||||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
@@ -1391,30 +823,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Save Button -->
|
||||||
<div class="sticky bottom-0 pt-4 pb-2 bg-[var(--bg-secondary)]">
|
<div class="sticky bottom-0 pt-4 pb-2 bg-[var(--bg-secondary)]">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -12,25 +12,6 @@
|
|||||||
let editingTabId = $state<string | null>(null);
|
let editingTabId = $state<string | null>(null);
|
||||||
let editingName = $state("");
|
let editingName = $state("");
|
||||||
|
|
||||||
// Tab order for pointer-drag reordering
|
|
||||||
let tabOrder = $state<string[]>([]);
|
|
||||||
let draggedId = $state<string | null>(null);
|
|
||||||
let dragOverId = $state<string | null>(null);
|
|
||||||
let dragStartX = 0;
|
|
||||||
let isDragging = false;
|
|
||||||
let wasDragged = false;
|
|
||||||
let tabsRef = $state<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
// Keep tabOrder in sync with conversations map (add new, remove deleted)
|
|
||||||
$effect(() => {
|
|
||||||
const currentIds = Array.from($conversations.keys());
|
|
||||||
const validIds = tabOrder.filter((id) => currentIds.includes(id));
|
|
||||||
const newIds = currentIds.filter((id) => !tabOrder.includes(id));
|
|
||||||
if (validIds.length !== tabOrder.length || newIds.length > 0) {
|
|
||||||
tabOrder = [...validIds, ...newIds];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track last seen message count for each conversation
|
// Track last seen message count for each conversation
|
||||||
let lastSeenMessageCount = new SvelteMap<string, number>();
|
let lastSeenMessageCount = new SvelteMap<string, number>();
|
||||||
|
|
||||||
@@ -157,73 +138,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTabClick(id: string) {
|
|
||||||
if (wasDragged) {
|
|
||||||
wasDragged = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await switchTab(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerDown(event: PointerEvent, id: string) {
|
|
||||||
if (editingTabId === id) return;
|
|
||||||
draggedId = id;
|
|
||||||
dragStartX = event.clientX;
|
|
||||||
isDragging = false;
|
|
||||||
wasDragged = false;
|
|
||||||
|
|
||||||
function onMove(e: PointerEvent) {
|
|
||||||
if (!isDragging && Math.abs(e.clientX - dragStartX) > 5) {
|
|
||||||
isDragging = true;
|
|
||||||
}
|
|
||||||
if (!isDragging || !tabsRef) return;
|
|
||||||
const tabs = tabsRef.querySelectorAll<HTMLElement>("[data-tab-id]");
|
|
||||||
dragOverId = null;
|
|
||||||
for (const tab of tabs) {
|
|
||||||
const rect = tab.getBoundingClientRect();
|
|
||||||
if (e.clientX >= rect.left && e.clientX <= rect.right) {
|
|
||||||
const tabId = tab.dataset.tabId;
|
|
||||||
if (tabId && tabId !== id) {
|
|
||||||
dragOverId = tabId;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onUp() {
|
|
||||||
if (isDragging && draggedId && dragOverId && draggedId !== dragOverId) {
|
|
||||||
const order = [...tabOrder];
|
|
||||||
const fromIndex = order.indexOf(draggedId);
|
|
||||||
const toIndex = order.indexOf(dragOverId);
|
|
||||||
order.splice(fromIndex, 1);
|
|
||||||
order.splice(toIndex, 0, draggedId);
|
|
||||||
tabOrder = order;
|
|
||||||
wasDragged = true;
|
|
||||||
}
|
|
||||||
draggedId = null;
|
|
||||||
dragOverId = null;
|
|
||||||
isDragging = false;
|
|
||||||
window.removeEventListener("pointermove", onMove);
|
|
||||||
window.removeEventListener("pointerup", onUp);
|
|
||||||
window.removeEventListener("pointercancel", onUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("pointermove", onMove);
|
|
||||||
window.addEventListener("pointerup", onUp);
|
|
||||||
window.addEventListener("pointercancel", onUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Initialise all conversations as seen on mount so that remounting
|
|
||||||
// this component (e.g. after closing the file editor) doesn't falsely
|
|
||||||
// mark existing messages as unread.
|
|
||||||
for (const [id, conversation] of $conversations) {
|
|
||||||
lastSeenMessageCount.set(id, conversation.terminalLines.length);
|
|
||||||
}
|
|
||||||
lastSeenMessageCount = lastSeenMessageCount;
|
|
||||||
|
|
||||||
function handleGlobalKeydown(event: KeyboardEvent) {
|
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||||
// Ctrl/Cmd + T: New tab
|
// Ctrl/Cmd + T: New tab
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === "t") {
|
if ((event.ctrlKey || event.metaKey) && event.key === "t") {
|
||||||
@@ -249,19 +165,21 @@
|
|||||||
// Ctrl/Cmd + Tab: Next tab
|
// Ctrl/Cmd + Tab: Next tab
|
||||||
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
|
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
|
const tabs = Array.from($conversations.keys());
|
||||||
|
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
|
||||||
if (currentIndex !== -1) {
|
if (currentIndex !== -1) {
|
||||||
const nextIndex = (currentIndex + 1) % tabOrder.length;
|
const nextIndex = (currentIndex + 1) % tabs.length;
|
||||||
claudeStore.switchConversation(tabOrder[nextIndex]);
|
claudeStore.switchConversation(tabs[nextIndex]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Ctrl/Cmd + Shift + Tab: Previous tab
|
// Ctrl/Cmd + Shift + Tab: Previous tab
|
||||||
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
|
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
|
const tabs = Array.from($conversations.keys());
|
||||||
|
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
|
||||||
if (currentIndex !== -1) {
|
if (currentIndex !== -1) {
|
||||||
const prevIndex = (currentIndex - 1 + tabOrder.length) % tabOrder.length;
|
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
||||||
claudeStore.switchConversation(tabOrder[prevIndex]);
|
claudeStore.switchConversation(tabs[prevIndex]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,22 +190,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={tabsRef}
|
|
||||||
class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
|
class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
|
||||||
>
|
>
|
||||||
{#each tabOrder
|
{#each Array.from($conversations.entries()) as [id, conversation] (id)}
|
||||||
.filter((id) => $conversations.has(id))
|
|
||||||
.map((id) => ({ id, conversation: $conversations.get(id)! })) as { id, conversation } (id)}
|
|
||||||
<div
|
<div
|
||||||
data-tab-id={id}
|
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all
|
||||||
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t transition-all
|
|
||||||
{id === $activeConversationId
|
{id === $activeConversationId
|
||||||
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
|
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
|
||||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}
|
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
|
||||||
{dragOverId === id && draggedId !== id ? 'drag-over' : ''}
|
onclick={() => switchTab(id)}
|
||||||
{draggedId === id ? 'dragging' : ''}"
|
|
||||||
onpointerdown={(e) => handlePointerDown(e, id)}
|
|
||||||
onclick={() => handleTabClick(id)}
|
|
||||||
onkeydown={(e) => handleTabKeydown(id, e)}
|
onkeydown={(e) => handleTabKeydown(id, e)}
|
||||||
role="tab"
|
role="tab"
|
||||||
tabindex={0}
|
tabindex={0}
|
||||||
@@ -300,7 +211,7 @@
|
|||||||
onblur={saveTabName}
|
onblur={saveTabName}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32 select-text"
|
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -385,20 +296,5 @@
|
|||||||
|
|
||||||
.tab-item {
|
.tab-item {
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
cursor: grab;
|
|
||||||
touch-action: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-item:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-over {
|
|
||||||
border-left: 2px solid var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dragging {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
/**
|
|
||||||
* ConversationTabs Component Tests
|
|
||||||
*
|
|
||||||
* Tests the connection status colour mapping and unread message detection
|
|
||||||
* logic used by the ConversationTabs component.
|
|
||||||
*
|
|
||||||
* What this component does:
|
|
||||||
* - Displays one tab per conversation
|
|
||||||
* - Each tab shows a coloured dot for its connection state
|
|
||||||
* - Inactive tabs with new messages show an animated blue dot badge
|
|
||||||
* - Tabs can be renamed by double-clicking
|
|
||||||
* - Tabs can be reordered by drag-and-drop
|
|
||||||
* - New tabs created with Ctrl+T, closed with Ctrl+W
|
|
||||||
*
|
|
||||||
* Manual testing checklist:
|
|
||||||
* - [ ] Connected tabs show a green dot
|
|
||||||
* - [ ] Connecting tabs show a yellow dot
|
|
||||||
* - [ ] Disconnected tabs show a red dot
|
|
||||||
* - [ ] Active tab never shows the unread badge
|
|
||||||
* - [ ] Inactive tab shows blue pulsing dot when it receives new messages
|
|
||||||
* - [ ] Switching to a tab clears the unread indicator
|
|
||||||
* - [ ] Double-clicking a tab name enables inline editing
|
|
||||||
* - [ ] Tabs can be dragged to reorder
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
|
|
||||||
type ConnectionStatus = "connected" | "connecting" | "disconnected";
|
|
||||||
|
|
||||||
function getConnectionStatusColor(status: ConnectionStatus | string): string {
|
|
||||||
switch (status) {
|
|
||||||
case "connected":
|
|
||||||
return "bg-green-500";
|
|
||||||
case "connecting":
|
|
||||||
return "bg-yellow-500";
|
|
||||||
case "disconnected":
|
|
||||||
return "bg-red-500";
|
|
||||||
default:
|
|
||||||
return "bg-gray-500";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasUnreadMessages(
|
|
||||||
id: string,
|
|
||||||
conversationLineCount: number,
|
|
||||||
activeConversationId: string | null,
|
|
||||||
lastSeenMessageCount: Map<string, number>
|
|
||||||
): boolean {
|
|
||||||
if (id === activeConversationId) return false;
|
|
||||||
const lastSeen = lastSeenMessageCount.get(id) ?? 0;
|
|
||||||
return conversationLineCount > lastSeen;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---
|
|
||||||
|
|
||||||
describe("getConnectionStatusColor", () => {
|
|
||||||
it("returns green for connected status", () => {
|
|
||||||
expect(getConnectionStatusColor("connected")).toBe("bg-green-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns yellow for connecting status", () => {
|
|
||||||
expect(getConnectionStatusColor("connecting")).toBe("bg-yellow-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns red for disconnected status", () => {
|
|
||||||
expect(getConnectionStatusColor("disconnected")).toBe("bg-red-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns grey for unknown status (fallback)", () => {
|
|
||||||
expect(getConnectionStatusColor("error")).toBe("bg-gray-500");
|
|
||||||
expect(getConnectionStatusColor("")).toBe("bg-gray-500");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("hasUnreadMessages", () => {
|
|
||||||
it("returns false for the active conversation regardless of message count", () => {
|
|
||||||
const lastSeen = new Map([["tab-1", 0]]);
|
|
||||||
expect(hasUnreadMessages("tab-1", 10, "tab-1", lastSeen)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true when an inactive tab has more messages than last seen", () => {
|
|
||||||
const lastSeen = new Map([["tab-1", 5]]);
|
|
||||||
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when an inactive tab has no new messages", () => {
|
|
||||||
const lastSeen = new Map([["tab-1", 10]]);
|
|
||||||
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when an inactive tab has fewer messages than last seen", () => {
|
|
||||||
const lastSeen = new Map([["tab-1", 15]]);
|
|
||||||
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a tab with no last-seen record as having 0 messages seen", () => {
|
|
||||||
const lastSeen = new Map<string, number>();
|
|
||||||
// Tab has 1 message but no entry in lastSeen → treated as 0 seen → unread
|
|
||||||
expect(hasUnreadMessages("tab-1", 1, "tab-2", lastSeen)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for an untracked tab with 0 messages", () => {
|
|
||||||
const lastSeen = new Map<string, number>();
|
|
||||||
expect(hasUnreadMessages("tab-1", 0, "tab-2", lastSeen)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles null activeConversationId (no active tab)", () => {
|
|
||||||
const lastSeen = new Map([["tab-1", 3]]);
|
|
||||||
expect(hasUnreadMessages("tab-1", 10, null, lastSeen)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { draftsStore, type Draft } from "$lib/stores/drafts";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onClose: () => void;
|
|
||||||
onInsert: (content: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { onClose, onInsert }: Props = $props();
|
|
||||||
|
|
||||||
let confirmingDeleteId = $state<string | null>(null);
|
|
||||||
let confirmingAll = $state(false);
|
|
||||||
|
|
||||||
const drafts = $derived(draftsStore.drafts);
|
|
||||||
const isLoading = $derived(draftsStore.isLoading);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
draftsStore.loadDrafts();
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleInsert(draft: Draft): void {
|
|
||||||
onInsert(draft.content);
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(draftId: string): Promise<void> {
|
|
||||||
await draftsStore.deleteDraft(draftId);
|
|
||||||
confirmingDeleteId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteAll(): Promise<void> {
|
|
||||||
await draftsStore.deleteAllDrafts();
|
|
||||||
confirmingAll = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateContent(content: string): string {
|
|
||||||
return content.length > 120 ? content.slice(0, 120) + "…" : content;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
|
||||||
onclick={onClose}
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
onkeydown={(e) => e.stopPropagation()}
|
|
||||||
role="dialog"
|
|
||||||
aria-labelledby="draft-panel-title"
|
|
||||||
tabindex="-1"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
|
||||||
<h2 id="draft-panel-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
|
||||||
Saved Drafts
|
|
||||||
</h2>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{#if $drafts.length > 0}
|
|
||||||
{#if confirmingAll}
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onclick={handleDeleteAll}
|
|
||||||
class="px-3 py-1.5 text-sm font-medium bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
|
||||||
>
|
|
||||||
Confirm Delete All
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => (confirmingAll = false)}
|
|
||||||
class="px-3 py-1.5 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
onclick={() => (confirmingAll = true)}
|
|
||||||
class="px-3 py-1.5 text-sm font-medium text-red-400 hover:text-red-300 transition-colors border border-red-400/30 rounded-lg hover:border-red-300/50 hover:bg-red-400/10"
|
|
||||||
>
|
|
||||||
Delete All
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
onclick={onClose}
|
|
||||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto">
|
|
||||||
{#if $isLoading}
|
|
||||||
<div class="flex items-center justify-center p-8">
|
|
||||||
<div class="text-[var(--text-tertiary)]">Loading drafts...</div>
|
|
||||||
</div>
|
|
||||||
{:else if $drafts.length === 0}
|
|
||||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
|
||||||
<svg
|
|
||||||
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="1.5"
|
|
||||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p class="text-[var(--text-secondary)]">No saved drafts yet</p>
|
|
||||||
<p class="text-sm text-[var(--text-tertiary)] mt-1">
|
|
||||||
Use "Save as Draft" to store messages for later
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="divide-y divide-[var(--border-color)]">
|
|
||||||
{#each $drafts as draft (draft.id)}
|
|
||||||
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)] mb-1">
|
|
||||||
{draftsStore.formatTimestamp(draft.saved_at)}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="text-sm text-[var(--text-secondary)] font-mono whitespace-pre-wrap break-words"
|
|
||||||
>
|
|
||||||
{truncateContent(draft.content)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onclick={() => handleInsert(draft)}
|
|
||||||
class="btn-trans-gradient px-3 py-1.5 text-xs font-medium rounded"
|
|
||||||
title="Insert this draft"
|
|
||||||
>
|
|
||||||
Insert
|
|
||||||
</button>
|
|
||||||
{#if confirmingDeleteId === draft.id}
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onclick={() => handleDelete(draft.id)}
|
|
||||||
class="px-2 py-1 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => (confirmingDeleteId = null)}
|
|
||||||
class="px-2 py-1 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
onclick={() => (confirmingDeleteId = draft.id)}
|
|
||||||
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors"
|
|
||||||
title="Delete draft"
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
[role="dialog"] {
|
|
||||||
animation: slideIn 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.95) translateY(10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1) translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.overflow-y-auto {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border-color) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overflow-y-auto::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overflow-y-auto::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
|
||||||
background-color: var(--border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
|
||||||
background-color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
/**
|
|
||||||
* HighlightedText Component Tests
|
|
||||||
*
|
|
||||||
* Tests the text-splitting logic used by the HighlightedText component,
|
|
||||||
* which highlights search query matches within a string.
|
|
||||||
*
|
|
||||||
* What this component does:
|
|
||||||
* - Splits text into an array of {text, isMatch} parts
|
|
||||||
* - Matches are case-insensitive
|
|
||||||
* - Special regex characters in the query are escaped
|
|
||||||
* - Non-matching text is preserved verbatim around matches
|
|
||||||
*
|
|
||||||
* Manual testing checklist:
|
|
||||||
* - [ ] Matching text is highlighted (yellow background) in the terminal
|
|
||||||
* - [ ] Highlighting is case-insensitive
|
|
||||||
* - [ ] Multiple matches on the same line all get highlighted
|
|
||||||
* - [ ] Non-matching text renders normally alongside matches
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
|
|
||||||
interface TextPart {
|
|
||||||
text: string;
|
|
||||||
isMatch: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHighlightedParts(text: string, query: string): TextPart[] {
|
|
||||||
if (!query) {
|
|
||||||
return [{ text, isMatch: false }];
|
|
||||||
}
|
|
||||||
|
|
||||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
const regex = new RegExp(`(${escapedQuery})`, "gi");
|
|
||||||
const parts: TextPart[] = [];
|
|
||||||
let lastIndex = 0;
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
|
|
||||||
while ((match = regex.exec(text)) !== null) {
|
|
||||||
if (match.index > lastIndex) {
|
|
||||||
parts.push({
|
|
||||||
text: text.slice(lastIndex, match.index),
|
|
||||||
isMatch: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
parts.push({
|
|
||||||
text: match[1],
|
|
||||||
isMatch: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
lastIndex = regex.lastIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastIndex < text.length) {
|
|
||||||
parts.push({
|
|
||||||
text: text.slice(lastIndex),
|
|
||||||
isMatch: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---
|
|
||||||
|
|
||||||
describe("getHighlightedParts", () => {
|
|
||||||
describe("empty query", () => {
|
|
||||||
it("returns the whole text as a single non-match when query is empty string", () => {
|
|
||||||
const result = getHighlightedParts("hello world", "");
|
|
||||||
expect(result).toEqual([{ text: "hello world", isMatch: false }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty non-match part when both text and query are empty", () => {
|
|
||||||
const result = getHighlightedParts("", "");
|
|
||||||
expect(result).toEqual([{ text: "", isMatch: false }]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("no match", () => {
|
|
||||||
it("returns the whole text as a single non-match when query is not found", () => {
|
|
||||||
const result = getHighlightedParts("hello world", "xyz");
|
|
||||||
expect(result).toEqual([{ text: "hello world", isMatch: false }]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("single match", () => {
|
|
||||||
it("returns three parts for a match in the middle", () => {
|
|
||||||
const result = getHighlightedParts("hello world foo", "world");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ text: "hello ", isMatch: false },
|
|
||||||
{ text: "world", isMatch: true },
|
|
||||||
{ text: " foo", isMatch: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns two parts for a match at the start", () => {
|
|
||||||
const result = getHighlightedParts("hello world", "hello");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ text: "hello", isMatch: true },
|
|
||||||
{ text: " world", isMatch: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns two parts for a match at the end", () => {
|
|
||||||
const result = getHighlightedParts("hello world", "world");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ text: "hello ", isMatch: false },
|
|
||||||
{ text: "world", isMatch: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a single match part when the entire text matches", () => {
|
|
||||||
const result = getHighlightedParts("hello", "hello");
|
|
||||||
expect(result).toEqual([{ text: "hello", isMatch: true }]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("multiple matches", () => {
|
|
||||||
it("returns interleaved match and non-match parts for multiple occurrences", () => {
|
|
||||||
const result = getHighlightedParts("foo bar foo", "foo");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ text: "foo", isMatch: true },
|
|
||||||
{ text: " bar ", isMatch: false },
|
|
||||||
{ text: "foo", isMatch: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles adjacent matches correctly", () => {
|
|
||||||
const result = getHighlightedParts("aaa", "a");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ text: "a", isMatch: true },
|
|
||||||
{ text: "a", isMatch: true },
|
|
||||||
{ text: "a", isMatch: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("case-insensitive matching", () => {
|
|
||||||
it("matches uppercase query against lowercase text", () => {
|
|
||||||
const result = getHighlightedParts("hello world", "WORLD");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ text: "hello ", isMatch: false },
|
|
||||||
{ text: "world", isMatch: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches lowercase query against uppercase text", () => {
|
|
||||||
const result = getHighlightedParts("HELLO WORLD", "hello");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ text: "HELLO", isMatch: true },
|
|
||||||
{ text: " WORLD", isMatch: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves the original casing of the matched text", () => {
|
|
||||||
const result = getHighlightedParts("Hello World", "hello");
|
|
||||||
const matchPart = result.find((p) => p.isMatch);
|
|
||||||
expect(matchPart?.text).toBe("Hello");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("special regex character escaping", () => {
|
|
||||||
it("treats a dot in the query as a literal dot, not a wildcard", () => {
|
|
||||||
const result = getHighlightedParts("v1.2.3 v123", "1.2");
|
|
||||||
const matchParts = result.filter((p) => p.isMatch);
|
|
||||||
expect(matchParts).toHaveLength(1);
|
|
||||||
expect(matchParts[0].text).toBe("1.2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles a query with parentheses", () => {
|
|
||||||
const result = getHighlightedParts("fn(args)", "(args)");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ text: "fn", isMatch: false },
|
|
||||||
{ text: "(args)", isMatch: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles a query with a plus sign", () => {
|
|
||||||
const result = getHighlightedParts("a+b=c", "+");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ text: "a", isMatch: false },
|
|
||||||
{ text: "+", isMatch: true },
|
|
||||||
{ text: "b=c", isMatch: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles a query with a question mark", () => {
|
|
||||||
const result = getHighlightedParts("is it true?", "?");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ text: "is it true", isMatch: false },
|
|
||||||
{ text: "?", isMatch: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
import { handleNewUserMessage } from "$lib/notifications/rules";
|
import { handleNewUserMessage } from "$lib/notifications/rules";
|
||||||
import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri";
|
import { setSkipNextGreeting } from "$lib/tauri";
|
||||||
import { clipboardStore } from "$lib/stores/clipboard";
|
import { clipboardStore } from "$lib/stores/clipboard";
|
||||||
import {
|
import {
|
||||||
setShouldRestoreHistory,
|
setShouldRestoreHistory,
|
||||||
@@ -17,8 +17,6 @@
|
|||||||
} from "$lib/stores/historyRestore";
|
} from "$lib/stores/historyRestore";
|
||||||
import MessageModeSelector from "$lib/components/MessageModeSelector.svelte";
|
import MessageModeSelector from "$lib/components/MessageModeSelector.svelte";
|
||||||
import SlashCommandMenu from "$lib/components/SlashCommandMenu.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 { getCurrentMode } from "$lib/stores/messageMode";
|
||||||
import { formatMessageWithMode } from "$lib/types/messageMode";
|
import { formatMessageWithMode } from "$lib/types/messageMode";
|
||||||
import {
|
import {
|
||||||
@@ -28,15 +26,11 @@
|
|||||||
type SlashCommand,
|
type SlashCommand,
|
||||||
} from "$lib/commands/slashCommands";
|
} from "$lib/commands/slashCommands";
|
||||||
import { configStore, isStreamerMode } from "$lib/stores/config";
|
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 AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
|
||||||
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
|
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
|
||||||
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
|
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
|
||||||
import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte";
|
import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte";
|
||||||
import DraftPanel from "$lib/components/DraftPanel.svelte";
|
|
||||||
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
|
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
|
||||||
import { draftsStore } from "$lib/stores/drafts";
|
|
||||||
import type { Attachment } from "$lib/types/messages";
|
import type { Attachment } from "$lib/types/messages";
|
||||||
|
|
||||||
const INPUT_HISTORY_KEY = "hikari-input-history";
|
const INPUT_HISTORY_KEY = "hikari-input-history";
|
||||||
@@ -54,16 +48,8 @@
|
|||||||
let showSnippetLibrary = $state(false);
|
let showSnippetLibrary = $state(false);
|
||||||
let showQuickActions = $state(false);
|
let showQuickActions = $state(false);
|
||||||
let showClipboardHistory = $state(false);
|
let showClipboardHistory = $state(false);
|
||||||
let showDraftPanel = $state(false);
|
|
||||||
let streamerModeActive = $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
|
// Context menu state
|
||||||
let textareaElement: HTMLTextAreaElement | undefined = $state();
|
let textareaElement: HTMLTextAreaElement | undefined = $state();
|
||||||
let contextMenuShow = $state(false);
|
let contextMenuShow = $state(false);
|
||||||
@@ -167,25 +153,6 @@
|
|||||||
attachments = storedAttachments;
|
attachments = storedAttachments;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Per-tab draft persistence — restore the draft text whenever the active
|
|
||||||
// conversation changes, and save it back on every keystroke.
|
|
||||||
claudeStore.activeConversationId.subscribe((conversationId) => {
|
|
||||||
if (conversationId) {
|
|
||||||
const conv = get(claudeStore.conversations).get(conversationId);
|
|
||||||
inputValue = conv?.draftText ?? "";
|
|
||||||
} else {
|
|
||||||
inputValue = "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function clearInput() {
|
|
||||||
inputValue = "";
|
|
||||||
const activeId = get(claudeStore.activeConversationId);
|
|
||||||
if (activeId) {
|
|
||||||
claudeStore.setDraftText(activeId, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInputChange() {
|
function handleInputChange() {
|
||||||
// If input is empty, allow history navigation again
|
// If input is empty, allow history navigation again
|
||||||
// Otherwise, mark that user has manually typed
|
// Otherwise, mark that user has manually typed
|
||||||
@@ -198,12 +165,6 @@
|
|||||||
historyIndex = -1;
|
historyIndex = -1;
|
||||||
tempInput = "";
|
tempInput = "";
|
||||||
|
|
||||||
// Save the current draft so it persists if the user switches tabs.
|
|
||||||
const activeId = get(claudeStore.activeConversationId);
|
|
||||||
if (activeId) {
|
|
||||||
claudeStore.setDraftText(activeId, inputValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSlashCommand(inputValue)) {
|
if (isSlashCommand(inputValue)) {
|
||||||
matchingCommands = getMatchingCommands(inputValue);
|
matchingCommands = getMatchingCommands(inputValue);
|
||||||
showCommandMenu = matchingCommands.length > 0;
|
showCommandMenu = matchingCommands.length > 0;
|
||||||
@@ -223,7 +184,7 @@
|
|||||||
async function executeSlashCommand(): Promise<boolean> {
|
async function executeSlashCommand(): Promise<boolean> {
|
||||||
const { command, args } = parseSlashCommand(inputValue);
|
const { command, args } = parseSlashCommand(inputValue);
|
||||||
if (command) {
|
if (command) {
|
||||||
clearInput();
|
inputValue = "";
|
||||||
showCommandMenu = false;
|
showCommandMenu = false;
|
||||||
matchingCommands = [];
|
matchingCommands = [];
|
||||||
await command.execute(args);
|
await command.execute(args);
|
||||||
@@ -256,7 +217,7 @@
|
|||||||
"error",
|
"error",
|
||||||
`Unknown command: ${message.split(" ")[0]}. Type /help for available commands.`
|
`Unknown command: ${message.split(" ")[0]}. Type /help for available commands.`
|
||||||
);
|
);
|
||||||
clearInput();
|
inputValue = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +233,7 @@
|
|||||||
userHasTyped = false;
|
userHasTyped = false;
|
||||||
|
|
||||||
isSubmitting = true;
|
isSubmitting = true;
|
||||||
clearInput();
|
inputValue = "";
|
||||||
|
|
||||||
// Capture attachments before clearing
|
// Capture attachments before clearing
|
||||||
const currentAttachments = [...attachments];
|
const currentAttachments = [...attachments];
|
||||||
@@ -354,7 +315,7 @@ User: ${formattedMessage}`;
|
|||||||
throw new Error("No active conversation");
|
throw new Error("No active conversation");
|
||||||
}
|
}
|
||||||
await invoke("interrupt_claude", { conversationId });
|
await invoke("interrupt_claude", { conversationId });
|
||||||
claudeStore.addLine("system", "Process interrupted via stop button — reconnecting...");
|
claudeStore.addLine("system", "Process interrupted - reconnecting...");
|
||||||
characterState.setState("idle");
|
characterState.setState("idle");
|
||||||
|
|
||||||
// Show connecting status while we reconnect
|
// Show connecting status while we reconnect
|
||||||
@@ -368,41 +329,19 @@ User: ${formattedMessage}`;
|
|||||||
throw new Error("No active conversation");
|
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 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
|
// Set the flag to skip greeting on next connection
|
||||||
setSkipNextGreeting(true);
|
setSkipNextGreeting(true);
|
||||||
|
|
||||||
// Reconnect to Claude with preserved permissions
|
// Reconnect to Claude
|
||||||
await invoke("start_claude", {
|
await invoke("start_claude", {
|
||||||
conversationId,
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
working_dir: workingDir,
|
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,
|
|
||||||
use_worktree: config.use_worktree ?? false,
|
|
||||||
disable_1m_context: config.disable_1m_context ?? false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update Discord RPC when reconnecting
|
|
||||||
if (activeConversation) {
|
|
||||||
await updateDiscordRpc(
|
|
||||||
activeConversation.name,
|
|
||||||
config.model || "claude",
|
|
||||||
activeConversation.startedAt
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (reconnectError) {
|
} catch (reconnectError) {
|
||||||
console.error("Failed to auto-reconnect:", reconnectError);
|
console.error("Failed to auto-reconnect:", reconnectError);
|
||||||
claudeStore.addLine("error", `Failed to reconnect: ${reconnectError}`);
|
claudeStore.addLine("error", `Failed to reconnect: ${reconnectError}`);
|
||||||
@@ -488,12 +427,11 @@ User: ${formattedMessage}`;
|
|||||||
try {
|
try {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const bytes = Array.from(new Uint8Array(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,
|
conversationId,
|
||||||
filename,
|
filename,
|
||||||
data: bytes,
|
data: bytes,
|
||||||
});
|
});
|
||||||
savedPath = result.path;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save dropped file to temp:", error);
|
console.error("Failed to save dropped file to temp:", error);
|
||||||
savedPath = file.name;
|
savedPath = file.name;
|
||||||
@@ -627,12 +565,11 @@ User: ${formattedMessage}`;
|
|||||||
try {
|
try {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const bytes = Array.from(new Uint8Array(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,
|
conversationId,
|
||||||
filename,
|
filename,
|
||||||
data: bytes,
|
data: bytes,
|
||||||
});
|
});
|
||||||
savedPath = result.path;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save pasted file to temp:", error);
|
console.error("Failed to save pasted file to temp:", error);
|
||||||
}
|
}
|
||||||
@@ -690,12 +627,11 @@ User: ${formattedMessage}`;
|
|||||||
try {
|
try {
|
||||||
const arrayBuffer = await blob.arrayBuffer();
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
const bytes = Array.from(new Uint8Array(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,
|
conversationId,
|
||||||
filename,
|
filename,
|
||||||
data: bytes,
|
data: bytes,
|
||||||
});
|
});
|
||||||
savedPath = result.path;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save clipboard image to temp:", error);
|
console.error("Failed to save clipboard image to temp:", error);
|
||||||
}
|
}
|
||||||
@@ -731,22 +667,6 @@ User: ${formattedMessage}`;
|
|||||||
userHasTyped = true;
|
userHasTyped = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDraftInsert(content: string): void {
|
|
||||||
inputValue = content;
|
|
||||||
userHasTyped = true;
|
|
||||||
const activeId = get(claudeStore.activeConversationId);
|
|
||||||
if (activeId) {
|
|
||||||
claudeStore.setDraftText(activeId, content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSaveAsDraft(): Promise<void> {
|
|
||||||
const content = inputValue.trim();
|
|
||||||
if (!content) return;
|
|
||||||
await draftsStore.saveDraft(content);
|
|
||||||
clearInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClipboardInsert(content: string): void {
|
function handleClipboardInsert(content: string): void {
|
||||||
// Insert clipboard content at cursor position or append to input
|
// Insert clipboard content at cursor position or append to input
|
||||||
if (inputValue.trim()) {
|
if (inputValue.trim()) {
|
||||||
@@ -962,32 +882,6 @@ User: ${formattedMessage}`;
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Clipboard</span>
|
<span>Clipboard</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (showDraftPanel = true)}
|
|
||||||
class="control-button"
|
|
||||||
title="Saved Drafts"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Drafts</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<CliVersion />
|
|
||||||
<SystemClock />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
@@ -1011,7 +905,7 @@ User: ${formattedMessage}`;
|
|||||||
: "Connect to Claude first..."}
|
: "Connect to Claude first..."}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
rows={1}
|
rows={1}
|
||||||
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
|
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px);"
|
||||||
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
|
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
|
||||||
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
|
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
|
||||||
input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed"
|
input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
@@ -1019,36 +913,6 @@ User: ${formattedMessage}`;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="button-wrapper">
|
<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={handleSaveAsDraft}
|
|
||||||
disabled={!inputValue.trim()}
|
|
||||||
class="attach-button"
|
|
||||||
title="Save as Draft"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
|
||||||
<polyline points="17 21 17 13 7 13 7 21" />
|
|
||||||
<polyline points="7 3 7 8 15 8" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files">
|
<button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files">
|
||||||
<svg
|
<svg
|
||||||
width="20"
|
width="20"
|
||||||
@@ -1114,10 +978,6 @@ User: ${formattedMessage}`;
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showDraftPanel}
|
|
||||||
<DraftPanel onClose={() => (showDraftPanel = false)} onInsert={handleDraftInsert} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if contextMenuShow && textareaElement}
|
{#if contextMenuShow && textareaElement}
|
||||||
<TextInputContextMenu
|
<TextInputContextMenu
|
||||||
x={contextMenuX}
|
x={contextMenuX}
|
||||||
@@ -1167,7 +1027,6 @@ User: ${formattedMessage}`;
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-button {
|
.control-button {
|
||||||
@@ -1184,18 +1043,6 @@ User: ${formattedMessage}`;
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
white-space: nowrap;
|
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 {
|
.control-button:hover {
|
||||||
@@ -1291,28 +1138,6 @@ User: ${formattedMessage}`;
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
height: 100%;
|
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 {
|
.attach-button {
|
||||||
|
|||||||
@@ -35,12 +35,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderer.codespan = ({ text }) => {
|
renderer.codespan = ({ text }) => {
|
||||||
const escaped = text.replace(/</g, "<").replace(/>/g, ">");
|
return `<code class="hljs-inline">${text}</code>`;
|
||||||
return `<code class="hljs-inline">${escaped}</code>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
renderer.html = ({ text }) => {
|
|
||||||
return text.replace(/</g, "<").replace(/>/g, ">");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
@@ -281,16 +276,10 @@
|
|||||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content :global(ul) {
|
.markdown-content :global(ul),
|
||||||
margin: 0.5em 0;
|
|
||||||
padding-left: 1.5em;
|
|
||||||
list-style-type: disc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-content :global(ol) {
|
.markdown-content :global(ol) {
|
||||||
margin: 0.5em 0;
|
margin: 0.5em 0;
|
||||||
padding-left: 1.5em;
|
padding-left: 1.5em;
|
||||||
list-style-type: decimal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content :global(li) {
|
.markdown-content :global(li) {
|
||||||
|
|||||||
@@ -1,416 +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
|
|
||||||
for="mcp-new-name"
|
|
||||||
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
|
||||||
>Server Name</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="mcp-new-name"
|
|
||||||
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
|
|
||||||
for="mcp-new-transport"
|
|
||||||
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
|
||||||
>Transport</label
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
id="mcp-new-transport"
|
|
||||||
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
|
|
||||||
for="mcp-new-url"
|
|
||||||
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
|
||||||
>
|
|
||||||
{newServerTransport === "stdio" ? "Command" : "URL"}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="mcp-new-url"
|
|
||||||
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)}
|
|
||||||
{@const TransportIcon = getTransportIcon(server.transport)}
|
|
||||||
<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">
|
|
||||||
<TransportIcon 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}
|
|
||||||
{@const TransportIcon = getTransportIcon(selectedServer.transport)}
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- Name -->
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Name</p>
|
|
||||||
<p class="text-sm text-[var(--text-primary)] mt-1">{selectedServer.name}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Transport -->
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Transport</p>
|
|
||||||
<p class="text-sm text-[var(--text-primary)] mt-1 flex items-center gap-2">
|
|
||||||
<TransportIcon class="w-4 h-4 {getTransportColor(selectedServer.transport)}" />
|
|
||||||
{selectedServer.transport.toUpperCase()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- URL or Command -->
|
|
||||||
{#if selectedServer.url}
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">URL</p>
|
|
||||||
<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>
|
|
||||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Command</p>
|
|
||||||
<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>
|
|
||||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
|
|
||||||
Environment
|
|
||||||
</p>
|
|
||||||
<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>
|
|
||||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
|
|
||||||
Full Details
|
|
||||||
</p>
|
|
||||||
<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>
|
|
||||||
@@ -1,480 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import Markdown from "./Markdown.svelte";
|
|
||||||
|
|
||||||
interface MemoryFileInfo {
|
|
||||||
path: string;
|
|
||||||
heading: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MemoryFilesResponse {
|
|
||||||
files: MemoryFileInfo[];
|
|
||||||
}
|
|
||||||
|
|
||||||
let memoryFiles: MemoryFileInfo[] = $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);
|
|
||||||
|
|
||||||
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 getDisplayName(file: MemoryFileInfo): string {
|
|
||||||
return file.heading ?? getFileName(file.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.path)}
|
|
||||||
<button
|
|
||||||
class="file-item"
|
|
||||||
class:active={selectedFile === file.path}
|
|
||||||
onclick={() => loadFileContent(file.path)}
|
|
||||||
title={getFileName(file.path)}
|
|
||||||
>
|
|
||||||
<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">{getDisplayName(file)}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="file-viewer">
|
|
||||||
{#if selectedFile && fileContent}
|
|
||||||
<div class="viewer-header">
|
|
||||||
{#each memoryFiles.filter((f) => f.path === selectedFile) as activeFile (activeFile.path)}
|
|
||||||
<h4>{getDisplayName(activeFile)}</h4>
|
|
||||||
{#if activeFile.heading}
|
|
||||||
<p class="viewer-filename">{getFileName(activeFile.path)}</p>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</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-filename {
|
|
||||||
margin: 0.25rem 0 0;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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,98 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
|
|
||||||
// Mirror pure logic functions from MemoryBrowserPanel.svelte
|
|
||||||
|
|
||||||
interface MemoryFileInfo {
|
|
||||||
path: string;
|
|
||||||
heading: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFileName(path: string): string {
|
|
||||||
return path.split("/").pop() || path;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDisplayName(file: MemoryFileInfo): string {
|
|
||||||
return file.heading ?? getFileName(file.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("getFileName", () => {
|
|
||||||
it("extracts the filename from an absolute Unix path", () => {
|
|
||||||
expect(getFileName("/home/naomi/.claude/projects/foo/memory/MEMORY.md")).toBe("MEMORY.md");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("extracts the filename from a nested path", () => {
|
|
||||||
expect(getFileName("/home/naomi/.claude/projects/foo/memory/debugging.md")).toBe(
|
|
||||||
"debugging.md"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the path itself when there is no slash", () => {
|
|
||||||
expect(getFileName("MEMORY.md")).toBe("MEMORY.md");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the path when the path ends with a slash (empty filename)", () => {
|
|
||||||
// split('/').pop() returns '' for trailing slash → falls back to full path
|
|
||||||
expect(getFileName("/some/dir/")).toBe("/some/dir/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles single-component paths", () => {
|
|
||||||
expect(getFileName("notes.md")).toBe("notes.md");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles Windows-style paths passed as Unix strings", () => {
|
|
||||||
// If the path contains no forward slashes, the whole string is the filename
|
|
||||||
expect(getFileName("C:\\Users\\naomi\\memory\\file.md")).toBe(
|
|
||||||
"C:\\Users\\naomi\\memory\\file.md"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getDisplayName", () => {
|
|
||||||
it("returns the heading when the file has one", () => {
|
|
||||||
const file: MemoryFileInfo = {
|
|
||||||
path: "/home/naomi/.claude/projects/foo/memory/MEMORY.md",
|
|
||||||
heading: "Hikari Desktop - Memory",
|
|
||||||
};
|
|
||||||
expect(getDisplayName(file)).toBe("Hikari Desktop - Memory");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the filename when heading is null", () => {
|
|
||||||
const file: MemoryFileInfo = {
|
|
||||||
path: "/home/naomi/.claude/projects/foo/memory/debugging.md",
|
|
||||||
heading: null,
|
|
||||||
};
|
|
||||||
expect(getDisplayName(file)).toBe("debugging.md");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the filename when heading is an empty string stored as null", () => {
|
|
||||||
const file: MemoryFileInfo = {
|
|
||||||
path: "/home/naomi/.claude/projects/foo/memory/patterns.md",
|
|
||||||
heading: null,
|
|
||||||
};
|
|
||||||
expect(getDisplayName(file)).toBe("patterns.md");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the heading even when it matches the filename", () => {
|
|
||||||
const file: MemoryFileInfo = {
|
|
||||||
path: "/home/naomi/.claude/projects/foo/memory/MEMORY.md",
|
|
||||||
heading: "MEMORY",
|
|
||||||
};
|
|
||||||
expect(getDisplayName(file)).toBe("MEMORY");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a multi-word heading verbatim", () => {
|
|
||||||
const file: MemoryFileInfo = {
|
|
||||||
path: "/some/path/foo.md",
|
|
||||||
heading: "My Detailed Debugging Notes",
|
|
||||||
};
|
|
||||||
expect(getDisplayName(file)).toBe("My Detailed Debugging Notes");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back gracefully when path has no directory separators", () => {
|
|
||||||
const file: MemoryFileInfo = {
|
|
||||||
path: "lonely-file.md",
|
|
||||||
heading: null,
|
|
||||||
};
|
|
||||||
expect(getDisplayName(file)).toBe("lonely-file.md");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,24 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import { SvelteSet } from "svelte/reactivity";
|
import { claudeStore, hasPermissionPending } from "$lib/stores/claude";
|
||||||
import { claudeStore } from "$lib/stores/claude";
|
|
||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
import type { PermissionRequest } from "$lib/types/messages";
|
import type { PermissionRequest } from "$lib/types/messages";
|
||||||
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
|
||||||
import { conversationsStore } from "$lib/stores/conversations";
|
|
||||||
import { configStore } from "$lib/stores/config";
|
|
||||||
|
|
||||||
let permissions: PermissionRequest[] = [];
|
let isVisible = $state(false);
|
||||||
let selectedPermissions = new SvelteSet<string>();
|
let permission: PermissionRequest | null = $state(null);
|
||||||
let grantedToolsList: string[] = [];
|
let grantedToolsList: string[] = $state([]);
|
||||||
let workingDirectory = "";
|
let workingDirectory = $state("");
|
||||||
|
|
||||||
conversationsStore.pendingPermissions.subscribe((perms) => {
|
hasPermissionPending.subscribe((pending) => {
|
||||||
permissions = perms;
|
isVisible = pending;
|
||||||
// When new permissions arrive, select all by default
|
});
|
||||||
if (perms.length > 0) {
|
|
||||||
selectedPermissions = new SvelteSet(perms.map((p) => p.id));
|
claudeStore.pendingPermission.subscribe((perm) => {
|
||||||
|
permission = perm;
|
||||||
|
if (perm) {
|
||||||
characterState.setState("permission");
|
characterState.setState("permission");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -32,33 +30,17 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function handleApproveAndReconnect() {
|
async function handleApproveAndReconnect() {
|
||||||
const selectedPerms = permissions.filter((p) => selectedPermissions.has(p.id));
|
if (permission) {
|
||||||
|
|
||||||
if (selectedPerms.length === 0) {
|
|
||||||
claudeStore.addLine("system", "No permissions selected to approve");
|
|
||||||
claudeStore.clearPermission();
|
|
||||||
characterState.setTemporaryState("idle", 1000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture conversation history before clearing/reconnecting
|
// Capture conversation history before clearing/reconnecting
|
||||||
const conversationHistory = claudeStore.getConversationHistory();
|
const conversationHistory = claudeStore.getConversationHistory();
|
||||||
|
const approvedTool = permission.tool;
|
||||||
|
const toolInput = permission.input;
|
||||||
|
|
||||||
// Grant all selected tools
|
claudeStore.grantTool(approvedTool);
|
||||||
const newlyGrantedTools: string[] = [];
|
const newGrantedTools = [...grantedToolsList, approvedTool];
|
||||||
for (const perm of selectedPerms) {
|
|
||||||
if (!grantedToolsList.includes(perm.tool)) {
|
|
||||||
claudeStore.grantTool(perm.tool);
|
|
||||||
newlyGrantedTools.push(perm.tool);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newGrantedTools = [...grantedToolsList, ...newlyGrantedTools];
|
|
||||||
const toolNames = selectedPerms.map((p) => p.tool).join(", ");
|
|
||||||
|
|
||||||
claudeStore.addLine(
|
claudeStore.addLine(
|
||||||
"system",
|
"system",
|
||||||
`Permission granted for ${selectedPerms.length} tool(s): ${toolNames}. Reconnecting with context...`
|
`Permission granted for: ${approvedTool}. Reconnecting with context...`
|
||||||
);
|
);
|
||||||
claudeStore.clearPermission();
|
claudeStore.clearPermission();
|
||||||
|
|
||||||
@@ -69,69 +51,46 @@
|
|||||||
throw new Error("No active conversation");
|
throw new Error("No active conversation");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent stats reset on reconnection
|
|
||||||
setSkipNextGreeting(true);
|
|
||||||
|
|
||||||
await invoke("stop_claude", { conversationId });
|
await invoke("stop_claude", { conversationId });
|
||||||
|
|
||||||
// Small delay to ensure clean shutdown
|
// Small delay to ensure clean shutdown
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
const config = configStore.getConfig();
|
|
||||||
await invoke("start_claude", {
|
await invoke("start_claude", {
|
||||||
conversationId,
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
working_dir: workingDirectory || "/home/naomi",
|
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,
|
allowed_tools: newGrantedTools,
|
||||||
use_worktree: config.use_worktree ?? false,
|
|
||||||
disable_1m_context: config.disable_1m_context ?? false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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
|
// Wait for connection to establish
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
// Send conversation context to restore state
|
// Send conversation context to restore state
|
||||||
if (conversationHistory) {
|
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]
|
const contextMessage = `[CONTEXT RESTORATION]
|
||||||
I just granted you permission to use ${selectedPerms.length} tool(s): ${toolNames}. Here's our conversation so far:
|
I just granted you permission to use the ${approvedTool} tool. Here's our conversation so far:
|
||||||
|
|
||||||
${conversationHistory}
|
${conversationHistory}
|
||||||
|
|
||||||
The actions that were blocked:
|
The last action that was blocked was: ${approvedTool} with input:
|
||||||
${blockedActions}
|
${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", {
|
await invoke("send_prompt", {
|
||||||
conversationId,
|
conversationId,
|
||||||
message: contextMessage,
|
message: contextMessage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
characterState.setTemporaryState("success", 2000);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to reconnect:", error);
|
console.error("Failed to reconnect:", error);
|
||||||
claudeStore.addLine("error", `Reconnect failed: ${error}`);
|
claudeStore.addLine("error", `Reconnect failed: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
characterState.setTemporaryState("success", 2000);
|
||||||
|
}
|
||||||
|
|
||||||
function handleDismiss() {
|
function handleDismiss() {
|
||||||
claudeStore.clearPermission();
|
claudeStore.clearPermission();
|
||||||
@@ -151,24 +110,8 @@ Please continue where we left off and retry those actions now that you have perm
|
|||||||
return grantedToolsList.includes(toolName);
|
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) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if (permissions.length === 0) return;
|
if (!isVisible || !permission) return;
|
||||||
|
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -182,126 +125,72 @@ Please continue where we left off and retry those actions now that you have perm
|
|||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
{#if permissions.length > 0}
|
{#if isVisible && permission}
|
||||||
<div
|
<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
|
<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="flex items-center gap-3 mb-4">
|
||||||
<div class="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center">
|
<div class="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center">
|
||||||
<span class="text-xl">🔐</span>
|
<span class="text-xl">🔐</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">
|
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Permission Blocked</h2>
|
||||||
{permissions.length === 1
|
<p class="text-sm text-[var(--text-secondary)]">Hikari tried to use a restricted tool</p>
|
||||||
? "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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 px-3 py-2 bg-amber-500/10 border border-amber-500/30 rounded-md">
|
<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">
|
<p class="text-sm text-amber-300">
|
||||||
{permissions.length === 1
|
This action was automatically blocked. Approve to allow this tool for future requests.
|
||||||
? "This action was automatically blocked. Select which permissions to grant."
|
|
||||||
: "These actions were automatically blocked. Select which permissions to grant."}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3 mb-6">
|
<div class="mb-4">
|
||||||
{#each permissions as perm (perm.id)}
|
<div class="text-sm text-[var(--text-secondary)] mb-1">Tool</div>
|
||||||
<div
|
<div
|
||||||
class="border border-[var(--border-color)] rounded-lg p-4 cursor-pointer transition-colors {selectedPermissions.has(
|
class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--accent-primary)] font-mono flex items-center justify-between"
|
||||||
perm.id
|
>
|
||||||
)
|
<span>{permission.tool}</span>
|
||||||
? 'bg-green-500/10 border-green-500/30'
|
{#if isToolAlreadyGranted(permission.tool)}
|
||||||
: 'bg-[var(--bg-secondary)] hover:bg-[var(--bg-secondary)]/80'}"
|
<span class="text-xs text-green-400 bg-green-500/20 px-2 py-0.5 rounded"
|
||||||
role="button"
|
>Already Granted</span
|
||||||
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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-[var(--text-secondary)] mb-2">
|
|
||||||
{perm.description}
|
|
||||||
</div>
|
</div>
|
||||||
{#if Object.keys(perm.input).length > 0}
|
|
||||||
<details class="text-xs">
|
<div class="mb-4">
|
||||||
<summary
|
<div class="text-sm text-[var(--text-secondary)] mb-1">Description</div>
|
||||||
class="cursor-pointer text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
<div class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--text-primary)]">
|
||||||
>
|
{permission.description}
|
||||||
View details
|
</div>
|
||||||
</summary>
|
</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
|
<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(
|
class="px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] text-xs overflow-x-auto max-h-32">{formatInput(
|
||||||
perm.input
|
permission.input
|
||||||
)}</pre>
|
)}</pre>
|
||||||
</details>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onclick={handleDismiss}
|
onclick={handleDismiss}
|
||||||
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
|
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
|
||||||
>
|
>
|
||||||
Dismiss All
|
Dismiss
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={handleApproveAndReconnect}
|
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"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
Approve Selected ({selectedPermissions.size})
|
Allow & Reconnect
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,432 +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>
|
|
||||||
@@ -15,23 +15,9 @@
|
|||||||
let showDeleteConfirm = $state<string | null>(null);
|
let showDeleteConfirm = $state<string | null>(null);
|
||||||
let showExportMenu = $state<string | null>(null);
|
let showExportMenu = $state<string | null>(null);
|
||||||
let isImporting = $state(false);
|
let isImporting = $state(false);
|
||||||
let showClearAllConfirm = $state(false);
|
|
||||||
|
|
||||||
let sessions = $state<SessionListItem[]>([]);
|
const sessions = $derived(sessionsStore.sessions);
|
||||||
let isLoading = $state(false);
|
const isLoading = $derived(sessionsStore.isLoading);
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const unsubSessions = sessionsStore.sessions.subscribe((value) => {
|
|
||||||
sessions = value;
|
|
||||||
});
|
|
||||||
const unsubLoading = sessionsStore.isLoading.subscribe((value) => {
|
|
||||||
isLoading = value;
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
unsubSessions();
|
|
||||||
unsubLoading();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
sessionsStore.loadSessions();
|
sessionsStore.loadSessions();
|
||||||
@@ -135,11 +121,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleClearAll(): Promise<void> {
|
|
||||||
await sessionsStore.clearAllSessions();
|
|
||||||
showClearAllConfirm = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleExportMenu(sessionId: string): void {
|
function toggleExportMenu(sessionId: string): void {
|
||||||
if (showExportMenu === sessionId) {
|
if (showExportMenu === sessionId) {
|
||||||
showExportMenu = null;
|
showExportMenu = null;
|
||||||
@@ -205,22 +186,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{isImporting ? "Importing..." : "Import"}
|
{isImporting ? "Importing..." : "Import"}
|
||||||
</button>
|
</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}
|
{/if}
|
||||||
<button
|
<button
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
@@ -316,11 +281,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-y-auto flex-1">
|
<div class="overflow-y-auto flex-1">
|
||||||
{#if isLoading}
|
{#if $isLoading}
|
||||||
<div class="flex items-center justify-center p-8">
|
<div class="flex items-center justify-center p-8">
|
||||||
<div class="text-[var(--text-tertiary)]">Loading sessions...</div>
|
<div class="text-[var(--text-tertiary)]">Loading sessions...</div>
|
||||||
</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">
|
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||||
<svg
|
<svg
|
||||||
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
|
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
|
||||||
@@ -342,7 +307,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="divide-y divide-[var(--border-color)]">
|
<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="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<button class="flex-1 text-left" onclick={() => handleViewSession(session)}>
|
<button class="flex-1 text-left" onclick={() => handleViewSession(session)}>
|
||||||
@@ -463,63 +428,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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)}
|
|
||||||
>
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<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>
|
<style>
|
||||||
[role="dialog"] {
|
[role="dialog"] {
|
||||||
animation: slideIn 0.2s ease-out;
|
animation: slideIn 0.2s ease-out;
|
||||||
|
|||||||
@@ -1,84 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import { formattedStats } from "$lib/stores/stats";
|
||||||
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 { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import { onMount } from "svelte";
|
|
||||||
|
|
||||||
interface Props {
|
let showToolsBreakdown = false;
|
||||||
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";
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="stats-display" transition:fade={{ duration: 200 }}>
|
<div class="stats-display" transition:fade={{ duration: 200 }}>
|
||||||
@@ -92,120 +16,6 @@
|
|||||||
<span class="stat-value">{$formattedStats.messagesSession}</span>
|
<span class="stat-value">{$formattedStats.messagesSession}</span>
|
||||||
</div>
|
</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">
|
<div class="stats-section">
|
||||||
<h3>Tokens & Cost</h3>
|
<h3>Tokens & Cost</h3>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
@@ -239,7 +49,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if $formattedStats.sessionToolsFormatted.length > 0}
|
{#if Object.keys($formattedStats.sessionToolsUsage).length > 0}
|
||||||
<div class="stats-section">
|
<div class="stats-section">
|
||||||
<h3 class="tools-header">
|
<h3 class="tools-header">
|
||||||
<button class="tools-toggle" onclick={() => (showToolsBreakdown = !showToolsBreakdown)}>
|
<button class="tools-toggle" onclick={() => (showToolsBreakdown = !showToolsBreakdown)}>
|
||||||
@@ -249,57 +59,17 @@
|
|||||||
</h3>
|
</h3>
|
||||||
{#if showToolsBreakdown}
|
{#if showToolsBreakdown}
|
||||||
<div class="tools-breakdown">
|
<div class="tools-breakdown">
|
||||||
{#each $formattedStats.sessionToolsFormatted.sort((a, b) => b.totalTokens - a.totalTokens) as tool (tool.name)}
|
{#each Object.entries($formattedStats.sessionToolsUsage).sort((a, b) => b[1] - a[1]) as [tool, count] (tool)}
|
||||||
<div class="stat-row stat-detail tool-row">
|
<div class="stat-row stat-detail">
|
||||||
<span class="stat-label">{tool.name}:</span>
|
<span class="stat-label">{tool}:</span>
|
||||||
<span class="stat-value tool-stats">
|
<span class="stat-value">{count}</span>
|
||||||
<span class="tool-calls">{tool.callCount} calls</span>
|
|
||||||
{#if tool.totalTokens > 0}
|
|
||||||
<span class="tool-tokens">(~{tool.formattedTokens})</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<div class="tools-note">* Token estimates based on attribution</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<div class="model-info">
|
||||||
<span class="model-label">Model:</span>
|
<span class="model-label">Model:</span>
|
||||||
<span class="model-value">{$formattedStats.model}</span>
|
<span class="model-value">{$formattedStats.model}</span>
|
||||||
@@ -358,79 +128,6 @@
|
|||||||
color: var(--text-primary, #e5e7eb);
|
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 {
|
.model-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -451,220 +148,4 @@
|
|||||||
color: var(--text-primary, #e5e7eb);
|
color: var(--text-primary, #e5e7eb);
|
||||||
font-size: 0.75rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -21,25 +21,9 @@
|
|||||||
import HelpPanel from "./HelpPanel.svelte";
|
import HelpPanel from "./HelpPanel.svelte";
|
||||||
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
|
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
|
||||||
import { achievementProgress } from "$lib/stores/achievements";
|
import { achievementProgress } from "$lib/stores/achievements";
|
||||||
import { runningAgentCount } from "$lib/stores/agents";
|
|
||||||
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
|
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
|
||||||
import TodoPanel from "./TodoPanel.svelte";
|
|
||||||
import GitPanel from "./GitPanel.svelte";
|
import GitPanel from "./GitPanel.svelte";
|
||||||
import ProfilePanel from "./ProfilePanel.svelte";
|
import ProfilePanel from "./ProfilePanel.svelte";
|
||||||
import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
|
|
||||||
import 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";
|
|
||||||
import WorkspaceTrustModal from "./WorkspaceTrustModal.svelte";
|
|
||||||
import type { WorkspaceHookInfo } from "$lib/types/messages";
|
|
||||||
|
|
||||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||||
const DONATE_URL = "https://donate.nhcarrigan.com";
|
const DONATE_URL = "https://donate.nhcarrigan.com";
|
||||||
@@ -55,18 +39,9 @@
|
|||||||
let showHelp = $state(false);
|
let showHelp = $state(false);
|
||||||
let showKeyboardShortcuts = $state(false);
|
let showKeyboardShortcuts = $state(false);
|
||||||
let showSessionHistory = $state(false);
|
let showSessionHistory = $state(false);
|
||||||
let showTodoPanel = $state(false);
|
|
||||||
let showGitPanel = $state(false);
|
let showGitPanel = $state(false);
|
||||||
let showProfile = $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);
|
|
||||||
let showWorkspaceTrust = $state(false);
|
|
||||||
let pendingHookInfo: WorkspaceHookInfo | null = $state(null);
|
|
||||||
const progress = $derived($achievementProgress);
|
const progress = $derived($achievementProgress);
|
||||||
const activeAgentCount = $derived($runningAgentCount);
|
|
||||||
let currentConfig: HikariConfig = $state({
|
let currentConfig: HikariConfig = $state({
|
||||||
model: null,
|
model: null,
|
||||||
api_key: null,
|
api_key: null,
|
||||||
@@ -82,6 +57,7 @@
|
|||||||
update_checks_enabled: true,
|
update_checks_enabled: true,
|
||||||
character_panel_width: null,
|
character_panel_width: null,
|
||||||
font_size: 14,
|
font_size: 14,
|
||||||
|
minimize_to_tray: false,
|
||||||
streamer_mode: false,
|
streamer_mode: false,
|
||||||
streamer_hide_paths: false,
|
streamer_hide_paths: false,
|
||||||
compact_mode: false,
|
compact_mode: false,
|
||||||
@@ -98,23 +74,6 @@
|
|||||||
text_secondary: null,
|
text_secondary: null,
|
||||||
border_color: 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,
|
|
||||||
use_worktree: false,
|
|
||||||
disable_1m_context: false,
|
|
||||||
max_output_tokens: null,
|
|
||||||
trusted_workspaces: [],
|
|
||||||
background_image_path: null,
|
|
||||||
background_image_opacity: 0.3,
|
|
||||||
custom_font_path: null,
|
|
||||||
custom_font_family: null,
|
|
||||||
custom_ui_font_path: null,
|
|
||||||
custom_ui_font_family: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let streamerModeActive = $state(false);
|
let streamerModeActive = $state(false);
|
||||||
@@ -168,7 +127,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doConnect(targetDir: string) {
|
async function handleConnect() {
|
||||||
|
if (isConnecting || connectionStatus === "connected") return;
|
||||||
|
|
||||||
|
const targetDir = selectedDirectory || "/home/naomi";
|
||||||
|
|
||||||
// Combine session-granted tools with config auto-granted tools
|
// Combine session-granted tools with config auto-granted tools
|
||||||
const allAllowedTools = [
|
const allAllowedTools = [
|
||||||
...new Set([...grantedToolsList, ...currentConfig.auto_granted_tools]),
|
...new Set([...grantedToolsList, ...currentConfig.auto_granted_tools]),
|
||||||
@@ -188,73 +151,14 @@
|
|||||||
custom_instructions: currentConfig.custom_instructions || null,
|
custom_instructions: currentConfig.custom_instructions || null,
|
||||||
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
||||||
allowed_tools: allAllowedTools,
|
allowed_tools: allAllowedTools,
|
||||||
use_worktree: currentConfig.use_worktree ?? false,
|
|
||||||
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
|
||||||
max_output_tokens: currentConfig.max_output_tokens ?? null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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) {
|
} catch (error) {
|
||||||
console.error("Failed to start Claude:", error);
|
console.error("Failed to start Claude:", error);
|
||||||
claudeStore.addLine("error", `Connection failed: ${error}`);
|
claudeStore.addLine("error", `Connection failed: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConnect() {
|
|
||||||
if (isConnecting || connectionStatus === "connected") return;
|
|
||||||
|
|
||||||
const targetDir = selectedDirectory || "/home/naomi";
|
|
||||||
|
|
||||||
if (currentConfig.trusted_workspaces?.includes(targetDir)) {
|
|
||||||
await doConnect(targetDir);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const hookInfo = await invoke<WorkspaceHookInfo>("check_workspace_hooks", {
|
|
||||||
workingDir: targetDir,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hookInfo.has_concerns) {
|
|
||||||
pendingHookInfo = hookInfo;
|
|
||||||
showWorkspaceTrust = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Fail open: if we can't check hooks, proceed with connection
|
|
||||||
console.error("Failed to check workspace hooks:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
await doConnect(targetDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleTrustAndConnect() {
|
|
||||||
showWorkspaceTrust = false;
|
|
||||||
const targetDir = selectedDirectory || "/home/naomi";
|
|
||||||
pendingHookInfo = null;
|
|
||||||
const alreadyTrusted = currentConfig.trusted_workspaces?.includes(targetDir) ?? false;
|
|
||||||
if (!alreadyTrusted) {
|
|
||||||
await configStore.updateConfig({
|
|
||||||
trusted_workspaces: [...(currentConfig.trusted_workspaces ?? []), targetDir],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
doConnect(targetDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCancelConnect() {
|
|
||||||
showWorkspaceTrust = false;
|
|
||||||
pendingHookInfo = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDisconnect() {
|
async function handleDisconnect() {
|
||||||
try {
|
try {
|
||||||
const conversationId = get(claudeStore.activeConversationId);
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
@@ -262,9 +166,6 @@
|
|||||||
throw new Error("No active conversation");
|
throw new Error("No active conversation");
|
||||||
}
|
}
|
||||||
await invoke("stop_claude", { conversationId });
|
await invoke("stop_claude", { conversationId });
|
||||||
|
|
||||||
// Clear granted permissions when user explicitly disconnects
|
|
||||||
claudeStore.revokeAllTools();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to stop Claude:", error);
|
console.error("Failed to stop Claude:", error);
|
||||||
}
|
}
|
||||||
@@ -299,112 +200,6 @@
|
|||||||
function toggleAchievements() {
|
function toggleAchievements() {
|
||||||
onToggleAchievements();
|
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,
|
|
||||||
use_worktree: currentConfig.use_worktree ?? false,
|
|
||||||
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
|
||||||
max_output_tokens: currentConfig.max_output_tokens ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -445,16 +240,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-wrap min-w-0">
|
<div class="flex items-center gap-3">
|
||||||
{#if streamerModeActive}
|
{#if streamerModeActive}
|
||||||
<div
|
<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)"
|
title="Streamer mode active (Ctrl+Shift+S to toggle)"
|
||||||
></div>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
onclick={() => (showProfile = true)}
|
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"
|
title="Profile"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -508,20 +303,6 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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
|
<button
|
||||||
onclick={() => (showGitPanel = true)}
|
onclick={() => (showGitPanel = true)}
|
||||||
class="p-1 text-gray-500 icon-trans-hover"
|
class="p-1 text-gray-500 icon-trans-hover"
|
||||||
@@ -536,34 +317,6 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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
|
<button
|
||||||
onclick={toggleEditor}
|
onclick={toggleEditor}
|
||||||
disabled={connectionStatus !== "connected"}
|
disabled={connectionStatus !== "connected"}
|
||||||
@@ -583,43 +336,6 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onclick={() => (showCastPanel = true)}
|
|
||||||
class="p-1 text-gray-500 icon-trans-hover"
|
|
||||||
title="Meet the Team"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
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
|
<button
|
||||||
onclick={() => (showStats = !showStats)}
|
onclick={() => (showStats = !showStats)}
|
||||||
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
|
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
|
||||||
@@ -634,20 +350,6 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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
|
<button
|
||||||
onclick={configStore.openSidebar}
|
onclick={configStore.openSidebar}
|
||||||
class="p-1 text-gray-500 icon-trans-hover"
|
class="p-1 text-gray-500 icon-trans-hover"
|
||||||
@@ -744,11 +446,7 @@
|
|||||||
|
|
||||||
{#if showStats}
|
{#if showStats}
|
||||||
<div class="absolute top-full right-0 mt-2 mr-4 z-50">
|
<div class="absolute top-full right-0 mt-2 mr-4 z-50">
|
||||||
<StatsDisplay
|
<StatsDisplay />
|
||||||
onRequestSummary={handleCompactConversation}
|
|
||||||
onStartFreshWithContext={handleStartFreshWithContext}
|
|
||||||
{isSummarising}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if connectionStatus === "connected"}
|
{#if connectionStatus === "connected"}
|
||||||
@@ -774,12 +472,8 @@
|
|||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div>
|
<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">
|
<div class="fixed top-14 right-4 z-50">
|
||||||
<StatsDisplay
|
<StatsDisplay />
|
||||||
onRequestSummary={handleCompactConversation}
|
|
||||||
onStartFreshWithContext={handleStartFreshWithContext}
|
|
||||||
{isSummarising}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -799,10 +493,6 @@
|
|||||||
<SessionHistoryPanel onClose={() => (showSessionHistory = false)} />
|
<SessionHistoryPanel onClose={() => (showSessionHistory = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showTodoPanel}
|
|
||||||
<TodoPanel onClose={() => (showTodoPanel = false)} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showGitPanel}
|
{#if showGitPanel}
|
||||||
<GitPanel isOpen={showGitPanel} onClose={() => (showGitPanel = false)} />
|
<GitPanel isOpen={showGitPanel} onClose={() => (showGitPanel = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -810,56 +500,3 @@
|
|||||||
{#if showProfile}
|
{#if showProfile}
|
||||||
<ProfilePanel onClose={() => (showProfile = false)} />
|
<ProfilePanel onClose={() => (showProfile = false)} />
|
||||||
{/if}
|
{/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}
|
|
||||||
|
|
||||||
{#if showWorkspaceTrust && pendingHookInfo}
|
|
||||||
<WorkspaceTrustModal
|
|
||||||
hookInfo={pendingHookInfo}
|
|
||||||
onTrust={handleTrustAndConnect}
|
|
||||||
onCancel={handleCancelConnect}
|
|
||||||
/>
|
|
||||||
{/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,89 +0,0 @@
|
|||||||
/**
|
|
||||||
* StatusBar Component Tests
|
|
||||||
*
|
|
||||||
* Tests the connection status colour and text helpers used by the
|
|
||||||
* StatusBar component to display the current Claude connection state.
|
|
||||||
*
|
|
||||||
* What this component does:
|
|
||||||
* - Shows a coloured indicator dot for the connection state
|
|
||||||
* - Shows a text label for the connection state
|
|
||||||
* - Provides connect/disconnect buttons
|
|
||||||
* - Contains the working directory input and browse button
|
|
||||||
* - Houses all toolbar action buttons (settings, stats, panels, etc.)
|
|
||||||
*
|
|
||||||
* Manual testing checklist:
|
|
||||||
* - [ ] Green dot and "Connected" label when Claude is running
|
|
||||||
* - [ ] Animated yellow dot and "Connecting..." label whilst connecting
|
|
||||||
* - [ ] Red dot and "Error" label on connection error
|
|
||||||
* - [ ] Grey dot and "Disconnected" label when not connected
|
|
||||||
* - [ ] Directory input is hidden when connected, visible when disconnected
|
|
||||||
* - [ ] Connect button transitions to Disconnect button on connection
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
|
|
||||||
type ConnectionStatus = "connected" | "connecting" | "disconnected" | "error";
|
|
||||||
|
|
||||||
function getStatusColor(connectionStatus: ConnectionStatus): string {
|
|
||||||
switch (connectionStatus) {
|
|
||||||
case "connected":
|
|
||||||
return "bg-green-500";
|
|
||||||
case "connecting":
|
|
||||||
return "bg-yellow-500 animate-pulse";
|
|
||||||
case "error":
|
|
||||||
return "bg-red-500";
|
|
||||||
default:
|
|
||||||
return "bg-gray-500";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusText(connectionStatus: ConnectionStatus): string {
|
|
||||||
switch (connectionStatus) {
|
|
||||||
case "connected":
|
|
||||||
return "Connected";
|
|
||||||
case "connecting":
|
|
||||||
return "Connecting...";
|
|
||||||
case "error":
|
|
||||||
return "Error";
|
|
||||||
default:
|
|
||||||
return "Disconnected";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---
|
|
||||||
|
|
||||||
describe("getStatusColor", () => {
|
|
||||||
it("returns green for connected status", () => {
|
|
||||||
expect(getStatusColor("connected")).toBe("bg-green-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns animated yellow for connecting status", () => {
|
|
||||||
expect(getStatusColor("connecting")).toBe("bg-yellow-500 animate-pulse");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns red for error status", () => {
|
|
||||||
expect(getStatusColor("error")).toBe("bg-red-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns grey for disconnected status", () => {
|
|
||||||
expect(getStatusColor("disconnected")).toBe("bg-gray-500");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getStatusText", () => {
|
|
||||||
it("returns 'Connected' for connected status", () => {
|
|
||||||
expect(getStatusText("connected")).toBe("Connected");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 'Connecting...' for connecting status", () => {
|
|
||||||
expect(getStatusText("connecting")).toBe("Connecting...");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 'Error' for error status", () => {
|
|
||||||
expect(getStatusText("error")).toBe("Error");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 'Disconnected' for disconnected status", () => {
|
|
||||||
expect(getStatusText("disconnected")).toBe("Disconnected");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
||||||
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
|
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
|
|
||||||
import ConversationTabs from "./ConversationTabs.svelte";
|
import ConversationTabs from "./ConversationTabs.svelte";
|
||||||
import Markdown from "./Markdown.svelte";
|
import Markdown from "./Markdown.svelte";
|
||||||
import HighlightedText from "./HighlightedText.svelte";
|
import HighlightedText from "./HighlightedText.svelte";
|
||||||
import ThinkingBlock from "./ThinkingBlock.svelte";
|
|
||||||
import ToolCallBlock from "./ToolCallBlock.svelte";
|
|
||||||
import { searchState, searchQuery } from "$lib/stores/search";
|
import { searchState, searchQuery } from "$lib/stores/search";
|
||||||
import { clipboardStore } from "$lib/stores/clipboard";
|
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 terminalElement: HTMLDivElement;
|
||||||
let shouldAutoScroll = true;
|
let shouldAutoScroll = true;
|
||||||
@@ -28,11 +24,6 @@
|
|||||||
hidePaths = value;
|
hidePaths = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
let showThinking = true;
|
|
||||||
showThinkingBlocks.subscribe((value) => {
|
|
||||||
showThinking = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
claudeStore.terminalLines.subscribe((value) => {
|
claudeStore.terminalLines.subscribe((value) => {
|
||||||
lines = value;
|
lines = value;
|
||||||
});
|
});
|
||||||
@@ -93,16 +84,6 @@
|
|||||||
return "terminal-tool";
|
return "terminal-tool";
|
||||||
case "error":
|
case "error":
|
||||||
return "terminal-error";
|
return "terminal-error";
|
||||||
case "thinking":
|
|
||||||
return "terminal-thinking";
|
|
||||||
case "rate-limit":
|
|
||||||
return "terminal-rate-limit";
|
|
||||||
case "compact-prompt":
|
|
||||||
return "terminal-compact-prompt";
|
|
||||||
case "worktree":
|
|
||||||
return "terminal-worktree";
|
|
||||||
case "config-change":
|
|
||||||
return "terminal-config-change";
|
|
||||||
default:
|
default:
|
||||||
return "terminal-default";
|
return "terminal-default";
|
||||||
}
|
}
|
||||||
@@ -120,12 +101,6 @@
|
|||||||
return "[tool]";
|
return "[tool]";
|
||||||
case "error":
|
case "error":
|
||||||
return "[error]";
|
return "[error]";
|
||||||
case "rate-limit":
|
|
||||||
return "[rate-limit]";
|
|
||||||
case "worktree":
|
|
||||||
return "[worktree]";
|
|
||||||
case "config-change":
|
|
||||||
return "[config]";
|
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -204,11 +179,6 @@
|
|||||||
copiedMessageId = null;
|
copiedMessageId = null;
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCompact() {
|
|
||||||
if (!currentConversationId) return;
|
|
||||||
await invoke("send_prompt", { conversationId: currentConversationId, message: "/compact" });
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -231,7 +201,7 @@
|
|||||||
bind:this={terminalElement}
|
bind:this={terminalElement}
|
||||||
onscroll={handleScroll}
|
onscroll={handleScroll}
|
||||||
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono"
|
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono"
|
||||||
style="font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
|
style="font-size: var(--terminal-font-size, 14px);"
|
||||||
>
|
>
|
||||||
{#if lines.length === 0}
|
{#if lines.length === 0}
|
||||||
<div class="terminal-waiting italic">
|
<div class="terminal-waiting italic">
|
||||||
@@ -239,65 +209,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each lines as line (line.id)}
|
{#each lines as line (line.id)}
|
||||||
{#if line.type === "thinking"}
|
<div class="terminal-line mb-2 {getLineClass(line.type)} relative group">
|
||||||
{#if showThinking}
|
|
||||||
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
|
|
||||||
{/if}
|
|
||||||
{:else if line.type === "tool"}
|
|
||||||
<div
|
|
||||||
style={line.parentToolUseId
|
|
||||||
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
|
|
||||||
: ""}
|
|
||||||
>
|
|
||||||
<ToolCallBlock
|
|
||||||
toolName={line.toolName ?? null}
|
|
||||||
content={maskPaths(line.content, hidePaths)}
|
|
||||||
timestamp={line.timestamp}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{: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>
|
<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)}
|
{#if getLinePrefix(line.type)}
|
||||||
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if line.type === "compact-prompt"}
|
{#if line.toolName}
|
||||||
<button class="compact-action-btn" onclick={handleCompact}>
|
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
||||||
⚡ Compact Conversation
|
{/if}
|
||||||
</button>
|
{#if line.type === "assistant" || line.type === "user"}
|
||||||
{:else if line.type === "assistant" || line.type === "user"}
|
|
||||||
<div class="message-content-wrapper">
|
<div class="message-content-wrapper">
|
||||||
<Markdown
|
<Markdown
|
||||||
content={maskPaths(line.content, hidePaths)}
|
content={maskPaths(line.content, hidePaths)}
|
||||||
@@ -331,7 +251,6 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -364,42 +283,6 @@
|
|||||||
color: var(--terminal-error, #f87171);
|
color: var(--terminal-error, #f87171);
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-rate-limit {
|
|
||||||
color: var(--terminal-rate-limit, #fb923c);
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-compact-prompt {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-worktree {
|
|
||||||
color: var(--terminal-worktree, #34d399);
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-config-change {
|
|
||||||
color: var(--terminal-config-change, #a78bfa);
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-action-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4em;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--terminal-error, #f87171);
|
|
||||||
color: var(--terminal-error, #f87171);
|
|
||||||
padding: 0.3em 0.8em;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-action-btn:hover {
|
|
||||||
background: color-mix(in srgb, var(--terminal-error, #f87171) 15%, transparent);
|
|
||||||
color: var(--terminal-error, #f87171);
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-default {
|
.terminal-default {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
@@ -408,14 +291,6 @@
|
|||||||
color: var(--text-tertiary, #6b7280);
|
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 {
|
.terminal-prefix {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,264 +0,0 @@
|
|||||||
/**
|
|
||||||
* Terminal Component Tests
|
|
||||||
*
|
|
||||||
* Tests the pure helper functions extracted from the Terminal component:
|
|
||||||
* - getLineClass: maps line types to CSS class names
|
|
||||||
* - getLinePrefix: maps line types to display prefixes
|
|
||||||
* - formatTime: formats a Date as "HH:MM AM/PM"
|
|
||||||
* - isToolContentLong: checks if tool content exceeds collapse threshold
|
|
||||||
* - truncateToolContent: truncates long tool content with ellipsis
|
|
||||||
*
|
|
||||||
* Manual testing checklist:
|
|
||||||
* - [ ] rate-limit lines appear in amber
|
|
||||||
* - [ ] error lines appear in red
|
|
||||||
* - [ ] tool lines appear in purple
|
|
||||||
* - [ ] system lines appear in grey italic
|
|
||||||
* - [ ] user lines appear in cyan
|
|
||||||
* - [ ] assistant lines appear in primary text colour
|
|
||||||
* - [ ] long tool content is collapsed by default with a toggle button
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
|
|
||||||
// Mirror functions from Terminal.svelte for isolated testing
|
|
||||||
|
|
||||||
function getLineClass(type: string): string {
|
|
||||||
switch (type) {
|
|
||||||
case "user":
|
|
||||||
return "terminal-user";
|
|
||||||
case "assistant":
|
|
||||||
return "terminal-assistant";
|
|
||||||
case "system":
|
|
||||||
return "terminal-system italic";
|
|
||||||
case "tool":
|
|
||||||
return "terminal-tool";
|
|
||||||
case "error":
|
|
||||||
return "terminal-error";
|
|
||||||
case "thinking":
|
|
||||||
return "terminal-thinking";
|
|
||||||
case "rate-limit":
|
|
||||||
return "terminal-rate-limit";
|
|
||||||
case "compact-prompt":
|
|
||||||
return "terminal-compact-prompt";
|
|
||||||
case "worktree":
|
|
||||||
return "terminal-worktree";
|
|
||||||
case "config-change":
|
|
||||||
return "terminal-config-change";
|
|
||||||
default:
|
|
||||||
return "terminal-default";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLinePrefix(type: string): string {
|
|
||||||
switch (type) {
|
|
||||||
case "user":
|
|
||||||
return ">";
|
|
||||||
case "assistant":
|
|
||||||
return "";
|
|
||||||
case "system":
|
|
||||||
return "[system]";
|
|
||||||
case "tool":
|
|
||||||
return "[tool]";
|
|
||||||
case "error":
|
|
||||||
return "[error]";
|
|
||||||
case "rate-limit":
|
|
||||||
return "[rate-limit]";
|
|
||||||
case "worktree":
|
|
||||||
return "[worktree]";
|
|
||||||
case "config-change":
|
|
||||||
return "[config]";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(date: Date): string {
|
|
||||||
return date.toLocaleTimeString("en-US", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const TOOL_COLLAPSE_THRESHOLD = 60;
|
|
||||||
|
|
||||||
function isToolContentLong(content: string): boolean {
|
|
||||||
return content.length > TOOL_COLLAPSE_THRESHOLD;
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateToolContent(content: string): string {
|
|
||||||
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---
|
|
||||||
|
|
||||||
describe("getLineClass", () => {
|
|
||||||
it("returns terminal-user for user lines", () => {
|
|
||||||
expect(getLineClass("user")).toBe("terminal-user");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns terminal-assistant for assistant lines", () => {
|
|
||||||
expect(getLineClass("assistant")).toBe("terminal-assistant");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns terminal-system italic for system lines", () => {
|
|
||||||
expect(getLineClass("system")).toBe("terminal-system italic");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns terminal-tool for tool lines", () => {
|
|
||||||
expect(getLineClass("tool")).toBe("terminal-tool");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns terminal-error for error lines", () => {
|
|
||||||
expect(getLineClass("error")).toBe("terminal-error");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns terminal-thinking for thinking lines", () => {
|
|
||||||
expect(getLineClass("thinking")).toBe("terminal-thinking");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns terminal-rate-limit for rate-limit lines", () => {
|
|
||||||
expect(getLineClass("rate-limit")).toBe("terminal-rate-limit");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns terminal-compact-prompt for compact-prompt lines", () => {
|
|
||||||
expect(getLineClass("compact-prompt")).toBe("terminal-compact-prompt");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns terminal-worktree for worktree lines", () => {
|
|
||||||
expect(getLineClass("worktree")).toBe("terminal-worktree");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns terminal-config-change for config-change lines", () => {
|
|
||||||
expect(getLineClass("config-change")).toBe("terminal-config-change");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns terminal-default for unknown line types", () => {
|
|
||||||
expect(getLineClass("unknown")).toBe("terminal-default");
|
|
||||||
expect(getLineClass("")).toBe("terminal-default");
|
|
||||||
expect(getLineClass("random-future-type")).toBe("terminal-default");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getLinePrefix", () => {
|
|
||||||
it("returns > for user lines", () => {
|
|
||||||
expect(getLinePrefix("user")).toBe(">");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty string for assistant lines", () => {
|
|
||||||
expect(getLinePrefix("assistant")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns [system] for system lines", () => {
|
|
||||||
expect(getLinePrefix("system")).toBe("[system]");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns [tool] for tool lines", () => {
|
|
||||||
expect(getLinePrefix("tool")).toBe("[tool]");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns [error] for error lines", () => {
|
|
||||||
expect(getLinePrefix("error")).toBe("[error]");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns [rate-limit] for rate-limit lines", () => {
|
|
||||||
expect(getLinePrefix("rate-limit")).toBe("[rate-limit]");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty string for compact-prompt lines (button renders instead)", () => {
|
|
||||||
expect(getLinePrefix("compact-prompt")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns [worktree] for worktree lines", () => {
|
|
||||||
expect(getLinePrefix("worktree")).toBe("[worktree]");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns [config] for config-change lines", () => {
|
|
||||||
expect(getLinePrefix("config-change")).toBe("[config]");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty string for thinking lines (no prefix)", () => {
|
|
||||||
expect(getLinePrefix("thinking")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty string for unknown line types", () => {
|
|
||||||
expect(getLinePrefix("unknown")).toBe("");
|
|
||||||
expect(getLinePrefix("")).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("formatTime", () => {
|
|
||||||
it("formats time in 12-hour format with AM/PM", () => {
|
|
||||||
const date = new Date(2026, 1, 7, 14, 35);
|
|
||||||
const formatted = formatTime(date);
|
|
||||||
expect(formatted).toMatch(/\d{2}:\d{2}\s?(AM|PM)/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats afternoon times correctly", () => {
|
|
||||||
const date = new Date(2026, 1, 7, 14, 35);
|
|
||||||
const formatted = formatTime(date);
|
|
||||||
expect(formatted).toContain("02:35");
|
|
||||||
expect(formatted.toUpperCase()).toContain("PM");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats morning times correctly", () => {
|
|
||||||
const date = new Date(2026, 1, 7, 9, 5);
|
|
||||||
const formatted = formatTime(date);
|
|
||||||
expect(formatted).toContain("09:05");
|
|
||||||
expect(formatted.toUpperCase()).toContain("AM");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats midnight correctly", () => {
|
|
||||||
const date = new Date(2026, 1, 7, 0, 0);
|
|
||||||
const formatted = formatTime(date);
|
|
||||||
expect(formatted).toContain("12:00");
|
|
||||||
expect(formatted.toUpperCase()).toContain("AM");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats noon correctly", () => {
|
|
||||||
const date = new Date(2026, 1, 7, 12, 0);
|
|
||||||
const formatted = formatTime(date);
|
|
||||||
expect(formatted).toContain("12:00");
|
|
||||||
expect(formatted.toUpperCase()).toContain("PM");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isToolContentLong", () => {
|
|
||||||
it("returns false for content at or below the threshold", () => {
|
|
||||||
const exactThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD);
|
|
||||||
expect(isToolContentLong(exactThreshold)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true for content exceeding the threshold", () => {
|
|
||||||
const overThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD + 1);
|
|
||||||
expect(isToolContentLong(overThreshold)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for short content", () => {
|
|
||||||
expect(isToolContentLong("short")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for empty content", () => {
|
|
||||||
expect(isToolContentLong("")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("truncateToolContent", () => {
|
|
||||||
it("truncates content to the threshold length with an ellipsis", () => {
|
|
||||||
const long = "x".repeat(100);
|
|
||||||
const result = truncateToolContent(long);
|
|
||||||
expect(result).toBe("x".repeat(TOOL_COLLAPSE_THRESHOLD) + "…");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps content shorter than threshold unchanged (plus ellipsis)", () => {
|
|
||||||
const short = "hello";
|
|
||||||
const result = truncateToolContent(short);
|
|
||||||
expect(result).toBe("hello…");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the unicode ellipsis character (not three dots)", () => {
|
|
||||||
const long = "x".repeat(100);
|
|
||||||
const result = truncateToolContent(long);
|
|
||||||
expect(result.endsWith("…")).toBe(true);
|
|
||||||
expect(result.endsWith("...")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
bind:this={menuElement}
|
bind:this={menuElement}
|
||||||
class="menu-content"
|
class="menu-content"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
interface Props {
|
|
||||||
toolName: string | null;
|
|
||||||
content: string;
|
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { toolName, 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="tool-block">
|
|
||||||
<button class="tool-header" onclick={toggleExpanded} type="button">
|
|
||||||
<span class="tool-timestamp">{formatTime(timestamp)}</span>
|
|
||||||
<svg
|
|
||||||
class="tool-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="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{#if toolName}
|
|
||||||
<span class="tool-name">[{toolName}]</span>
|
|
||||||
{/if}
|
|
||||||
<span class="tool-label">{content}</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="tool-content">
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.tool-block {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--terminal-tool, #c084fc);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-header:hover {
|
|
||||||
background: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-timestamp {
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-name {
|
|
||||||
font-family: monospace;
|
|
||||||
font-weight: 600;
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--terminal-tool-name, #ddd6fe);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-label {
|
|
||||||
flex: 1;
|
|
||||||
text-align: left;
|
|
||||||
font-style: italic;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevron {
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevron.expanded {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-content {
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
color: var(--terminal-tool, #c084fc);
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-style: italic;
|
|
||||||
line-height: 1.5;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -5,9 +5,6 @@
|
|||||||
import { claudeStore, hasQuestionPending } from "$lib/stores/claude";
|
import { claudeStore, hasQuestionPending } from "$lib/stores/claude";
|
||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
import type { UserQuestionEvent } from "$lib/types/messages";
|
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 isVisible = $state(false);
|
||||||
let question: UserQuestionEvent | null = $state(null);
|
let question: UserQuestionEvent | null = $state(null);
|
||||||
@@ -89,38 +86,18 @@
|
|||||||
claudeStore.clearQuestion();
|
claudeStore.clearQuestion();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Prevent stats reset on reconnection
|
|
||||||
setSkipNextGreeting(true);
|
|
||||||
|
|
||||||
await invoke("stop_claude", { conversationId });
|
await invoke("stop_claude", { conversationId });
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
const config = configStore.getConfig();
|
|
||||||
await invoke("start_claude", {
|
await invoke("start_claude", {
|
||||||
conversationId,
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
working_dir: workingDirectory || "/home/naomi",
|
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,
|
allowed_tools: grantedToolsList,
|
||||||
use_worktree: config.use_worktree ?? false,
|
|
||||||
disable_1m_context: config.disable_1m_context ?? false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
if (conversationHistory) {
|
if (conversationHistory) {
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { characterState } from "$lib/stores/character";
|
|
||||||
import type { WorkspaceHookInfo } from "$lib/types/messages";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
hookInfo: WorkspaceHookInfo;
|
|
||||||
onTrust: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { hookInfo, onTrust, onCancel }: Props = $props();
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
characterState.setState("permission");
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onclick={onCancel}>
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
|
||||||
class="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 max-w-md w-full mx-4 shadow-xl"
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3 mb-4">
|
|
||||||
<svg
|
|
||||||
class="w-6 h-6 text-yellow-400 flex-shrink-0"
|
|
||||||
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>
|
|
||||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Workspace Trust Required</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-sm text-[var(--text-secondary)] mb-4">
|
|
||||||
This workspace contains configuration that can execute code on your system. Review what was
|
|
||||||
found before connecting.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="space-y-3 mb-4">
|
|
||||||
{#if hookInfo.hook_types.length > 0}
|
|
||||||
<div class="bg-[var(--bg-primary)] rounded-md p-3">
|
|
||||||
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
|
|
||||||
Hooks (run shell commands automatically):
|
|
||||||
</p>
|
|
||||||
<ul class="space-y-1">
|
|
||||||
{#each hookInfo.hook_types as hookType (hookType)}
|
|
||||||
<li class="text-sm text-yellow-400 font-mono">• {hookType}</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if hookInfo.mcp_servers.length > 0}
|
|
||||||
<div class="bg-[var(--bg-primary)] rounded-md p-3">
|
|
||||||
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
|
|
||||||
MCP servers (run as local processes with system access):
|
|
||||||
</p>
|
|
||||||
<ul class="space-y-1">
|
|
||||||
{#each hookInfo.mcp_servers as server (server)}
|
|
||||||
<li class="text-sm text-yellow-400 font-mono">• {server}</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if hookInfo.custom_commands.length > 0}
|
|
||||||
<div class="bg-[var(--bg-primary)] rounded-md p-3">
|
|
||||||
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
|
|
||||||
Custom slash commands (can execute arbitrary instructions):
|
|
||||||
</p>
|
|
||||||
<ul class="space-y-1">
|
|
||||||
{#each hookInfo.custom_commands as cmd (cmd)}
|
|
||||||
<li class="text-sm text-yellow-400 font-mono">• /{cmd}</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-xs text-[var(--text-secondary)] mb-6">
|
|
||||||
Only connect to workspaces you trust. Trusting this workspace will remember your choice for
|
|
||||||
future sessions.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex gap-3 justify-end">
|
|
||||||
<button
|
|
||||||
onclick={onCancel}
|
|
||||||
class="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border-color)] rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={onTrust}
|
|
||||||
class="px-4 py-2 text-sm bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 border border-yellow-500/30 rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
Trust and Connect
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -30,9 +30,9 @@
|
|||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="dialog-overlay" onclick={onCancel}>
|
<div class="dialog-overlay" onclick={onCancel}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="dialog-content" onclick={(e) => e.stopPropagation()}>
|
<div class="dialog-content" onclick={(e) => e.stopPropagation()}>
|
||||||
<h2 class="dialog-title">{title}</h2>
|
<h2 class="dialog-title">{title}</h2>
|
||||||
<p class="dialog-message">{message}</p>
|
<p class="dialog-message">{message}</p>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
bind:this={menuElement}
|
bind:this={menuElement}
|
||||||
class="menu-content"
|
class="menu-content"
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="menu-overlay"
|
class="menu-overlay"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
bind:this={menuElement}
|
bind:this={menuElement}
|
||||||
class="menu-content"
|
class="menu-content"
|
||||||
|
|||||||
@@ -50,9 +50,9 @@
|
|||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="dialog-overlay" onclick={onCancel}>
|
<div class="dialog-overlay" onclick={onCancel}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="dialog-content" onclick={(e) => e.stopPropagation()}>
|
<div class="dialog-content" onclick={(e) => e.stopPropagation()}>
|
||||||
<h2 class="dialog-title">{title}</h2>
|
<h2 class="dialog-title">{title}</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -1,242 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { isPermissionGranted } from "@tauri-apps/plugin-notification";
|
|
||||||
import { setMockInvokeResult } from "../../../vitest.setup";
|
|
||||||
import { notificationManager } from "./notificationManager";
|
|
||||||
import { sendTerminalNotification } from "./terminalNotifier";
|
|
||||||
import { NotificationType, NOTIFICATION_SOUNDS } from "./types";
|
|
||||||
|
|
||||||
vi.mock("./soundPlayer", () => ({
|
|
||||||
soundPlayer: { play: vi.fn().mockResolvedValue(undefined) },
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./terminalNotifier", () => ({
|
|
||||||
sendTerminalNotification: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("NotificationManager", () => {
|
|
||||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
||||||
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
||||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
consoleWarnSpy.mockRestore();
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("notify()", () => {
|
|
||||||
it("calls the first notification method by default", async () => {
|
|
||||||
await notificationManager.notify(NotificationType.SUCCESS);
|
|
||||||
expect(invoke).toHaveBeenCalledWith(
|
|
||||||
"send_windows_notification",
|
|
||||||
expect.objectContaining({
|
|
||||||
title: NOTIFICATION_SOUNDS[NotificationType.SUCCESS].phrase,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes a custom message to the notification", async () => {
|
|
||||||
await notificationManager.notify(NotificationType.ERROR, "Custom error message");
|
|
||||||
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
|
||||||
title: NOTIFICATION_SOUNDS[NotificationType.ERROR].phrase,
|
|
||||||
body: "Custom error message",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the next method when the first fails", async () => {
|
|
||||||
setMockInvokeResult("send_windows_notification", new Error("Method 1 failed"));
|
|
||||||
await notificationManager.notify(NotificationType.SUCCESS);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("send_windows_toast", expect.any(Object));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to terminal notification when all methods fail", async () => {
|
|
||||||
setMockInvokeResult("send_windows_notification", new Error("failed"));
|
|
||||||
setMockInvokeResult("send_windows_toast", new Error("failed"));
|
|
||||||
setMockInvokeResult("send_wsl_notification", new Error("failed"));
|
|
||||||
setMockInvokeResult("send_notify_send", new Error("failed"));
|
|
||||||
vi.mocked(isPermissionGranted).mockRejectedValueOnce(new Error("Permission check failed"));
|
|
||||||
|
|
||||||
await notificationManager.notify(NotificationType.SUCCESS);
|
|
||||||
|
|
||||||
expect(sendTerminalNotification).toHaveBeenCalledWith(
|
|
||||||
NotificationType.SUCCESS,
|
|
||||||
"Task completed successfully!"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the default SUCCESS message when none is provided", async () => {
|
|
||||||
await notificationManager.notify(NotificationType.SUCCESS);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
|
||||||
title: expect.any(String),
|
|
||||||
body: "Task completed successfully!",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the default ERROR message", async () => {
|
|
||||||
await notificationManager.notify(NotificationType.ERROR);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
|
||||||
title: expect.any(String),
|
|
||||||
body: "Something went wrong...",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the default PERMISSION message", async () => {
|
|
||||||
await notificationManager.notify(NotificationType.PERMISSION);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
|
||||||
title: expect.any(String),
|
|
||||||
body: "Permission needed to continue",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the default CONNECTION message", async () => {
|
|
||||||
await notificationManager.notify(NotificationType.CONNECTION);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
|
||||||
title: expect.any(String),
|
|
||||||
body: "Successfully connected to Claude Code",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the default TASK_START message", async () => {
|
|
||||||
await notificationManager.notify(NotificationType.TASK_START);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
|
||||||
title: expect.any(String),
|
|
||||||
body: "Starting task...",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the default COST_ALERT message", async () => {
|
|
||||||
await notificationManager.notify(NotificationType.COST_ALERT);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
|
||||||
title: expect.any(String),
|
|
||||||
body: "You've exceeded your cost threshold!",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the fallback default message for unhandled types", async () => {
|
|
||||||
await notificationManager.notify(NotificationType.ACHIEVEMENT);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
|
||||||
title: expect.any(String),
|
|
||||||
body: "Notification",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("helper methods", () => {
|
|
||||||
it("notifySuccess calls notify with SUCCESS type and default message", async () => {
|
|
||||||
await notificationManager.notifySuccess();
|
|
||||||
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
|
||||||
title: NOTIFICATION_SOUNDS[NotificationType.SUCCESS].phrase,
|
|
||||||
body: "Task completed successfully!",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("notifySuccess passes a custom message", async () => {
|
|
||||||
await notificationManager.notifySuccess("All done!");
|
|
||||||
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
|
||||||
title: expect.any(String),
|
|
||||||
body: "All done!",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("notifyError calls notify with ERROR type", async () => {
|
|
||||||
await notificationManager.notifyError();
|
|
||||||
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
|
||||||
title: NOTIFICATION_SOUNDS[NotificationType.ERROR].phrase,
|
|
||||||
body: "Something went wrong...",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("notifyPermission calls notify with PERMISSION type", async () => {
|
|
||||||
await notificationManager.notifyPermission();
|
|
||||||
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
|
||||||
title: NOTIFICATION_SOUNDS[NotificationType.PERMISSION].phrase,
|
|
||||||
body: "Permission needed to continue",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("notifyConnection calls notify with CONNECTION type", async () => {
|
|
||||||
await notificationManager.notifyConnection();
|
|
||||||
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
|
||||||
title: NOTIFICATION_SOUNDS[NotificationType.CONNECTION].phrase,
|
|
||||||
body: "Successfully connected to Claude Code",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("notifyTaskStart calls notify with TASK_START type", async () => {
|
|
||||||
await notificationManager.notifyTaskStart();
|
|
||||||
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
|
||||||
title: NOTIFICATION_SOUNDS[NotificationType.TASK_START].phrase,
|
|
||||||
body: "Starting task...",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("notifyCostAlert calls notify with COST_ALERT type", async () => {
|
|
||||||
await notificationManager.notifyCostAlert();
|
|
||||||
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
|
||||||
title: NOTIFICATION_SOUNDS[NotificationType.COST_ALERT].phrase,
|
|
||||||
body: "You've exceeded your cost threshold!",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Tauri plugin notification method (Method 4)", () => {
|
|
||||||
it("sends notification when permission is already granted", async () => {
|
|
||||||
const { isPermissionGranted, sendNotification } =
|
|
||||||
await import("@tauri-apps/plugin-notification");
|
|
||||||
setMockInvokeResult("send_windows_notification", new Error("failed"));
|
|
||||||
setMockInvokeResult("send_windows_toast", new Error("failed"));
|
|
||||||
setMockInvokeResult("send_wsl_notification", new Error("failed"));
|
|
||||||
vi.mocked(isPermissionGranted).mockResolvedValueOnce(true);
|
|
||||||
|
|
||||||
await notificationManager.notify(NotificationType.SUCCESS);
|
|
||||||
|
|
||||||
expect(sendNotification).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
title: NOTIFICATION_SOUNDS[NotificationType.SUCCESS].phrase,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requests permission and sends notification when not yet granted", async () => {
|
|
||||||
const { isPermissionGranted, requestPermission, sendNotification } =
|
|
||||||
await import("@tauri-apps/plugin-notification");
|
|
||||||
setMockInvokeResult("send_windows_notification", new Error("failed"));
|
|
||||||
setMockInvokeResult("send_windows_toast", new Error("failed"));
|
|
||||||
setMockInvokeResult("send_wsl_notification", new Error("failed"));
|
|
||||||
vi.mocked(isPermissionGranted).mockResolvedValueOnce(false);
|
|
||||||
vi.mocked(requestPermission).mockResolvedValueOnce("granted");
|
|
||||||
|
|
||||||
await notificationManager.notify(NotificationType.SUCCESS);
|
|
||||||
|
|
||||||
expect(requestPermission).toHaveBeenCalledWith();
|
|
||||||
expect(sendNotification).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ body: "Task completed successfully!" })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls through to next method when permission is denied", async () => {
|
|
||||||
const { isPermissionGranted, requestPermission } =
|
|
||||||
await import("@tauri-apps/plugin-notification");
|
|
||||||
setMockInvokeResult("send_windows_notification", new Error("failed"));
|
|
||||||
setMockInvokeResult("send_windows_toast", new Error("failed"));
|
|
||||||
setMockInvokeResult("send_wsl_notification", new Error("failed"));
|
|
||||||
vi.mocked(isPermissionGranted).mockResolvedValueOnce(false);
|
|
||||||
vi.mocked(requestPermission).mockResolvedValueOnce("denied");
|
|
||||||
setMockInvokeResult("send_notify_send", new Error("failed"));
|
|
||||||
|
|
||||||
await notificationManager.notify(NotificationType.SUCCESS);
|
|
||||||
|
|
||||||
expect(sendTerminalNotification).toHaveBeenCalledWith(
|
|
||||||
NotificationType.SUCCESS,
|
|
||||||
"Task completed successfully!"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -90,8 +90,6 @@ class NotificationManager {
|
|||||||
return "Successfully connected to Claude Code";
|
return "Successfully connected to Claude Code";
|
||||||
case NotificationType.TASK_START:
|
case NotificationType.TASK_START:
|
||||||
return "Starting task...";
|
return "Starting task...";
|
||||||
case NotificationType.COST_ALERT:
|
|
||||||
return "You've exceeded your cost threshold!";
|
|
||||||
default:
|
default:
|
||||||
return "Notification";
|
return "Notification";
|
||||||
}
|
}
|
||||||
@@ -117,10 +115,6 @@ class NotificationManager {
|
|||||||
async notifyTaskStart(message?: string): Promise<void> {
|
async notifyTaskStart(message?: string): Promise<void> {
|
||||||
await this.notify(NotificationType.TASK_START, message);
|
await this.notify(NotificationType.TASK_START, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
async notifyCostAlert(message?: string): Promise<void> {
|
|
||||||
await this.notify(NotificationType.COST_ALERT, message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
|
|||||||
@@ -51,13 +51,9 @@ describe("notifications", () => {
|
|||||||
expect(NotificationType.ACHIEVEMENT).toBe("achievement");
|
expect(NotificationType.ACHIEVEMENT).toBe("achievement");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has exactly 7 notification types", () => {
|
it("has exactly 6 notification types", () => {
|
||||||
const types = Object.values(NotificationType);
|
const types = Object.values(NotificationType);
|
||||||
expect(types.length).toBe(7);
|
expect(types.length).toBe(6);
|
||||||
});
|
|
||||||
|
|
||||||
it("has COST_ALERT type", () => {
|
|
||||||
expect(NotificationType.COST_ALERT).toBe("cost_alert");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -232,53 +228,6 @@ describe("notifications", () => {
|
|||||||
// Should not throw
|
// Should not throw
|
||||||
await expect(soundPlayer.play(NotificationType.SUCCESS)).resolves.toBeUndefined();
|
await expect(soundPlayer.play(NotificationType.SUCCESS)).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("play warns when audio type is not in the cache", async () => {
|
|
||||||
const { soundPlayer } = await import("./soundPlayer");
|
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
||||||
|
|
||||||
soundPlayer.setEnabled(true);
|
|
||||||
await soundPlayer.play("nonexistent" as NotificationType);
|
|
||||||
|
|
||||||
expect(warnSpy).toHaveBeenCalledWith("No audio found for notification type: nonexistent");
|
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("play catches errors from audio playback", async () => {
|
|
||||||
vi.resetModules();
|
|
||||||
|
|
||||||
class FailingAudio {
|
|
||||||
volume = 1;
|
|
||||||
preload = "auto";
|
|
||||||
|
|
||||||
cloneNode() {
|
|
||||||
const clone = new FailingAudio();
|
|
||||||
clone.volume = this.volume;
|
|
||||||
return clone;
|
|
||||||
}
|
|
||||||
|
|
||||||
async play(): Promise<void> {
|
|
||||||
throw new Error("Playback blocked by browser");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalAudio = globalThis.Audio;
|
|
||||||
globalThis.Audio = FailingAudio as unknown as typeof Audio;
|
|
||||||
|
|
||||||
const { soundPlayer: freshPlayer } = await import("./soundPlayer");
|
|
||||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
|
|
||||||
freshPlayer.setEnabled(true);
|
|
||||||
await freshPlayer.play(NotificationType.SUCCESS);
|
|
||||||
|
|
||||||
expect(errorSpy).toHaveBeenCalledWith(
|
|
||||||
"Failed to play notification sound:",
|
|
||||||
expect.any(Error)
|
|
||||||
);
|
|
||||||
|
|
||||||
errorSpy.mockRestore();
|
|
||||||
globalThis.Audio = originalAudio;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("NotificationManager class", () => {
|
describe("NotificationManager class", () => {
|
||||||
@@ -365,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 filenames = Object.values(NOTIFICATION_SOUNDS).map((s) => s.filename);
|
||||||
const uniqueFilenames = new Set(filenames);
|
const uniqueFilenames = new Set(filenames);
|
||||||
// Allow some sound reuse (e.g., COST_ALERT reuses ERROR sound)
|
expect(uniqueFilenames.size).toBe(filenames.length);
|
||||||
expect(uniqueFilenames.size).toBeGreaterThanOrEqual(filenames.length - 1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("phrases are unique", () => {
|
it("phrases are unique", () => {
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
/**
|
|
||||||
* Notification Rules Tests
|
|
||||||
*
|
|
||||||
* Tests the connection status change handler, which fires a connection
|
|
||||||
* notification sound exactly once per reconnect cycle.
|
|
||||||
*
|
|
||||||
* What this module does:
|
|
||||||
* - Tracks the previous connection status in module-level state
|
|
||||||
* - Fires a notification only when transitioning from a non-connected
|
|
||||||
* state (disconnected/connecting) to "connected"
|
|
||||||
* - Ignores the initial connection (null → connected) to avoid noisy
|
|
||||||
* notifications on app start
|
|
||||||
* - Provides no-op handlers for tool execution and user messages
|
|
||||||
* (reserved for future notification rules)
|
|
||||||
* - cleanupNotificationRules() resets tracking state on teardown
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
const { mockNotifyConnection } = vi.hoisted(() => ({
|
|
||||||
mockNotifyConnection: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./notificationManager", () => ({
|
|
||||||
notificationManager: {
|
|
||||||
notifyConnection: mockNotifyConnection,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import {
|
|
||||||
handleConnectionStatusChange,
|
|
||||||
handleToolExecution,
|
|
||||||
handleNewUserMessage,
|
|
||||||
initializeNotificationRules,
|
|
||||||
cleanupNotificationRules,
|
|
||||||
} from "./rules";
|
|
||||||
|
|
||||||
// ---
|
|
||||||
|
|
||||||
describe("handleConnectionStatusChange", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockNotifyConnection.mockReset();
|
|
||||||
cleanupNotificationRules(); // Reset module-level previousConnectionStatus to null
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("initial connection (null → status)", () => {
|
|
||||||
it("does not notify on first connection (null → connected)", () => {
|
|
||||||
// previousConnectionStatus is null (falsy), so condition is not met
|
|
||||||
handleConnectionStatusChange("connected");
|
|
||||||
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not notify when disconnecting from initial state (null → disconnected)", () => {
|
|
||||||
handleConnectionStatusChange("disconnected");
|
|
||||||
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not notify when entering connecting from initial state (null → connecting)", () => {
|
|
||||||
handleConnectionStatusChange("connecting");
|
|
||||||
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("reconnection (disconnected → connected)", () => {
|
|
||||||
it("notifies when reconnecting after a disconnection", () => {
|
|
||||||
handleConnectionStatusChange("disconnected");
|
|
||||||
handleConnectionStatusChange("connected");
|
|
||||||
expect(mockNotifyConnection).toHaveBeenCalledWith();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("notifies exactly once per reconnect", () => {
|
|
||||||
handleConnectionStatusChange("disconnected");
|
|
||||||
handleConnectionStatusChange("connected");
|
|
||||||
expect(mockNotifyConnection).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("reconnection (connecting → connected)", () => {
|
|
||||||
it("notifies when transitioning from connecting to connected", () => {
|
|
||||||
handleConnectionStatusChange("connecting");
|
|
||||||
handleConnectionStatusChange("connected");
|
|
||||||
expect(mockNotifyConnection).toHaveBeenCalledWith();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("already connected (connected → connected)", () => {
|
|
||||||
it("does not notify when already connected", () => {
|
|
||||||
handleConnectionStatusChange("disconnected");
|
|
||||||
handleConnectionStatusChange("connected"); // First connection — notifies
|
|
||||||
mockNotifyConnection.mockReset();
|
|
||||||
|
|
||||||
handleConnectionStatusChange("connected"); // Second — same status, no notify
|
|
||||||
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("disconnecting (connected → disconnected)", () => {
|
|
||||||
it("does not notify when disconnecting", () => {
|
|
||||||
handleConnectionStatusChange("disconnected");
|
|
||||||
handleConnectionStatusChange("connected");
|
|
||||||
mockNotifyConnection.mockReset();
|
|
||||||
|
|
||||||
handleConnectionStatusChange("disconnected");
|
|
||||||
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("multiple reconnect cycles", () => {
|
|
||||||
it("notifies once per reconnect cycle", () => {
|
|
||||||
// First cycle
|
|
||||||
handleConnectionStatusChange("disconnected");
|
|
||||||
handleConnectionStatusChange("connected");
|
|
||||||
expect(mockNotifyConnection).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
mockNotifyConnection.mockReset();
|
|
||||||
|
|
||||||
// Second cycle
|
|
||||||
handleConnectionStatusChange("disconnected");
|
|
||||||
handleConnectionStatusChange("connected");
|
|
||||||
expect(mockNotifyConnection).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("cleanupNotificationRules", () => {
|
|
||||||
it("resets state so the next connection is treated as the first", () => {
|
|
||||||
// Establish a known previous status
|
|
||||||
handleConnectionStatusChange("disconnected");
|
|
||||||
// Now cleanup
|
|
||||||
cleanupNotificationRules();
|
|
||||||
// After cleanup, previousConnectionStatus is null again
|
|
||||||
// So the next "connected" should NOT notify (treated as initial connection)
|
|
||||||
handleConnectionStatusChange("connected");
|
|
||||||
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("no-op handlers", () => {
|
|
||||||
it("handleToolExecution does not throw", () => {
|
|
||||||
expect(() => handleToolExecution("Bash")).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handleNewUserMessage does not throw", () => {
|
|
||||||
expect(() => handleNewUserMessage()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("initializeNotificationRules does not throw", () => {
|
|
||||||
expect(() => initializeNotificationRules()).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,8 +1,52 @@
|
|||||||
|
import { characterState } from "$lib/stores/character";
|
||||||
import { notificationManager } from "./notificationManager";
|
import { notificationManager } from "./notificationManager";
|
||||||
|
import type { CharacterState } from "$lib/types/states";
|
||||||
import type { ConnectionStatus } from "$lib/types/messages";
|
import type { ConnectionStatus } from "$lib/types/messages";
|
||||||
|
|
||||||
// Track previous connection status to detect transitions
|
// Track previous states to detect transitions
|
||||||
|
let previousCharacterState: CharacterState | null = null;
|
||||||
let previousConnectionStatus: ConnectionStatus | null = null;
|
let previousConnectionStatus: ConnectionStatus | null = null;
|
||||||
|
let taskStartTime: number | null = null;
|
||||||
|
let hasNotifiedTaskStart = false;
|
||||||
|
|
||||||
|
export function handleCharacterStateChange(newState: CharacterState): void {
|
||||||
|
// Detect state transitions
|
||||||
|
if (previousCharacterState === newState) return;
|
||||||
|
|
||||||
|
// Task completion: any state -> success
|
||||||
|
if (newState === "success" && previousCharacterState !== null) {
|
||||||
|
const taskDuration = taskStartTime ? Date.now() - taskStartTime : 0;
|
||||||
|
// Only notify for tasks that took more than 2 seconds
|
||||||
|
if (taskDuration > 2000) {
|
||||||
|
notificationManager.notifySuccess();
|
||||||
|
}
|
||||||
|
taskStartTime = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error occurred
|
||||||
|
if (newState === "error" && previousCharacterState !== "error") {
|
||||||
|
notificationManager.notifyError();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission needed
|
||||||
|
if (newState === "permission") {
|
||||||
|
notificationManager.notifyPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starting long tasks - only notify once per response
|
||||||
|
if (
|
||||||
|
(newState === "coding" || newState === "searching") &&
|
||||||
|
previousCharacterState !== "coding" &&
|
||||||
|
previousCharacterState !== "searching" &&
|
||||||
|
!hasNotifiedTaskStart
|
||||||
|
) {
|
||||||
|
taskStartTime = Date.now();
|
||||||
|
hasNotifiedTaskStart = true;
|
||||||
|
notificationManager.notifyTaskStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
previousCharacterState = newState;
|
||||||
|
}
|
||||||
|
|
||||||
export function handleConnectionStatusChange(newStatus: ConnectionStatus): void {
|
export function handleConnectionStatusChange(newStatus: ConnectionStatus): void {
|
||||||
// Only notify on successful connection after being disconnected
|
// Only notify on successful connection after being disconnected
|
||||||
@@ -23,13 +67,37 @@ export function handleToolExecution(_toolName: string): void {
|
|||||||
// But we could add specific rules here if needed
|
// But we could add specific rules here if needed
|
||||||
}
|
}
|
||||||
|
|
||||||
// No-op: sound tracking is now per-conversation in tauri.ts
|
// Reset notification state for a new response
|
||||||
export function handleNewUserMessage(): void {}
|
export function handleNewUserMessage(): void {
|
||||||
|
hasNotifiedTaskStart = false;
|
||||||
// No-op: all per-conversation sounds are driven by tauri.ts event listeners
|
}
|
||||||
export function initializeNotificationRules(): void {}
|
|
||||||
|
// Store unsubscribe functions
|
||||||
// Cleanup — reset connection tracking on teardown
|
let unsubscribeCharacterState: (() => void) | null = null;
|
||||||
export function cleanupNotificationRules(): void {
|
|
||||||
previousConnectionStatus = null;
|
// Initialize listeners
|
||||||
|
export function initializeNotificationRules(): void {
|
||||||
|
// Clean up any existing subscriptions first
|
||||||
|
cleanupNotificationRules();
|
||||||
|
|
||||||
|
// Subscribe to character state changes
|
||||||
|
unsubscribeCharacterState = characterState.subscribe((state) => {
|
||||||
|
handleCharacterStateChange(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
// We'll connect to connection status in the next step
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup function to prevent duplicate listeners
|
||||||
|
export function cleanupNotificationRules(): void {
|
||||||
|
if (unsubscribeCharacterState) {
|
||||||
|
unsubscribeCharacterState();
|
||||||
|
unsubscribeCharacterState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset state tracking
|
||||||
|
previousCharacterState = null;
|
||||||
|
previousConnectionStatus = null;
|
||||||
|
taskStartTime = null;
|
||||||
|
hasNotifiedTaskStart = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { NotificationType } from "./types";
|
|
||||||
import { claudeStore } from "$lib/stores/claude";
|
|
||||||
import { sendTerminalNotification } from "./terminalNotifier";
|
|
||||||
|
|
||||||
vi.mock("$lib/stores/claude", () => ({
|
|
||||||
claudeStore: {
|
|
||||||
addLine: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("sendTerminalNotification", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds a system line for success type with sparkle emoji", () => {
|
|
||||||
sendTerminalNotification(NotificationType.SUCCESS);
|
|
||||||
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("✨"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds a system line for error type with cross emoji", () => {
|
|
||||||
sendTerminalNotification(NotificationType.ERROR);
|
|
||||||
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("❌"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds a system line for permission type with lock emoji", () => {
|
|
||||||
sendTerminalNotification(NotificationType.PERMISSION);
|
|
||||||
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("🔐"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds a system line for connection type with link emoji", () => {
|
|
||||||
sendTerminalNotification(NotificationType.CONNECTION);
|
|
||||||
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("🔗"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds a system line for task_start type with rocket emoji", () => {
|
|
||||||
sendTerminalNotification(NotificationType.TASK_START);
|
|
||||||
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("🚀"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes the optional message in the notification", () => {
|
|
||||||
sendTerminalNotification(NotificationType.SUCCESS, "Custom message text");
|
|
||||||
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
||||||
"system",
|
|
||||||
expect.stringContaining("Custom message text")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes the sound phrase as the notification title", () => {
|
|
||||||
sendTerminalNotification(NotificationType.SUCCESS);
|
|
||||||
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
||||||
"system",
|
|
||||||
expect.stringContaining("I'm done!")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { testAllNotifications } from "./testNotifications";
|
|
||||||
|
|
||||||
vi.mock("./notificationManager", () => ({
|
|
||||||
notificationManager: {
|
|
||||||
notifySuccess: vi.fn().mockResolvedValue(undefined),
|
|
||||||
notifyError: vi.fn().mockResolvedValue(undefined),
|
|
||||||
notifyPermission: vi.fn().mockResolvedValue(undefined),
|
|
||||||
notifyConnection: vi.fn().mockResolvedValue(undefined),
|
|
||||||
notifyTaskStart: vi.fn().mockResolvedValue(undefined),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("testNotifications", () => {
|
|
||||||
describe("window assignment", () => {
|
|
||||||
it("assigns testAllNotifications to window.testNotifications", async () => {
|
|
||||||
// The module-level if block runs on import — reimport to ensure it ran
|
|
||||||
await import("./testNotifications");
|
|
||||||
|
|
||||||
expect((window as unknown as { testNotifications: unknown }).testNotifications).toBe(
|
|
||||||
testAllNotifications
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("testAllNotifications", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is an async function", () => {
|
|
||||||
expect(typeof testAllNotifications).toBe("function");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("schedules all five notification type calls", async () => {
|
|
||||||
const { notificationManager } = await import("./notificationManager");
|
|
||||||
|
|
||||||
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
||||||
|
|
||||||
await testAllNotifications();
|
|
||||||
await vi.runAllTimersAsync();
|
|
||||||
|
|
||||||
expect(notificationManager.notifySuccess).toHaveBeenCalledWith("Test task completed!");
|
|
||||||
expect(notificationManager.notifyError).toHaveBeenCalledWith("Test error occurred!");
|
|
||||||
expect(notificationManager.notifyPermission).toHaveBeenCalledWith("Test permission request!");
|
|
||||||
expect(notificationManager.notifyConnection).toHaveBeenCalledWith(
|
|
||||||
"Test connection established!"
|
|
||||||
);
|
|
||||||
expect(notificationManager.notifyTaskStart).toHaveBeenCalledWith("Test task starting!");
|
|
||||||
|
|
||||||
consoleLogSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -5,7 +5,6 @@ export enum NotificationType {
|
|||||||
CONNECTION = "connection",
|
CONNECTION = "connection",
|
||||||
TASK_START = "task_start",
|
TASK_START = "task_start",
|
||||||
ACHIEVEMENT = "achievement",
|
ACHIEVEMENT = "achievement",
|
||||||
COST_ALERT = "cost_alert",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationSound {
|
export interface NotificationSound {
|
||||||
@@ -53,10 +52,4 @@ export const NOTIFICATION_SOUNDS: Record<NotificationType, NotificationSound> =
|
|||||||
phrase: "Achievement Get~!",
|
phrase: "Achievement Get~!",
|
||||||
volume: 0.8,
|
volume: 0.8,
|
||||||
},
|
},
|
||||||
[NotificationType.COST_ALERT]: {
|
|
||||||
type: NotificationType.COST_ALERT,
|
|
||||||
filename: "oh-no.mp3",
|
|
||||||
phrase: "Cost Alert!",
|
|
||||||
volume: 0.9,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { sendWSLNotification } from "./wslNotificationHelper";
|
|
||||||
|
|
||||||
// platform() is mocked in vitest.setup.ts to return "linux" by default
|
|
||||||
|
|
||||||
describe("sendWSLNotification", () => {
|
|
||||||
it("invokes send_windows_notification when platform is windows", async () => {
|
|
||||||
vi.mocked(platform).mockResolvedValueOnce("windows" as Awaited<ReturnType<typeof platform>>);
|
|
||||||
|
|
||||||
await sendWSLNotification("Test Title", "Test body");
|
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
|
||||||
title: "Test Title",
|
|
||||||
body: "Test body",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not invoke on non-Windows platforms", async () => {
|
|
||||||
// Default mock returns "linux"
|
|
||||||
await sendWSLNotification("Test Title", "Test body");
|
|
||||||
|
|
||||||
expect(invoke).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles invoke errors gracefully and logs them", async () => {
|
|
||||||
vi.mocked(platform).mockResolvedValueOnce("windows" as Awaited<ReturnType<typeof platform>>);
|
|
||||||
vi.mocked(invoke).mockRejectedValueOnce(new Error("Windows notification failed"));
|
|
||||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
|
|
||||||
await sendWSLNotification("Title", "Body");
|
|
||||||
|
|
||||||
expect(errorSpy).toHaveBeenCalledWith(
|
|
||||||
"Failed to send Windows notification:",
|
|
||||||
expect.any(Error)
|
|
||||||
);
|
|
||||||
errorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { NotificationType } from "$lib/notifications/types";
|
|
||||||
|
|
||||||
const mockPlay = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("$lib/notifications", () => ({
|
|
||||||
soundPlayer: {
|
|
||||||
play: mockPlay,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("achievement sounds", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockPlay.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("playAchievementSound", () => {
|
|
||||||
it("plays the achievement notification sound", async () => {
|
|
||||||
const { playAchievementSound } = await import("./achievement");
|
|
||||||
playAchievementSound();
|
|
||||||
expect(mockPlay).toHaveBeenCalledWith(NotificationType.ACHIEVEMENT);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("testAchievementSound", () => {
|
|
||||||
it("calls playAchievementSound without throwing", async () => {
|
|
||||||
const { testAchievementSound } = await import("./achievement");
|
|
||||||
expect(() => testAchievementSound()).not.toThrow();
|
|
||||||
expect(mockPlay).toHaveBeenCalledWith(NotificationType.ACHIEVEMENT);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("catches errors from the sound player gracefully", async () => {
|
|
||||||
mockPlay.mockImplementation(() => {
|
|
||||||
throw new Error("Audio not available");
|
|
||||||
});
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
|
|
||||||
const { testAchievementSound } = await import("./achievement");
|
|
||||||
expect(() => testAchievementSound()).not.toThrow();
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
||||||
"Error playing achievement sound:",
|
|
||||||
expect.any(Error)
|
|
||||||
);
|
|
||||||
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { get } from "svelte/store";
|
|
||||||
import { setMockInvokeResult, emitMockEvent } from "../../../vitest.setup";
|
|
||||||
import {
|
|
||||||
achievementsStore,
|
|
||||||
unlockedAchievements,
|
|
||||||
lockedAchievements,
|
|
||||||
achievementsByRarity,
|
|
||||||
achievementProgress,
|
|
||||||
initAchievementsListener,
|
|
||||||
} from "./achievements";
|
|
||||||
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
|
|
||||||
import { playAchievementSound } from "$lib/sounds/achievement";
|
|
||||||
|
|
||||||
vi.mock("$lib/sounds/achievement", () => ({
|
|
||||||
playAchievementSound: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Helper to build a minimal unlock event
|
|
||||||
function makeEvent(id: AchievementUnlockedEvent["achievement"]["id"]): AchievementUnlockedEvent {
|
|
||||||
return {
|
|
||||||
achievement: {
|
|
||||||
id,
|
|
||||||
name: "Test",
|
|
||||||
description: "Test achievement",
|
|
||||||
icon: "🏆",
|
|
||||||
unlocked_at: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("achievementsStore initial state", () => {
|
|
||||||
it("all achievements start as locked", () => {
|
|
||||||
const state = get(achievementsStore);
|
|
||||||
expect(state.achievements["FirstSteps"].unlocked).toBe(false);
|
|
||||||
expect(state.achievements["GrowingStrong"].unlocked).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("totalUnlocked starts at 0", () => {
|
|
||||||
expect(get(achievementsStore).totalUnlocked).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lastUnlocked starts as null", () => {
|
|
||||||
expect(get(achievementsStore).lastUnlocked).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("derived stores initial state", () => {
|
|
||||||
it("unlockedAchievements is initially empty", () => {
|
|
||||||
expect(get(unlockedAchievements)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lockedAchievements contains all achievements initially", () => {
|
|
||||||
const locked = get(lockedAchievements);
|
|
||||||
const total = Object.keys(get(achievementsStore).achievements).length;
|
|
||||||
expect(locked.length).toBe(total);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("achievementsByRarity groups achievements into rarity buckets", () => {
|
|
||||||
const byRarity = get(achievementsByRarity);
|
|
||||||
expect(byRarity.common).toBeInstanceOf(Array);
|
|
||||||
expect(byRarity.rare).toBeInstanceOf(Array);
|
|
||||||
expect(byRarity.epic).toBeInstanceOf(Array);
|
|
||||||
expect(byRarity.legendary).toBeInstanceOf(Array);
|
|
||||||
expect(byRarity.common.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("achievementProgress shows zero unlocked initially", () => {
|
|
||||||
const progress = get(achievementProgress);
|
|
||||||
expect(progress.unlocked).toBe(0);
|
|
||||||
expect(progress.total).toBeGreaterThan(0);
|
|
||||||
expect(progress.percentage).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("achievementsStore.unlockAchievement", () => {
|
|
||||||
it("marks the achievement as unlocked and updates totalUnlocked", () => {
|
|
||||||
achievementsStore.unlockAchievement(makeEvent("GrowingStrong"));
|
|
||||||
const state = get(achievementsStore);
|
|
||||||
expect(state.achievements["GrowingStrong"].unlocked).toBe(true);
|
|
||||||
expect(state.totalUnlocked).toBe(1);
|
|
||||||
expect(state.lastUnlocked?.id).toBe("GrowingStrong");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets unlockedAt from the event's unlocked_at timestamp", () => {
|
|
||||||
achievementsStore.unlockAchievement({
|
|
||||||
achievement: {
|
|
||||||
id: "BlossomingCoder",
|
|
||||||
name: "Blossoming Coder",
|
|
||||||
description: "100k tokens",
|
|
||||||
icon: "🌸",
|
|
||||||
unlocked_at: "2026-01-15T12:00:00.000Z",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const state = get(achievementsStore);
|
|
||||||
expect(state.achievements["BlossomingCoder"].unlockedAt).toBeInstanceOf(Date);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing when the achievement is already unlocked", () => {
|
|
||||||
achievementsStore.unlockAchievement(makeEvent("TokenMaster"));
|
|
||||||
const firstTotal = get(achievementsStore).totalUnlocked;
|
|
||||||
achievementsStore.unlockAchievement(makeEvent("TokenMaster"));
|
|
||||||
expect(get(achievementsStore).totalUnlocked).toBe(firstTotal);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls playAchievementSound when playSound is true (default)", () => {
|
|
||||||
achievementsStore.unlockAchievement(makeEvent("TokenBillionaire"));
|
|
||||||
expect(playAchievementSound).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not call playAchievementSound when playSound is false", () => {
|
|
||||||
achievementsStore.unlockAchievement(makeEvent("TokenTreasure"), false);
|
|
||||||
expect(playAchievementSound).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logs an error when playAchievementSound throws", () => {
|
|
||||||
vi.mocked(playAchievementSound).mockImplementationOnce(() => {
|
|
||||||
throw new Error("Sound failed");
|
|
||||||
});
|
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
achievementsStore.unlockAchievement(makeEvent("HelloWorld"));
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to play achievement sound:", expect.any(Error));
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("derived stores after unlocks", () => {
|
|
||||||
it("unlockedAchievements includes previously unlocked achievements", () => {
|
|
||||||
const unlocked = get(unlockedAchievements);
|
|
||||||
expect(unlocked.some((a) => a.id === "GrowingStrong")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lockedAchievements excludes previously unlocked achievements", () => {
|
|
||||||
const locked = get(lockedAchievements);
|
|
||||||
expect(locked.some((a) => a.id === "GrowingStrong")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("achievementProgress reflects the current unlocked count", () => {
|
|
||||||
const progress = get(achievementProgress);
|
|
||||||
expect(progress.unlocked).toBeGreaterThan(0);
|
|
||||||
expect(progress.percentage).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("achievementsStore.updateProgress", () => {
|
|
||||||
it("updates the progress value for an achievement", () => {
|
|
||||||
achievementsStore.updateProgress("FirstMessage", 50);
|
|
||||||
expect(get(achievementsStore).achievements["FirstMessage"].progress).toBe(50);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("achievementsStore.reset", () => {
|
|
||||||
it("resets totalUnlocked to 0 and lastUnlocked to null", () => {
|
|
||||||
achievementsStore.reset();
|
|
||||||
const state = get(achievementsStore);
|
|
||||||
expect(state.totalUnlocked).toBe(0);
|
|
||||||
expect(state.lastUnlocked).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("initAchievementsListener", () => {
|
|
||||||
it("unlocks an achievement when the achievement:unlocked event fires", async () => {
|
|
||||||
await initAchievementsListener();
|
|
||||||
emitMockEvent("achievement:unlocked", makeEvent("FirstSteps"));
|
|
||||||
expect(get(achievementsStore).achievements["FirstSteps"].unlocked).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("loads saved achievements from the backend without playing sounds", async () => {
|
|
||||||
setMockInvokeResult("load_saved_achievements", [makeEvent("ConversationStarter")]);
|
|
||||||
await initAchievementsListener();
|
|
||||||
expect(get(achievementsStore).achievements["ConversationStarter"].unlocked).toBe(true);
|
|
||||||
expect(playAchievementSound).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logs an error when loading saved achievements fails", async () => {
|
|
||||||
setMockInvokeResult("load_saved_achievements", new Error("Storage unavailable"));
|
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
await initAchievementsListener();
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(
|
|
||||||
"Failed to load saved achievements:",
|
|
||||||
expect.any(Error)
|
|
||||||
);
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,376 +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
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores lastAssistantMessage when provided", () => {
|
|
||||||
const agent = createMockAgent({ status: "running" });
|
|
||||||
agentStore.addAgent(conversationId, agent);
|
|
||||||
|
|
||||||
agentStore.endAgent(
|
|
||||||
conversationId,
|
|
||||||
agent.toolUseId,
|
|
||||||
Date.now(),
|
|
||||||
false,
|
|
||||||
"Task completed successfully."
|
|
||||||
);
|
|
||||||
|
|
||||||
const agents = get(getAgentsForConversation(conversationId));
|
|
||||||
expect(agents[0].lastAssistantMessage).toBe("Task completed successfully.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves lastAssistantMessage undefined when not provided", () => {
|
|
||||||
const agent = createMockAgent({ status: "running" });
|
|
||||||
agentStore.addAgent(conversationId, agent);
|
|
||||||
|
|
||||||
agentStore.endAgent(conversationId, agent.toolUseId, Date.now(), false);
|
|
||||||
|
|
||||||
const agents = get(getAgentsForConversation(conversationId));
|
|
||||||
expect(agents[0].lastAssistantMessage).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("markAllErrored", () => {
|
|
||||||
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,134 +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,
|
|
||||||
lastAssistantMessage?: 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];
|
|
||||||
const agent = updated[agentIndex];
|
|
||||||
const durationMs = endedAt - agent.startedAt;
|
|
||||||
|
|
||||||
updated[agentIndex] = {
|
|
||||||
...agent,
|
|
||||||
endedAt,
|
|
||||||
status: isError ? "errored" : "completed",
|
|
||||||
durationMs,
|
|
||||||
lastAssistantMessage,
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
|
||||||
import { get } from "svelte/store";
|
|
||||||
import { characterState, characterInfo } from "./character";
|
|
||||||
|
|
||||||
describe("characterState store", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
characterState.reset();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("initial state", () => {
|
|
||||||
it("starts in idle state", () => {
|
|
||||||
expect(get(characterState)).toBe("idle");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setState", () => {
|
|
||||||
it("sets the character state", () => {
|
|
||||||
characterState.setState("thinking");
|
|
||||||
expect(get(characterState)).toBe("thinking");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can set any valid state", () => {
|
|
||||||
const states = [
|
|
||||||
"idle",
|
|
||||||
"thinking",
|
|
||||||
"typing",
|
|
||||||
"coding",
|
|
||||||
"searching",
|
|
||||||
"mcp",
|
|
||||||
"permission",
|
|
||||||
"success",
|
|
||||||
"error",
|
|
||||||
] as const;
|
|
||||||
for (const state of states) {
|
|
||||||
characterState.setState(state);
|
|
||||||
expect(get(characterState)).toBe(state);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cancels any active temporary state timer", () => {
|
|
||||||
characterState.setTemporaryState("success", 5000);
|
|
||||||
characterState.setState("thinking");
|
|
||||||
|
|
||||||
// Advance past the temporary state duration — should stay as thinking
|
|
||||||
vi.advanceTimersByTime(6000);
|
|
||||||
expect(get(characterState)).toBe("thinking");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setTemporaryState", () => {
|
|
||||||
it("sets the character state immediately", () => {
|
|
||||||
characterState.setTemporaryState("success", 2000);
|
|
||||||
expect(get(characterState)).toBe("success");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reverts to idle after the specified duration", () => {
|
|
||||||
characterState.setTemporaryState("success", 2000);
|
|
||||||
vi.advanceTimersByTime(2000);
|
|
||||||
expect(get(characterState)).toBe("idle");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses 2000ms as the default duration", () => {
|
|
||||||
characterState.setTemporaryState("error");
|
|
||||||
vi.advanceTimersByTime(1999);
|
|
||||||
expect(get(characterState)).toBe("error");
|
|
||||||
vi.advanceTimersByTime(1);
|
|
||||||
expect(get(characterState)).toBe("idle");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cancels a previous temporary state timer when a new one is set", () => {
|
|
||||||
characterState.setTemporaryState("success", 5000);
|
|
||||||
characterState.setTemporaryState("error", 1000);
|
|
||||||
|
|
||||||
// First timer would have fired at 5000ms but was cancelled
|
|
||||||
vi.advanceTimersByTime(1000);
|
|
||||||
expect(get(characterState)).toBe("idle");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("reset", () => {
|
|
||||||
it("resets the state to idle", () => {
|
|
||||||
characterState.setState("thinking");
|
|
||||||
characterState.reset();
|
|
||||||
expect(get(characterState)).toBe("idle");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cancels any pending temporary state timer", () => {
|
|
||||||
characterState.setTemporaryState("success", 5000);
|
|
||||||
characterState.reset();
|
|
||||||
|
|
||||||
// Should now be idle and should NOT revert again after timer fires
|
|
||||||
expect(get(characterState)).toBe("idle");
|
|
||||||
vi.advanceTimersByTime(5000);
|
|
||||||
expect(get(characterState)).toBe("idle");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("characterInfo derived store", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
characterState.reset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the info object for the current state", () => {
|
|
||||||
const info = get(characterInfo);
|
|
||||||
expect(info).toBeDefined();
|
|
||||||
expect(typeof info.label).toBe("string");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates when the character state changes", () => {
|
|
||||||
characterState.setState("thinking");
|
|
||||||
const thinkingInfo = get(characterInfo);
|
|
||||||
|
|
||||||
characterState.setState("idle");
|
|
||||||
const idleInfo = get(characterInfo);
|
|
||||||
|
|
||||||
expect(thinkingInfo.label).not.toBe(idleInfo.label);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { describe, it, expect, afterEach } from "vitest";
|
|
||||||
import { get } from "svelte/store";
|
|
||||||
import {
|
|
||||||
claudeStore,
|
|
||||||
hasPermissionPending,
|
|
||||||
hasQuestionPending,
|
|
||||||
isClaudeProcessing,
|
|
||||||
} from "./claude";
|
|
||||||
import { conversationsStore } from "./conversations";
|
|
||||||
import { characterState } from "$lib/stores/character";
|
|
||||||
import type { PermissionRequest, UserQuestionEvent } from "$lib/types/messages";
|
|
||||||
|
|
||||||
describe("claudeStore (compatibility wrapper)", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
conversationsStore.revokeAllTools();
|
|
||||||
conversationsStore.clearPermission();
|
|
||||||
conversationsStore.clearQuestion();
|
|
||||||
conversationsStore.setConnectionStatus("disconnected");
|
|
||||||
characterState.setState("idle");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getGrantedTools", () => {
|
|
||||||
it("returns an empty array when no tools are granted", () => {
|
|
||||||
expect(claudeStore.getGrantedTools()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns granted tools as an array", () => {
|
|
||||||
conversationsStore.grantTool("Read");
|
|
||||||
conversationsStore.grantTool("Write");
|
|
||||||
|
|
||||||
const tools = claudeStore.getGrantedTools();
|
|
||||||
|
|
||||||
expect(tools).toContain("Read");
|
|
||||||
expect(tools).toContain("Write");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("reset", () => {
|
|
||||||
it("clears terminal lines and resets processing state", () => {
|
|
||||||
conversationsStore.setProcessing(true);
|
|
||||||
conversationsStore.grantTool("Edit");
|
|
||||||
|
|
||||||
claudeStore.reset();
|
|
||||||
|
|
||||||
expect(get(conversationsStore.isProcessing)).toBe(false);
|
|
||||||
expect(claudeStore.getGrantedTools()).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("hasPermissionPending derived store", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
conversationsStore.clearPermission();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is false when there are no pending permissions", () => {
|
|
||||||
expect(get(hasPermissionPending)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is true when there is a pending permission request", () => {
|
|
||||||
const request: PermissionRequest = {
|
|
||||||
id: "perm-1",
|
|
||||||
tool: "Bash",
|
|
||||||
description: "Run a shell command",
|
|
||||||
input: { command: "ls" },
|
|
||||||
};
|
|
||||||
|
|
||||||
conversationsStore.requestPermission(request);
|
|
||||||
|
|
||||||
expect(get(hasPermissionPending)).toBe(true);
|
|
||||||
|
|
||||||
conversationsStore.clearPermission();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("hasQuestionPending derived store", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
conversationsStore.clearQuestion();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is false when there is no pending question", () => {
|
|
||||||
expect(get(hasQuestionPending)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is true when there is a pending question", () => {
|
|
||||||
const question: UserQuestionEvent = {
|
|
||||||
id: "q-1",
|
|
||||||
question: "Which approach do you prefer?",
|
|
||||||
options: [{ label: "A" }, { label: "B" }],
|
|
||||||
multi_select: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
conversationsStore.requestQuestion(question);
|
|
||||||
|
|
||||||
expect(get(hasQuestionPending)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isClaudeProcessing derived store", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
conversationsStore.setConnectionStatus("disconnected");
|
|
||||||
characterState.setState("idle");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is false when disconnected regardless of character state", () => {
|
|
||||||
conversationsStore.setConnectionStatus("disconnected");
|
|
||||||
characterState.setState("thinking");
|
|
||||||
|
|
||||||
expect(get(isClaudeProcessing)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is false when connected but in idle state", () => {
|
|
||||||
conversationsStore.setConnectionStatus("connected");
|
|
||||||
characterState.setState("idle");
|
|
||||||
|
|
||||||
expect(get(isClaudeProcessing)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is true when connected and in thinking state", () => {
|
|
||||||
conversationsStore.setConnectionStatus("connected");
|
|
||||||
characterState.setState("thinking");
|
|
||||||
|
|
||||||
expect(get(isClaudeProcessing)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is true when connected and in coding state", () => {
|
|
||||||
conversationsStore.setConnectionStatus("connected");
|
|
||||||
characterState.setState("coding");
|
|
||||||
|
|
||||||
expect(get(isClaudeProcessing)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is true when connected and in searching state", () => {
|
|
||||||
conversationsStore.setConnectionStatus("connected");
|
|
||||||
characterState.setState("searching");
|
|
||||||
|
|
||||||
expect(get(isClaudeProcessing)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -60,15 +60,6 @@ export const claudeStore = {
|
|||||||
isToolGranted: conversationsStore.isToolGranted,
|
isToolGranted: conversationsStore.isToolGranted,
|
||||||
setPendingRetryMessage: conversationsStore.setPendingRetryMessage,
|
setPendingRetryMessage: conversationsStore.setPendingRetryMessage,
|
||||||
|
|
||||||
// Sound tracking
|
|
||||||
resetSoundState: conversationsStore.resetSoundState,
|
|
||||||
setTaskStartTime: conversationsStore.setTaskStartTime,
|
|
||||||
markSuccessSoundFired: conversationsStore.markSuccessSoundFired,
|
|
||||||
markTaskStartSoundFired: conversationsStore.markTaskStartSoundFired,
|
|
||||||
|
|
||||||
// Draft text (per-tab input persistence)
|
|
||||||
setDraftText: conversationsStore.setDraftText,
|
|
||||||
|
|
||||||
// Conversation management
|
// Conversation management
|
||||||
createConversation: conversationsStore.createConversation,
|
createConversation: conversationsStore.createConversation,
|
||||||
deleteConversation: conversationsStore.deleteConversation,
|
deleteConversation: conversationsStore.deleteConversation,
|
||||||
@@ -110,10 +101,7 @@ export const claudeStore = {
|
|||||||
|
|
||||||
export const hasPermissionPending = derived(
|
export const hasPermissionPending = derived(
|
||||||
claudeStore.activeConversation,
|
claudeStore.activeConversation,
|
||||||
($conversation) =>
|
($conversation) => $conversation?.pendingPermission !== null
|
||||||
$conversation?.pendingPermissions !== null &&
|
|
||||||
$conversation?.pendingPermissions !== undefined &&
|
|
||||||
$conversation.pendingPermissions.length > 0
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const hasQuestionPending = derived(
|
export const hasQuestionPending = derived(
|
||||||
|
|||||||
@@ -1,675 +0,0 @@
|
|||||||
/**
|
|
||||||
* Clipboard Store Tests
|
|
||||||
*
|
|
||||||
* Tests the pure helper functions and store actions from the clipboard store:
|
|
||||||
* - detectLanguage: identifies programming language from code content
|
|
||||||
* - formatTimestamp: converts an ISO timestamp to a relative time string
|
|
||||||
* - Store actions: loadEntries, captureClipboard, deleteEntry, togglePin, etc.
|
|
||||||
* - Derived stores: filteredEntries (language + search filtering), languages
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { get } from "svelte/store";
|
|
||||||
import { clipboardStore } from "$lib/stores/clipboard";
|
|
||||||
import { setMockInvokeResult } from "../../../vitest.setup";
|
|
||||||
|
|
||||||
describe("detectLanguage", () => {
|
|
||||||
describe("TypeScript detection", () => {
|
|
||||||
it("detects import statements", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("import React from 'react';")).toBe("typescript");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects export statements", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("export function foo() {}")).toBe("typescript");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects const declarations", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("const x = 1;")).toBe("typescript");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects interface declarations", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("interface Foo {\n bar: string;\n}")).toBe(
|
|
||||||
"typescript"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects type aliases", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("type MyType = string | number;")).toBe("typescript");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Python detection", () => {
|
|
||||||
it("detects def statements", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("def foo():\n pass")).toBe("python");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects async def statements", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("async def bar():\n pass")).toBe("python");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects from imports", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("from os import path")).toBe("python");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects the __name__ guard", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("if __name__ == '__main__':\n main()")).toBe("python");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Rust detection", () => {
|
|
||||||
it("detects fn declarations", () => {
|
|
||||||
expect(clipboardStore.detectLanguage('fn main() {\n println!("hello");\n}')).toBe("rust");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects impl blocks", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("impl Foo {\n pub fn new() -> Self {}\n}")).toBe(
|
|
||||||
"rust"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects struct declarations", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("struct Point {\n x: f64,\n y: f64,\n}")).toBe("rust");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects enum declarations", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("enum Direction {\n North,\n South,\n}")).toBe("rust");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects mod declarations", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("mod utils;")).toBe("rust");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects pub visibility", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("pub fn exported() {}")).toBe("rust");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Go detection", () => {
|
|
||||||
it("detects package declarations", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("package main")).toBe("go");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects func declarations", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("func main() {}")).toBe("go");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("PHP detection", () => {
|
|
||||||
it("detects the PHP open tag", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("<?php\necho 'hello';")).toBe("php");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("SQL detection", () => {
|
|
||||||
it("detects SELECT statements", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("SELECT * FROM users WHERE id = 1")).toBe("sql");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects INSERT statements", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("INSERT INTO users (name) VALUES ('Alice')")).toBe(
|
|
||||||
"sql"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects CREATE statements", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("CREATE TABLE users (id INT PRIMARY KEY)")).toBe("sql");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects SQL case-insensitively", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("select * from users")).toBe("sql");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("HTML detection", () => {
|
|
||||||
it("detects DOCTYPE declarations", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("<!DOCTYPE html>")).toBe("html");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects html tags", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("<html><body></body></html>")).toBe("html");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects div tags", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("<div class='foo'>bar</div>")).toBe("html");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects span tags", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("<span>text</span>")).toBe("html");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("JSON detection", () => {
|
|
||||||
it("detects JSON object syntax", () => {
|
|
||||||
expect(clipboardStore.detectLanguage('{"name": "test", "value": 42}')).toBe("json");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects JSON with hyphenated keys", () => {
|
|
||||||
expect(clipboardStore.detectLanguage('{"my-key": "value"}')).toBe("json");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("YAML detection", () => {
|
|
||||||
it("detects YAML document separator", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("---\nkey: value\nother: 123")).toBe("yaml");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("C detection", () => {
|
|
||||||
it("detects #include directives", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("#include <stdio.h>\nint main() {}")).toBe("c");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects #define directives", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("#define MAX 100")).toBe("c");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects #ifdef directives", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("#ifdef DEBUG\n// debug code\n#endif")).toBe("c");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Java detection", () => {
|
|
||||||
it("detects public class declarations", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("public class Foo {\n // ...\n}")).toBe("java");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects private static methods", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("private static void helper() {}")).toBe("java");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects protected interface declarations", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("protected interface Bar {}")).toBe("java");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Bash detection", () => {
|
|
||||||
it("detects shell variable assignments", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("$HOME=/usr/local")).toBe("bash");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects variable assignments with underscores", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("$MY_VAR=some_value")).toBe("bash");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("unknown content", () => {
|
|
||||||
it("returns null for plain text", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("Hello, world!")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for empty string", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for mathematical expressions", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("1 + 1 = 2")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for a markdown heading", () => {
|
|
||||||
expect(clipboardStore.detectLanguage("# My Heading\nSome text")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("formatTimestamp", () => {
|
|
||||||
const NOW = new Date("2026-03-03T12:00:00.000Z");
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime(NOW);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("'Just now' threshold (< 1 minute)", () => {
|
|
||||||
it("returns 'Just now' for a timestamp 30 seconds ago", () => {
|
|
||||||
const ts = new Date("2026-03-03T11:59:30.000Z").toISOString();
|
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 'Just now' for the current moment", () => {
|
|
||||||
const ts = NOW.toISOString();
|
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 'Just now' for a timestamp 59 seconds ago", () => {
|
|
||||||
const ts = new Date("2026-03-03T11:59:01.000Z").toISOString();
|
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("'Xm ago' threshold (1–59 minutes)", () => {
|
|
||||||
it("returns '1m ago' at exactly 1 minute", () => {
|
|
||||||
const ts = new Date("2026-03-03T11:59:00.000Z").toISOString();
|
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("1m ago");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns '5m ago' for a timestamp 5 minutes ago", () => {
|
|
||||||
const ts = new Date("2026-03-03T11:55:00.000Z").toISOString();
|
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("5m ago");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns '59m ago' just before the 1-hour threshold", () => {
|
|
||||||
const ts = new Date("2026-03-03T11:01:00.000Z").toISOString();
|
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("59m ago");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("'Xh ago' threshold (1–23 hours)", () => {
|
|
||||||
it("returns '1h ago' at exactly 1 hour", () => {
|
|
||||||
const ts = new Date("2026-03-03T11:00:00.000Z").toISOString();
|
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("1h ago");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns '2h ago' for a timestamp 2 hours ago", () => {
|
|
||||||
const ts = new Date("2026-03-03T10:00:00.000Z").toISOString();
|
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("2h ago");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns '23h ago' just before the 1-day threshold", () => {
|
|
||||||
const ts = new Date("2026-03-02T13:00:00.000Z").toISOString();
|
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("23h ago");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("'Xd ago' threshold (1–6 days)", () => {
|
|
||||||
it("returns '1d ago' at exactly 1 day", () => {
|
|
||||||
const ts = new Date("2026-03-02T12:00:00.000Z").toISOString();
|
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("1d ago");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns '3d ago' for a timestamp 3 days ago", () => {
|
|
||||||
const ts = new Date("2026-02-28T12:00:00.000Z").toISOString();
|
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("3d ago");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns '6d ago' just before the 7-day threshold", () => {
|
|
||||||
const ts = new Date("2026-02-25T12:00:00.000Z").toISOString();
|
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("6d ago");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("locale date string (7+ days ago)", () => {
|
|
||||||
it("returns a locale date string for a 2-week-old timestamp", () => {
|
|
||||||
const ts = new Date("2026-02-17T12:00:00.000Z").toISOString();
|
|
||||||
const result = clipboardStore.formatTimestamp(ts);
|
|
||||||
// Should not be a relative time string
|
|
||||||
expect(result).not.toContain("m ago");
|
|
||||||
expect(result).not.toContain("h ago");
|
|
||||||
expect(result).not.toContain("d ago");
|
|
||||||
expect(result).not.toBe("Just now");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a locale date string for a 1-month-old timestamp", () => {
|
|
||||||
const ts = new Date("2026-02-03T12:00:00.000Z").toISOString();
|
|
||||||
const result = clipboardStore.formatTimestamp(ts);
|
|
||||||
expect(result).not.toContain("ago");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("clipboardStore - derived stores", () => {
|
|
||||||
const makeEntry = (overrides: Record<string, unknown> = {}) => ({
|
|
||||||
id: "entry-1",
|
|
||||||
content: "const x = 1;",
|
|
||||||
language: "typescript",
|
|
||||||
source: "test.ts",
|
|
||||||
timestamp: "2026-03-03T12:00:00.000Z",
|
|
||||||
is_pinned: false,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
clipboardStore.entries.set([]);
|
|
||||||
clipboardStore.searchQuery.set("");
|
|
||||||
clipboardStore.languageFilter.set(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("filteredEntries - language filter", () => {
|
|
||||||
it("returns all entries when no language filter is set", () => {
|
|
||||||
clipboardStore.entries.set([
|
|
||||||
makeEntry({ id: "1", language: "typescript" }),
|
|
||||||
makeEntry({ id: "2", language: "python" }),
|
|
||||||
]);
|
|
||||||
const filtered = get(clipboardStore.filteredEntries);
|
|
||||||
expect(filtered).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters entries by language", () => {
|
|
||||||
clipboardStore.entries.set([
|
|
||||||
makeEntry({ id: "1", language: "typescript" }),
|
|
||||||
makeEntry({ id: "2", language: "python" }),
|
|
||||||
makeEntry({ id: "3", language: "typescript" }),
|
|
||||||
]);
|
|
||||||
clipboardStore.languageFilter.set("typescript");
|
|
||||||
const filtered = get(clipboardStore.filteredEntries);
|
|
||||||
expect(filtered).toHaveLength(2);
|
|
||||||
expect(filtered.every((e) => e.language === "typescript")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty array when filter matches nothing", () => {
|
|
||||||
clipboardStore.entries.set([makeEntry({ language: "typescript" })]);
|
|
||||||
clipboardStore.languageFilter.set("rust");
|
|
||||||
const filtered = get(clipboardStore.filteredEntries);
|
|
||||||
expect(filtered).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("filteredEntries - search query", () => {
|
|
||||||
it("filters by content (case-insensitive)", () => {
|
|
||||||
clipboardStore.entries.set([
|
|
||||||
makeEntry({ id: "1", content: "const HELLO = 1;" }),
|
|
||||||
makeEntry({ id: "2", content: "let world = 2;" }),
|
|
||||||
]);
|
|
||||||
clipboardStore.searchQuery.set("hello");
|
|
||||||
const filtered = get(clipboardStore.filteredEntries);
|
|
||||||
expect(filtered).toHaveLength(1);
|
|
||||||
expect(filtered[0].id).toBe("1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters by language field", () => {
|
|
||||||
clipboardStore.entries.set([
|
|
||||||
makeEntry({ id: "1", language: "typescript", content: "unrelated" }),
|
|
||||||
makeEntry({ id: "2", language: "python", content: "also unrelated" }),
|
|
||||||
]);
|
|
||||||
clipboardStore.searchQuery.set("python");
|
|
||||||
const filtered = get(clipboardStore.filteredEntries);
|
|
||||||
expect(filtered).toHaveLength(1);
|
|
||||||
expect(filtered[0].id).toBe("2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters by source field", () => {
|
|
||||||
clipboardStore.entries.set([
|
|
||||||
makeEntry({ id: "1", source: "main.rs", content: "fn main() {}" }),
|
|
||||||
makeEntry({ id: "2", source: "app.ts", content: "const x = 1;" }),
|
|
||||||
]);
|
|
||||||
clipboardStore.searchQuery.set("main.rs");
|
|
||||||
const filtered = get(clipboardStore.filteredEntries);
|
|
||||||
expect(filtered).toHaveLength(1);
|
|
||||||
expect(filtered[0].id).toBe("1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns all entries when search query is empty", () => {
|
|
||||||
clipboardStore.entries.set([makeEntry({ id: "1" }), makeEntry({ id: "2" })]);
|
|
||||||
clipboardStore.searchQuery.set("");
|
|
||||||
const filtered = get(clipboardStore.filteredEntries);
|
|
||||||
expect(filtered).toHaveLength(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("languages derived store", () => {
|
|
||||||
it("returns empty array when no entries", () => {
|
|
||||||
clipboardStore.entries.set([]);
|
|
||||||
expect(get(clipboardStore.languages)).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns unique sorted languages", () => {
|
|
||||||
clipboardStore.entries.set([
|
|
||||||
makeEntry({ language: "typescript" }),
|
|
||||||
makeEntry({ language: "python" }),
|
|
||||||
makeEntry({ language: "typescript" }),
|
|
||||||
makeEntry({ language: "rust" }),
|
|
||||||
]);
|
|
||||||
const langs = get(clipboardStore.languages);
|
|
||||||
expect(langs).toEqual(["python", "rust", "typescript"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes entries with null language", () => {
|
|
||||||
clipboardStore.entries.set([
|
|
||||||
makeEntry({ language: "typescript" }),
|
|
||||||
makeEntry({ language: null }),
|
|
||||||
]);
|
|
||||||
const langs = get(clipboardStore.languages);
|
|
||||||
expect(langs).toEqual(["typescript"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("clipboardStore - setSearchQuery and setLanguageFilter", () => {
|
|
||||||
it("setSearchQuery updates the searchQuery store", () => {
|
|
||||||
clipboardStore.setSearchQuery("hello world");
|
|
||||||
expect(get(clipboardStore.searchQuery)).toBe("hello world");
|
|
||||||
clipboardStore.setSearchQuery("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("setLanguageFilter updates the languageFilter store", () => {
|
|
||||||
clipboardStore.setLanguageFilter("python");
|
|
||||||
expect(get(clipboardStore.languageFilter)).toBe("python");
|
|
||||||
clipboardStore.setLanguageFilter(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("setLanguageFilter can be reset to null", () => {
|
|
||||||
clipboardStore.setLanguageFilter("rust");
|
|
||||||
clipboardStore.setLanguageFilter(null);
|
|
||||||
expect(get(clipboardStore.languageFilter)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("clipboardStore - store actions", () => {
|
|
||||||
const mockEntry = {
|
|
||||||
id: "entry-1",
|
|
||||||
content: "const x = 1;",
|
|
||||||
language: "typescript",
|
|
||||||
source: "test.ts",
|
|
||||||
timestamp: "2026-03-03T12:00:00.000Z",
|
|
||||||
is_pinned: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
clipboardStore.entries.set([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("loadEntries", () => {
|
|
||||||
it("loads entries from backend and updates the store", async () => {
|
|
||||||
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
|
|
||||||
await clipboardStore.loadEntries();
|
|
||||||
expect(get(clipboardStore.entries)).toEqual([mockEntry]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles errors gracefully without crashing", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("list_clipboard_entries", new Error("Backend unavailable"));
|
|
||||||
await clipboardStore.loadEntries();
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
||||||
"Failed to load clipboard entries:",
|
|
||||||
expect.any(Error)
|
|
||||||
);
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets isLoading to false after completion", async () => {
|
|
||||||
setMockInvokeResult("list_clipboard_entries", []);
|
|
||||||
await clipboardStore.loadEntries();
|
|
||||||
expect(get(clipboardStore.isLoading)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("captureClipboard", () => {
|
|
||||||
it("captures clipboard entry with provided language", async () => {
|
|
||||||
setMockInvokeResult("capture_clipboard", mockEntry);
|
|
||||||
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
|
|
||||||
const result = await clipboardStore.captureClipboard("const x = 1;", "typescript", "test.ts");
|
|
||||||
expect(result).toEqual(mockEntry);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("auto-detects language when none provided", async () => {
|
|
||||||
setMockInvokeResult("capture_clipboard", mockEntry);
|
|
||||||
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
|
|
||||||
const result = await clipboardStore.captureClipboard("const x = 1;");
|
|
||||||
expect(result).toEqual(mockEntry);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null on error", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("capture_clipboard", new Error("Failed"));
|
|
||||||
const result = await clipboardStore.captureClipboard("const x = 1;");
|
|
||||||
expect(result).toBeNull();
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("deleteEntry", () => {
|
|
||||||
it("removes entry from store on success", async () => {
|
|
||||||
clipboardStore.entries.set([mockEntry, { ...mockEntry, id: "entry-2" }]);
|
|
||||||
setMockInvokeResult("delete_clipboard_entry", undefined);
|
|
||||||
await clipboardStore.deleteEntry("entry-1");
|
|
||||||
expect(get(clipboardStore.entries)).toHaveLength(1);
|
|
||||||
expect(get(clipboardStore.entries)[0].id).toBe("entry-2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("delete_clipboard_entry", new Error("Failed"));
|
|
||||||
await clipboardStore.deleteEntry("entry-1");
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
||||||
"Failed to delete clipboard entry:",
|
|
||||||
expect.any(Error)
|
|
||||||
);
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("togglePin", () => {
|
|
||||||
it("updates entry pin status and sorts pinned first", async () => {
|
|
||||||
const unpinned1 = { ...mockEntry, id: "entry-1", is_pinned: false };
|
|
||||||
const unpinned2 = { ...mockEntry, id: "entry-2", is_pinned: false };
|
|
||||||
clipboardStore.entries.set([unpinned1, unpinned2]);
|
|
||||||
const pinned = { ...unpinned2, is_pinned: true };
|
|
||||||
setMockInvokeResult("toggle_pin_clipboard_entry", pinned);
|
|
||||||
await clipboardStore.togglePin("entry-2");
|
|
||||||
const entries = get(clipboardStore.entries);
|
|
||||||
expect(entries[0].id).toBe("entry-2");
|
|
||||||
expect(entries[0].is_pinned).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("toggle_pin_clipboard_entry", new Error("Failed"));
|
|
||||||
await clipboardStore.togglePin("entry-1");
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to toggle pin:", expect.any(Error));
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exercises the unpinned-before-pinned sort branch", async () => {
|
|
||||||
// Three entries so sort compares (entry-1 unpinned, entry-2 pinned) — hits line 144
|
|
||||||
const e1 = { ...mockEntry, id: "e1", is_pinned: false };
|
|
||||||
const e2 = { ...mockEntry, id: "e2", is_pinned: false };
|
|
||||||
const e3 = { ...mockEntry, id: "e3", is_pinned: false };
|
|
||||||
clipboardStore.entries.set([e1, e2, e3]);
|
|
||||||
const pinned = { ...e2, is_pinned: true };
|
|
||||||
setMockInvokeResult("toggle_pin_clipboard_entry", pinned);
|
|
||||||
await clipboardStore.togglePin("e2");
|
|
||||||
expect(get(clipboardStore.entries)[0].id).toBe("e2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sorts same-pin-status entries by timestamp descending", async () => {
|
|
||||||
// Toggle entry to pinned while two others remain unpinned — sort compares unpinned pair by timestamp
|
|
||||||
const older = {
|
|
||||||
...mockEntry,
|
|
||||||
id: "older",
|
|
||||||
is_pinned: false,
|
|
||||||
timestamp: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
const newer = {
|
|
||||||
...mockEntry,
|
|
||||||
id: "newer",
|
|
||||||
is_pinned: false,
|
|
||||||
timestamp: "2026-01-02T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
const pinned = {
|
|
||||||
...mockEntry,
|
|
||||||
id: "pinned",
|
|
||||||
is_pinned: false,
|
|
||||||
timestamp: "2026-01-03T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
clipboardStore.entries.set([older, newer, pinned]);
|
|
||||||
const pinnedResult = { ...pinned, is_pinned: true };
|
|
||||||
setMockInvokeResult("toggle_pin_clipboard_entry", pinnedResult);
|
|
||||||
await clipboardStore.togglePin("pinned");
|
|
||||||
const entries = get(clipboardStore.entries);
|
|
||||||
expect(entries[0].id).toBe("pinned");
|
|
||||||
expect(entries[1].id).toBe("newer");
|
|
||||||
expect(entries[2].id).toBe("older");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("clearHistory", () => {
|
|
||||||
it("removes unpinned entries and keeps pinned ones", async () => {
|
|
||||||
const pinned = { ...mockEntry, id: "pinned", is_pinned: true };
|
|
||||||
const unpinned = { ...mockEntry, id: "unpinned", is_pinned: false };
|
|
||||||
clipboardStore.entries.set([pinned, unpinned]);
|
|
||||||
setMockInvokeResult("clear_clipboard_history", undefined);
|
|
||||||
await clipboardStore.clearHistory();
|
|
||||||
const entries = get(clipboardStore.entries);
|
|
||||||
expect(entries).toHaveLength(1);
|
|
||||||
expect(entries[0].id).toBe("pinned");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("clear_clipboard_history", new Error("Failed"));
|
|
||||||
await clipboardStore.clearHistory();
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
||||||
"Failed to clear clipboard history:",
|
|
||||||
expect.any(Error)
|
|
||||||
);
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("updateLanguage", () => {
|
|
||||||
it("updates entry language in the store", async () => {
|
|
||||||
clipboardStore.entries.set([mockEntry]);
|
|
||||||
const updated = { ...mockEntry, language: "javascript" };
|
|
||||||
setMockInvokeResult("update_clipboard_language", updated);
|
|
||||||
await clipboardStore.updateLanguage("entry-1", "javascript");
|
|
||||||
expect(get(clipboardStore.entries)[0].language).toBe("javascript");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves non-matching entries unchanged", async () => {
|
|
||||||
const other = { ...mockEntry, id: "other", language: "python" };
|
|
||||||
clipboardStore.entries.set([mockEntry, other]);
|
|
||||||
const updated = { ...mockEntry, language: "javascript" };
|
|
||||||
setMockInvokeResult("update_clipboard_language", updated);
|
|
||||||
await clipboardStore.updateLanguage("entry-1", "javascript");
|
|
||||||
const entries = get(clipboardStore.entries);
|
|
||||||
expect(entries[1].language).toBe("python");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("update_clipboard_language", new Error("Failed"));
|
|
||||||
await clipboardStore.updateLanguage("entry-1", "javascript");
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to update language:", expect.any(Error));
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("copyToClipboard", () => {
|
|
||||||
it("returns true and copies text on success", async () => {
|
|
||||||
Object.assign(navigator, {
|
|
||||||
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
|
||||||
});
|
|
||||||
const result = await clipboardStore.copyToClipboard("hello world");
|
|
||||||
expect(result).toBe(true);
|
|
||||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello world");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false and logs error on failure", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
Object.assign(navigator, {
|
|
||||||
clipboard: { writeText: vi.fn().mockRejectedValue(new Error("Clipboard denied")) },
|
|
||||||
});
|
|
||||||
const result = await clipboardStore.copyToClipboard("hello world");
|
|
||||||
expect(result).toBe(false);
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
||||||
"Failed to copy to clipboard:",
|
|
||||||
expect.any(Error)
|
|
||||||
);
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
+23
-171
@@ -1,9 +1,7 @@
|
|||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { readFile } from "@tauri-apps/plugin-fs";
|
|
||||||
|
|
||||||
export type Theme = "dark" | "light" | "high-contrast" | "custom";
|
export type Theme = "dark" | "light" | "high-contrast" | "custom";
|
||||||
export type BudgetAction = "warn" | "block";
|
|
||||||
|
|
||||||
export interface CustomThemeColors {
|
export interface CustomThemeColors {
|
||||||
bg_primary: string | null;
|
bg_primary: string | null;
|
||||||
@@ -28,6 +26,7 @@ export interface HikariConfig {
|
|||||||
notifications_enabled: boolean;
|
notifications_enabled: boolean;
|
||||||
notification_volume: number;
|
notification_volume: number;
|
||||||
always_on_top: boolean;
|
always_on_top: boolean;
|
||||||
|
minimize_to_tray: boolean;
|
||||||
update_checks_enabled: boolean;
|
update_checks_enabled: boolean;
|
||||||
character_panel_width: number | null;
|
character_panel_width: number | null;
|
||||||
font_size: number;
|
font_size: number;
|
||||||
@@ -38,33 +37,6 @@ export interface HikariConfig {
|
|||||||
profile_avatar_path: string | null;
|
profile_avatar_path: string | null;
|
||||||
profile_bio: string | null;
|
profile_bio: string | null;
|
||||||
custom_theme_colors: CustomThemeColors;
|
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;
|
|
||||||
// Worktree isolation
|
|
||||||
use_worktree: boolean;
|
|
||||||
// Disable 1M context window
|
|
||||||
disable_1m_context: boolean;
|
|
||||||
// Max output tokens for Claude Code responses
|
|
||||||
max_output_tokens: number | null;
|
|
||||||
// Workspaces the user has explicitly trusted
|
|
||||||
trusted_workspaces: string[];
|
|
||||||
// Background image settings
|
|
||||||
background_image_path: string | null;
|
|
||||||
background_image_opacity: number;
|
|
||||||
// Custom terminal font settings
|
|
||||||
custom_font_path: string | null;
|
|
||||||
custom_font_family: string | null;
|
|
||||||
// Custom UI font settings
|
|
||||||
custom_ui_font_path: string | null;
|
|
||||||
custom_ui_font_family: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: HikariConfig = {
|
const defaultConfig: HikariConfig = {
|
||||||
@@ -79,6 +51,7 @@ const defaultConfig: HikariConfig = {
|
|||||||
notifications_enabled: true,
|
notifications_enabled: true,
|
||||||
notification_volume: 0.7,
|
notification_volume: 0.7,
|
||||||
always_on_top: false,
|
always_on_top: false,
|
||||||
|
minimize_to_tray: false,
|
||||||
update_checks_enabled: true,
|
update_checks_enabled: true,
|
||||||
character_panel_width: null,
|
character_panel_width: null,
|
||||||
font_size: 14,
|
font_size: 14,
|
||||||
@@ -98,23 +71,6 @@ const defaultConfig: HikariConfig = {
|
|||||||
text_secondary: null,
|
text_secondary: null,
|
||||||
border_color: 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,
|
|
||||||
use_worktree: false,
|
|
||||||
disable_1m_context: false,
|
|
||||||
max_output_tokens: null,
|
|
||||||
trusted_workspaces: [],
|
|
||||||
background_image_path: null,
|
|
||||||
background_image_opacity: 0.3,
|
|
||||||
custom_font_path: null,
|
|
||||||
custom_font_family: null,
|
|
||||||
custom_ui_font_path: null,
|
|
||||||
custom_ui_font_family: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function createConfigStore() {
|
function createConfigStore() {
|
||||||
@@ -123,14 +79,6 @@ function createConfigStore() {
|
|||||||
const isSidebarOpen = writable<boolean>(false);
|
const isSidebarOpen = writable<boolean>(false);
|
||||||
const saveError = writable<string | null>(null);
|
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() {
|
async function loadConfig() {
|
||||||
isLoading.set(true);
|
isLoading.set(true);
|
||||||
try {
|
try {
|
||||||
@@ -158,7 +106,8 @@ function createConfigStore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateConfig(updates: Partial<HikariConfig>) {
|
async function updateConfig(updates: Partial<HikariConfig>) {
|
||||||
const currentConfig = getCurrentConfig();
|
let currentConfig: HikariConfig = defaultConfig;
|
||||||
|
config.subscribe((c) => (currentConfig = c))();
|
||||||
const newConfig = { ...currentConfig, ...updates };
|
const newConfig = { ...currentConfig, ...updates };
|
||||||
await saveConfig(newConfig);
|
await saveConfig(newConfig);
|
||||||
}
|
}
|
||||||
@@ -183,13 +132,15 @@ function createConfigStore() {
|
|||||||
updates.custom_theme_colors = customColors;
|
updates.custom_theme_colors = customColors;
|
||||||
}
|
}
|
||||||
await updateConfig(updates);
|
await updateConfig(updates);
|
||||||
const currentConfig = getCurrentConfig();
|
let currentConfig: HikariConfig = defaultConfig;
|
||||||
|
config.subscribe((c) => (currentConfig = c))();
|
||||||
applyTheme(theme, currentConfig.custom_theme_colors);
|
applyTheme(theme, currentConfig.custom_theme_colors);
|
||||||
},
|
},
|
||||||
|
|
||||||
setCustomThemeColors: async (colors: CustomThemeColors) => {
|
setCustomThemeColors: async (colors: CustomThemeColors) => {
|
||||||
await updateConfig({ custom_theme_colors: colors });
|
await updateConfig({ custom_theme_colors: colors });
|
||||||
const currentConfig = getCurrentConfig();
|
let currentConfig: HikariConfig = defaultConfig;
|
||||||
|
config.subscribe((c) => (currentConfig = c))();
|
||||||
if (currentConfig.theme === "custom") {
|
if (currentConfig.theme === "custom") {
|
||||||
applyCustomThemeColors(colors);
|
applyCustomThemeColors(colors);
|
||||||
}
|
}
|
||||||
@@ -202,14 +153,16 @@ function createConfigStore() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
increaseFontSize: async () => {
|
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);
|
const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2);
|
||||||
await updateConfig({ font_size: newSize });
|
await updateConfig({ font_size: newSize });
|
||||||
applyFontSize(newSize);
|
applyFontSize(newSize);
|
||||||
},
|
},
|
||||||
|
|
||||||
decreaseFontSize: async () => {
|
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);
|
const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2);
|
||||||
await updateConfig({ font_size: newSize });
|
await updateConfig({ font_size: newSize });
|
||||||
applyFontSize(newSize);
|
applyFontSize(newSize);
|
||||||
@@ -221,7 +174,8 @@ function createConfigStore() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
addAutoGrantedTool: async (tool: string) => {
|
addAutoGrantedTool: async (tool: string) => {
|
||||||
const currentConfig = getCurrentConfig();
|
let currentConfig: HikariConfig = defaultConfig;
|
||||||
|
config.subscribe((c) => (currentConfig = c))();
|
||||||
if (!currentConfig.auto_granted_tools.includes(tool)) {
|
if (!currentConfig.auto_granted_tools.includes(tool)) {
|
||||||
const newTools = [...currentConfig.auto_granted_tools, tool];
|
const newTools = [...currentConfig.auto_granted_tools, tool];
|
||||||
await updateConfig({ auto_granted_tools: newTools });
|
await updateConfig({ auto_granted_tools: newTools });
|
||||||
@@ -229,38 +183,33 @@ function createConfigStore() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
removeAutoGrantedTool: async (tool: string) => {
|
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);
|
const newTools = currentConfig.auto_granted_tools.filter((t) => t !== tool);
|
||||||
await updateConfig({ auto_granted_tools: newTools });
|
await updateConfig({ auto_granted_tools: newTools });
|
||||||
},
|
},
|
||||||
|
|
||||||
getConfig: (): HikariConfig => {
|
getConfig: (): HikariConfig => {
|
||||||
return getCurrentConfig();
|
let currentConfig: HikariConfig = defaultConfig;
|
||||||
|
config.subscribe((c) => (currentConfig = c))();
|
||||||
|
return currentConfig;
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleStreamerMode: async () => {
|
toggleStreamerMode: async () => {
|
||||||
const currentConfig = getCurrentConfig();
|
let currentConfig: HikariConfig = defaultConfig;
|
||||||
|
config.subscribe((c) => (currentConfig = c))();
|
||||||
await updateConfig({ streamer_mode: !currentConfig.streamer_mode });
|
await updateConfig({ streamer_mode: !currentConfig.streamer_mode });
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleCompactMode: async () => {
|
toggleCompactMode: async () => {
|
||||||
const currentConfig = getCurrentConfig();
|
let currentConfig: HikariConfig = defaultConfig;
|
||||||
|
config.subscribe((c) => (currentConfig = c))();
|
||||||
await updateConfig({ compact_mode: !currentConfig.compact_mode });
|
await updateConfig({ compact_mode: !currentConfig.compact_mode });
|
||||||
},
|
},
|
||||||
|
|
||||||
setCompactMode: async (enabled: boolean) => {
|
setCompactMode: async (enabled: boolean) => {
|
||||||
await updateConfig({ compact_mode: enabled });
|
await updateConfig({ compact_mode: enabled });
|
||||||
},
|
},
|
||||||
|
|
||||||
setCustomFont: async (path: string | null, family: string | null) => {
|
|
||||||
await updateConfig({ custom_font_path: path, custom_font_family: family });
|
|
||||||
await applyCustomFont(path, family);
|
|
||||||
},
|
|
||||||
|
|
||||||
setCustomUiFont: async (path: string | null, family: string | null) => {
|
|
||||||
await updateConfig({ custom_ui_font_path: path, custom_ui_font_family: family });
|
|
||||||
await applyCustomUiFont(path, family);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,99 +275,6 @@ export function clampFontSize(size: number): number {
|
|||||||
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
|
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
|
||||||
}
|
}
|
||||||
|
|
||||||
const DIRECT_FONT_EXTENSIONS = new Set(["woff", "woff2", "ttf", "otf", "eot"]);
|
|
||||||
|
|
||||||
const FONT_MIME_MAP: Record<string, string> = {
|
|
||||||
woff: "font/woff",
|
|
||||||
woff2: "font/woff2",
|
|
||||||
ttf: "font/ttf",
|
|
||||||
otf: "font/otf",
|
|
||||||
eot: "application/vnd.ms-fontobject",
|
|
||||||
};
|
|
||||||
|
|
||||||
async function applyFontFromSource(path: string, family: string, styleId: string): Promise<void> {
|
|
||||||
const style = document.createElement("style");
|
|
||||||
style.id = styleId;
|
|
||||||
|
|
||||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
|
||||||
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
|
||||||
|
|
||||||
if (DIRECT_FONT_EXTENSIONS.has(ext)) {
|
|
||||||
style.textContent = `@font-face { font-family: '${family}'; src: url('${path}'); }`;
|
|
||||||
} else {
|
|
||||||
style.textContent = `@import url('${path}');`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const data = await readFile(path);
|
|
||||||
const chunks: string[] = [];
|
|
||||||
const chunkSize = 8192;
|
|
||||||
for (let i = 0; i < data.length; i += chunkSize) {
|
|
||||||
chunks.push(String.fromCharCode(...data.slice(i, i + chunkSize)));
|
|
||||||
}
|
|
||||||
const ext = path.split(".").pop()?.toLowerCase() ?? "ttf";
|
|
||||||
const mime = FONT_MIME_MAP[ext] ?? "font/ttf";
|
|
||||||
const dataUrl = `data:${mime};base64,${btoa(chunks.join(""))}`;
|
|
||||||
style.textContent = `@font-face { font-family: '${family}'; src: url('${dataUrl}'); }`;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.head.appendChild(style);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function applyCustomFont(path: string | null, family: string | null): Promise<void> {
|
|
||||||
if (typeof document === "undefined") return;
|
|
||||||
|
|
||||||
const styleId = "hikari-custom-font";
|
|
||||||
const cssVar = "--terminal-font-family";
|
|
||||||
const fallbackFamily = "HikariCustomFont";
|
|
||||||
|
|
||||||
document.getElementById(styleId)?.remove();
|
|
||||||
|
|
||||||
const trimmedPath = path?.trim() ?? "";
|
|
||||||
const trimmedFamily = family?.trim() ?? "";
|
|
||||||
|
|
||||||
if (!trimmedPath && !trimmedFamily) {
|
|
||||||
document.documentElement.style.removeProperty(cssVar);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmedPath) {
|
|
||||||
await applyFontFromSource(trimmedPath, trimmedFamily || fallbackFamily, styleId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmedFamily) {
|
|
||||||
document.documentElement.style.setProperty(cssVar, `'${trimmedFamily}', monospace`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function applyCustomUiFont(path: string | null, family: string | null): Promise<void> {
|
|
||||||
if (typeof document === "undefined") return;
|
|
||||||
|
|
||||||
const styleId = "hikari-custom-ui-font";
|
|
||||||
const cssVar = "--ui-font-family";
|
|
||||||
const fallbackFamily = "HikariCustomUiFont";
|
|
||||||
|
|
||||||
document.getElementById(styleId)?.remove();
|
|
||||||
|
|
||||||
const trimmedPath = path?.trim() ?? "";
|
|
||||||
const trimmedFamily = family?.trim() ?? "";
|
|
||||||
|
|
||||||
if (!trimmedPath && !trimmedFamily) {
|
|
||||||
document.documentElement.style.removeProperty(cssVar);
|
|
||||||
document.body?.style.removeProperty("font-family");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const effectiveFamily = trimmedFamily || fallbackFamily;
|
|
||||||
|
|
||||||
if (trimmedPath) {
|
|
||||||
await applyFontFromSource(trimmedPath, effectiveFamily, styleId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fontValue = `'${effectiveFamily}', sans-serif`;
|
|
||||||
document.documentElement.style.setProperty(cssVar, fontValue);
|
|
||||||
document.body?.style.setProperty("font-family", fontValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
|
export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
|
||||||
|
|
||||||
export const configStore = createConfigStore();
|
export const configStore = createConfigStore();
|
||||||
@@ -431,10 +287,6 @@ export const shouldHidePaths = derived(
|
|||||||
configStore.config,
|
configStore.config,
|
||||||
($config) => $config.streamer_mode && $config.streamer_hide_paths
|
($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.
|
* Masks file paths in text when streamer mode with hide paths is enabled.
|
||||||
|
|||||||
@@ -523,41 +523,3 @@ describe("pending retry message", () => {
|
|||||||
expect(pendingRetryMessage).toBeNull();
|
expect(pendingRetryMessage).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("draft text persistence", () => {
|
|
||||||
it("initialises draft text as empty string", () => {
|
|
||||||
const conversation = { draftText: "" };
|
|
||||||
expect(conversation.draftText).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores draft text per conversation", () => {
|
|
||||||
const conversations = new Map([
|
|
||||||
["conv-1", { draftText: "Hello world" }],
|
|
||||||
["conv-2", { draftText: "" }],
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(conversations.get("conv-1")?.draftText).toBe("Hello world");
|
|
||||||
expect(conversations.get("conv-2")?.draftText).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates draft text independently per conversation", () => {
|
|
||||||
const conversations = new Map([
|
|
||||||
["conv-1", { draftText: "Draft A" }],
|
|
||||||
["conv-2", { draftText: "Draft B" }],
|
|
||||||
]);
|
|
||||||
|
|
||||||
const convA = conversations.get("conv-1");
|
|
||||||
if (convA) convA.draftText = "Updated A";
|
|
||||||
|
|
||||||
expect(conversations.get("conv-1")?.draftText).toBe("Updated A");
|
|
||||||
expect(conversations.get("conv-2")?.draftText).toBe("Draft B");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears draft text after submission", () => {
|
|
||||||
const conversation = { draftText: "My prompt" };
|
|
||||||
|
|
||||||
conversation.draftText = "";
|
|
||||||
|
|
||||||
expect(conversation.draftText).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
+13
-342
@@ -10,14 +10,6 @@ import type { CharacterState } from "$lib/types/states";
|
|||||||
import { cleanupConversationTracking } from "$lib/tauri";
|
import { cleanupConversationTracking } from "$lib/tauri";
|
||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
import { sessionsStore } from "$lib/stores/sessions";
|
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 {
|
export interface Conversation {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,100 +21,12 @@ export interface Conversation {
|
|||||||
characterState: CharacterState;
|
characterState: CharacterState;
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
grantedTools: Set<string>;
|
grantedTools: Set<string>;
|
||||||
pendingPermissions: PermissionRequest[];
|
pendingPermission: PermissionRequest | null;
|
||||||
pendingQuestion: UserQuestionEvent | null;
|
pendingQuestion: UserQuestionEvent | null;
|
||||||
scrollPosition: number;
|
scrollPosition: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
lastActivityAt: Date;
|
lastActivityAt: Date;
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
summary: ConversationSummary | null;
|
|
||||||
startedAt: Date;
|
|
||||||
taskStartTime: number | null;
|
|
||||||
successSoundFired: boolean;
|
|
||||||
taskStartSoundFired: boolean;
|
|
||||||
draftText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TAB_NAMES = [
|
|
||||||
// Cosmic & celestial
|
|
||||||
"Starfall",
|
|
||||||
"Moonbeam",
|
|
||||||
"Nebula",
|
|
||||||
"Aurora",
|
|
||||||
"Stardust",
|
|
||||||
"Solstice",
|
|
||||||
"Comet",
|
|
||||||
"Eclipse",
|
|
||||||
"Zenith",
|
|
||||||
"Celestia",
|
|
||||||
"Nova",
|
|
||||||
"Quasar",
|
|
||||||
"Lyra",
|
|
||||||
"Andromeda",
|
|
||||||
"Twilight",
|
|
||||||
// Magical & fantastical
|
|
||||||
"Camelot",
|
|
||||||
"Reverie",
|
|
||||||
"Arcane",
|
|
||||||
"Spellbound",
|
|
||||||
"Mirage",
|
|
||||||
"Oracle",
|
|
||||||
"Seraphim",
|
|
||||||
"Ethereal",
|
|
||||||
"Labyrinth",
|
|
||||||
"Enchantment",
|
|
||||||
// Nature & cosy
|
|
||||||
"Sakura",
|
|
||||||
"Ember",
|
|
||||||
"Cascade",
|
|
||||||
"Zephyr",
|
|
||||||
"Serendipity",
|
|
||||||
"Solace",
|
|
||||||
"Blossom",
|
|
||||||
"Whisper",
|
|
||||||
"Dewdrop",
|
|
||||||
"Sunbeam",
|
|
||||||
"Willow",
|
|
||||||
"Clover",
|
|
||||||
"Honeybee",
|
|
||||||
"Buttercup",
|
|
||||||
"Dandelion",
|
|
||||||
// Japanese/anime-inspired
|
|
||||||
"Tsukimi",
|
|
||||||
"Hanami",
|
|
||||||
"Yozora",
|
|
||||||
"Hoshi",
|
|
||||||
"Koharu",
|
|
||||||
"Akari",
|
|
||||||
"Midori",
|
|
||||||
// Adventure & epic
|
|
||||||
"Odyssey",
|
|
||||||
"Wanderer",
|
|
||||||
"Horizon",
|
|
||||||
"Voyage",
|
|
||||||
"Pathfinder",
|
|
||||||
"Frontier",
|
|
||||||
// Whimsical & sweet
|
|
||||||
"Bubblegum",
|
|
||||||
"Marshmallow",
|
|
||||||
"Daydream",
|
|
||||||
"Whimsy",
|
|
||||||
"Jellybean",
|
|
||||||
"Sprinkle",
|
|
||||||
"Cupcake",
|
|
||||||
// Dreamy & poetic
|
|
||||||
"Revenant",
|
|
||||||
"Elysium",
|
|
||||||
"Halcyon",
|
|
||||||
"Ephemera",
|
|
||||||
"Serenade",
|
|
||||||
"Lullaby",
|
|
||||||
"Nocturne",
|
|
||||||
"Rhapsody",
|
|
||||||
];
|
|
||||||
|
|
||||||
function pickRandomTabName(): string {
|
|
||||||
return TAB_NAMES[Math.floor(Math.random() * TAB_NAMES.length)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createConversationsStore() {
|
function createConversationsStore() {
|
||||||
@@ -145,7 +49,7 @@ function createConversationsStore() {
|
|||||||
const id = generateConversationId();
|
const id = generateConversationId();
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: name ?? pickRandomTabName(),
|
name: name || `Conversation ${conversationCounter}`,
|
||||||
terminalLines: [],
|
terminalLines: [],
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
connectionStatus: "disconnected",
|
connectionStatus: "disconnected",
|
||||||
@@ -153,18 +57,12 @@ function createConversationsStore() {
|
|||||||
characterState: "idle",
|
characterState: "idle",
|
||||||
isProcessing: false,
|
isProcessing: false,
|
||||||
grantedTools: new Set(),
|
grantedTools: new Set(),
|
||||||
pendingPermissions: [],
|
pendingPermission: null,
|
||||||
pendingQuestion: null,
|
pendingQuestion: null,
|
||||||
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
|
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
lastActivityAt: new Date(),
|
lastActivityAt: new Date(),
|
||||||
attachments: [],
|
attachments: [],
|
||||||
summary: null,
|
|
||||||
startedAt: new Date(),
|
|
||||||
taskStartTime: null,
|
|
||||||
successSoundFired: false,
|
|
||||||
taskStartSoundFired: false,
|
|
||||||
draftText: "",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +71,7 @@ function createConversationsStore() {
|
|||||||
function ensureInitialized() {
|
function ensureInitialized() {
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
initialized = true;
|
initialized = true;
|
||||||
const initialConversation = createNewConversation();
|
const initialConversation = createNewConversation("Main");
|
||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
convs.set(initialConversation.id, initialConversation);
|
convs.set(initialConversation.id, initialConversation);
|
||||||
return convs;
|
return convs;
|
||||||
@@ -211,11 +109,7 @@ function createConversationsStore() {
|
|||||||
);
|
);
|
||||||
const pendingPermission = derived(
|
const pendingPermission = derived(
|
||||||
activeConversation,
|
activeConversation,
|
||||||
($conv) => $conv?.pendingPermissions[0] || null
|
($conv) => $conv?.pendingPermission || null
|
||||||
);
|
|
||||||
const pendingPermissions = derived(
|
|
||||||
activeConversation,
|
|
||||||
($conv) => $conv?.pendingPermissions || []
|
|
||||||
);
|
);
|
||||||
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
|
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
|
||||||
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
|
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
|
||||||
@@ -228,7 +122,6 @@ function createConversationsStore() {
|
|||||||
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
|
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
|
||||||
terminalLines: { subscribe: terminalLines.subscribe },
|
terminalLines: { subscribe: terminalLines.subscribe },
|
||||||
pendingPermission: { subscribe: pendingPermission.subscribe },
|
pendingPermission: { subscribe: pendingPermission.subscribe },
|
||||||
pendingPermissions: { subscribe: pendingPermissions.subscribe },
|
|
||||||
pendingQuestion: { subscribe: pendingQuestion.subscribe },
|
pendingQuestion: { subscribe: pendingQuestion.subscribe },
|
||||||
isProcessing: { subscribe: isProcessing.subscribe },
|
isProcessing: { subscribe: isProcessing.subscribe },
|
||||||
grantedTools: { subscribe: grantedTools.subscribe },
|
grantedTools: { subscribe: grantedTools.subscribe },
|
||||||
@@ -286,7 +179,7 @@ function createConversationsStore() {
|
|||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
const conv = convs.get(activeId);
|
const conv = convs.get(activeId);
|
||||||
if (conv) {
|
if (conv) {
|
||||||
conv.pendingPermissions = [...conv.pendingPermissions, request];
|
conv.pendingPermission = request;
|
||||||
conv.lastActivityAt = new Date();
|
conv.lastActivityAt = new Date();
|
||||||
}
|
}
|
||||||
return convs;
|
return convs;
|
||||||
@@ -299,7 +192,7 @@ function createConversationsStore() {
|
|||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
const conv = convs.get(activeId);
|
const conv = convs.get(activeId);
|
||||||
if (conv) {
|
if (conv) {
|
||||||
conv.pendingPermissions = [];
|
conv.pendingPermission = null;
|
||||||
conv.lastActivityAt = new Date();
|
conv.lastActivityAt = new Date();
|
||||||
}
|
}
|
||||||
return convs;
|
return convs;
|
||||||
@@ -309,7 +202,7 @@ function createConversationsStore() {
|
|||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
const conv = convs.get(conversationId);
|
const conv = convs.get(conversationId);
|
||||||
if (conv) {
|
if (conv) {
|
||||||
conv.pendingPermissions = [...conv.pendingPermissions, request];
|
conv.pendingPermission = request;
|
||||||
conv.lastActivityAt = new Date();
|
conv.lastActivityAt = new Date();
|
||||||
}
|
}
|
||||||
return convs;
|
return convs;
|
||||||
@@ -319,30 +212,7 @@ function createConversationsStore() {
|
|||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
const conv = convs.get(conversationId);
|
const conv = convs.get(conversationId);
|
||||||
if (conv) {
|
if (conv) {
|
||||||
conv.pendingPermissions = [];
|
conv.pendingPermission = null;
|
||||||
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.lastActivityAt = new Date();
|
conv.lastActivityAt = new Date();
|
||||||
}
|
}
|
||||||
return convs;
|
return convs;
|
||||||
@@ -424,10 +294,6 @@ function createConversationsStore() {
|
|||||||
// Clean up tracking for this conversation (including temp files)
|
// Clean up tracking for this conversation (including temp files)
|
||||||
await cleanupConversationTracking(id);
|
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) => {
|
conversations.update((c) => {
|
||||||
c.delete(id);
|
c.delete(id);
|
||||||
return c;
|
return c;
|
||||||
@@ -454,15 +320,9 @@ function createConversationsStore() {
|
|||||||
if (currentId !== id) {
|
if (currentId !== id) {
|
||||||
activeConversationId.set(id);
|
activeConversationId.set(id);
|
||||||
|
|
||||||
// Update the global character state to match the conversation's state.
|
// Update the global character state to match the conversation's state
|
||||||
// Map success/error → idle since those are transient states that have
|
|
||||||
// already been displayed — restoring them would re-trigger sound rules.
|
|
||||||
if (targetConv) {
|
if (targetConv) {
|
||||||
const stateToRestore =
|
characterState.setState(targetConv.characterState);
|
||||||
targetConv.characterState === "success" || targetConv.characterState === "error"
|
|
||||||
? "idle"
|
|
||||||
: targetConv.characterState;
|
|
||||||
characterState.setState(stateToRestore);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -560,13 +420,7 @@ function createConversationsStore() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
addLine: (
|
addLine: (type: TerminalLine["type"], content: string, toolName?: string) => {
|
||||||
type: TerminalLine["type"],
|
|
||||||
content: string,
|
|
||||||
toolName?: string,
|
|
||||||
cost?: TerminalLine["cost"],
|
|
||||||
parentToolUseId?: string
|
|
||||||
) => {
|
|
||||||
ensureInitialized();
|
ensureInitialized();
|
||||||
const activeId = get(activeConversationId);
|
const activeId = get(activeConversationId);
|
||||||
if (!activeId) return "";
|
if (!activeId) return "";
|
||||||
@@ -577,8 +431,6 @@ function createConversationsStore() {
|
|||||||
content,
|
content,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
toolName,
|
toolName,
|
||||||
cost,
|
|
||||||
parentToolUseId,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
@@ -599,9 +451,7 @@ function createConversationsStore() {
|
|||||||
conversationId: string,
|
conversationId: string,
|
||||||
type: TerminalLine["type"],
|
type: TerminalLine["type"],
|
||||||
content: string,
|
content: string,
|
||||||
toolName?: string,
|
toolName?: string
|
||||||
cost?: TerminalLine["cost"],
|
|
||||||
parentToolUseId?: string
|
|
||||||
) => {
|
) => {
|
||||||
ensureInitialized();
|
ensureInitialized();
|
||||||
|
|
||||||
@@ -611,8 +461,6 @@ function createConversationsStore() {
|
|||||||
content,
|
content,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
toolName,
|
toolName,
|
||||||
cost,
|
|
||||||
parentToolUseId,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
@@ -788,183 +636,6 @@ function createConversationsStore() {
|
|||||||
return conv?.attachments || [];
|
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;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Sound tracking methods
|
|
||||||
resetSoundState: (conversationId: string) => {
|
|
||||||
conversations.update((convs) => {
|
|
||||||
const conv = convs.get(conversationId);
|
|
||||||
if (conv) {
|
|
||||||
conv.taskStartTime = null;
|
|
||||||
conv.successSoundFired = false;
|
|
||||||
conv.taskStartSoundFired = false;
|
|
||||||
}
|
|
||||||
return convs;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
setTaskStartTime: (conversationId: string, time: number | null) => {
|
|
||||||
conversations.update((convs) => {
|
|
||||||
const conv = convs.get(conversationId);
|
|
||||||
if (conv) {
|
|
||||||
conv.taskStartTime = time;
|
|
||||||
}
|
|
||||||
return convs;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
markSuccessSoundFired: (conversationId: string) => {
|
|
||||||
conversations.update((convs) => {
|
|
||||||
const conv = convs.get(conversationId);
|
|
||||||
if (conv) {
|
|
||||||
conv.successSoundFired = true;
|
|
||||||
}
|
|
||||||
return convs;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
markTaskStartSoundFired: (conversationId: string) => {
|
|
||||||
conversations.update((convs) => {
|
|
||||||
const conv = convs.get(conversationId);
|
|
||||||
if (conv) {
|
|
||||||
conv.taskStartSoundFired = true;
|
|
||||||
}
|
|
||||||
return convs;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
setDraftText: (conversationId: string, text: string) => {
|
|
||||||
conversations.update((convs) => {
|
|
||||||
const conv = convs.get(conversationId);
|
|
||||||
if (conv) {
|
|
||||||
conv.draftText = text;
|
|
||||||
}
|
|
||||||
return convs;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Add initialization helper
|
// Add initialization helper
|
||||||
initialize: () => {
|
initialize: () => {
|
||||||
ensureInitialized();
|
ensureInitialized();
|
||||||
|
|||||||
@@ -1,292 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { get } from "svelte/store";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { setMockInvokeResult } from "../../../vitest.setup";
|
|
||||||
import {
|
|
||||||
formatCost,
|
|
||||||
formatAlertType,
|
|
||||||
getAlertMessage,
|
|
||||||
costTrackingStore,
|
|
||||||
formattedCosts,
|
|
||||||
type AlertType,
|
|
||||||
type CostAlert,
|
|
||||||
} from "./costTracking";
|
|
||||||
|
|
||||||
vi.mock("$lib/notifications/notificationManager", () => ({
|
|
||||||
notificationManager: {
|
|
||||||
notifyCostAlert: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("formatCost", () => {
|
|
||||||
it("formats amounts below $0.01 to 4 decimal places", () => {
|
|
||||||
expect(formatCost(0)).toBe("$0.0000");
|
|
||||||
expect(formatCost(0.001)).toBe("$0.0010");
|
|
||||||
expect(formatCost(0.0099)).toBe("$0.0099");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats amounts between $0.01 and $1 to 3 decimal places", () => {
|
|
||||||
expect(formatCost(0.01)).toBe("$0.010");
|
|
||||||
expect(formatCost(0.123)).toBe("$0.123");
|
|
||||||
expect(formatCost(0.999)).toBe("$0.999");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats amounts $1 and above to 2 decimal places", () => {
|
|
||||||
expect(formatCost(1)).toBe("$1.00");
|
|
||||||
expect(formatCost(1.5)).toBe("$1.50");
|
|
||||||
expect(formatCost(100.99)).toBe("$100.99");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("formatAlertType", () => {
|
|
||||||
it("formats Daily as Today", () => {
|
|
||||||
expect(formatAlertType("Daily")).toBe("Today");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats Weekly as This Week", () => {
|
|
||||||
expect(formatAlertType("Weekly")).toBe("This Week");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats Monthly as This Month", () => {
|
|
||||||
expect(formatAlertType("Monthly")).toBe("This Month");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles all AlertType values", () => {
|
|
||||||
const types: AlertType[] = ["Daily", "Weekly", "Monthly"];
|
|
||||||
const results = types.map(formatAlertType);
|
|
||||||
expect(results).toEqual(["Today", "This Week", "This Month"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getAlertMessage", () => {
|
|
||||||
it("generates a message for a Daily alert", () => {
|
|
||||||
const alert: CostAlert = {
|
|
||||||
alert_type: "Daily",
|
|
||||||
threshold: 1.0,
|
|
||||||
current_cost: 1.5,
|
|
||||||
};
|
|
||||||
const message = getAlertMessage(alert);
|
|
||||||
expect(message).toContain("Today");
|
|
||||||
expect(message).toContain("$1.50");
|
|
||||||
expect(message).toContain("$1.00");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("generates a message for a Weekly alert", () => {
|
|
||||||
const alert: CostAlert = {
|
|
||||||
alert_type: "Weekly",
|
|
||||||
threshold: 5.0,
|
|
||||||
current_cost: 6.0,
|
|
||||||
};
|
|
||||||
const message = getAlertMessage(alert);
|
|
||||||
expect(message).toContain("This Week");
|
|
||||||
expect(message).toContain("$6.00");
|
|
||||||
expect(message).toContain("$5.00");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("generates a message for a Monthly alert", () => {
|
|
||||||
const alert: CostAlert = {
|
|
||||||
alert_type: "Monthly",
|
|
||||||
threshold: 20.0,
|
|
||||||
current_cost: 25.0,
|
|
||||||
};
|
|
||||||
const message = getAlertMessage(alert);
|
|
||||||
expect(message).toContain("This Month");
|
|
||||||
expect(message).toContain("$25.00");
|
|
||||||
expect(message).toContain("$20.00");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes threshold and current cost in the message", () => {
|
|
||||||
const alert: CostAlert = {
|
|
||||||
alert_type: "Daily",
|
|
||||||
threshold: 0.005,
|
|
||||||
current_cost: 0.007,
|
|
||||||
};
|
|
||||||
const message = getAlertMessage(alert);
|
|
||||||
expect(message).toContain("$0.0070");
|
|
||||||
expect(message).toContain("$0.0050");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("costTrackingStore", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
costTrackingStore.reset();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("refresh", () => {
|
|
||||||
it("loads cost data from the backend", async () => {
|
|
||||||
setMockInvokeResult("get_today_cost", 1.5);
|
|
||||||
setMockInvokeResult("get_week_cost", 5.25);
|
|
||||||
setMockInvokeResult("get_month_cost", 20.0);
|
|
||||||
setMockInvokeResult("get_cost_alerts", []);
|
|
||||||
|
|
||||||
await costTrackingStore.refresh();
|
|
||||||
|
|
||||||
const state = get(costTrackingStore);
|
|
||||||
expect(state.todayCost).toBe(1.5);
|
|
||||||
expect(state.weekCost).toBe(5.25);
|
|
||||||
expect(state.monthCost).toBe(20.0);
|
|
||||||
expect(state.isLoading).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("triggers notifications for any returned alerts", async () => {
|
|
||||||
const { notificationManager } = await import("$lib/notifications/notificationManager");
|
|
||||||
const alert: CostAlert = { alert_type: "Daily", threshold: 1.0, current_cost: 1.5 };
|
|
||||||
setMockInvokeResult("get_today_cost", 1.5);
|
|
||||||
setMockInvokeResult("get_week_cost", 0);
|
|
||||||
setMockInvokeResult("get_month_cost", 0);
|
|
||||||
setMockInvokeResult("get_cost_alerts", [alert]);
|
|
||||||
|
|
||||||
await costTrackingStore.refresh();
|
|
||||||
|
|
||||||
expect(notificationManager.notifyCostAlert).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("Today")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns alerts from refresh", async () => {
|
|
||||||
const alert: CostAlert = { alert_type: "Weekly", threshold: 5.0, current_cost: 6.0 };
|
|
||||||
setMockInvokeResult("get_today_cost", 0);
|
|
||||||
setMockInvokeResult("get_week_cost", 6.0);
|
|
||||||
setMockInvokeResult("get_month_cost", 0);
|
|
||||||
setMockInvokeResult("get_cost_alerts", [alert]);
|
|
||||||
|
|
||||||
const result = await costTrackingStore.refresh();
|
|
||||||
|
|
||||||
expect(result).toEqual([alert]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles refresh errors gracefully and returns empty array", async () => {
|
|
||||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("get_today_cost", new Error("Backend error"));
|
|
||||||
|
|
||||||
const result = await costTrackingStore.refresh();
|
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
expect(errorSpy).toHaveBeenCalledWith("Failed to refresh cost tracking:", expect.any(Error));
|
|
||||||
errorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getSummary", () => {
|
|
||||||
it("fetches and stores a cost summary", async () => {
|
|
||||||
const mockSummary = {
|
|
||||||
period_days: 7,
|
|
||||||
total_input_tokens: 1000,
|
|
||||||
total_output_tokens: 500,
|
|
||||||
total_cost: 0.05,
|
|
||||||
total_messages: 20,
|
|
||||||
total_sessions: 3,
|
|
||||||
average_daily_cost: 0.007,
|
|
||||||
daily_breakdown: [],
|
|
||||||
};
|
|
||||||
setMockInvokeResult("get_cost_summary", mockSummary);
|
|
||||||
|
|
||||||
const result = await costTrackingStore.getSummary(7);
|
|
||||||
|
|
||||||
expect(result).toEqual(mockSummary);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("get_cost_summary", { days: 7 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null and logs error on failure", async () => {
|
|
||||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("get_cost_summary", new Error("Summary unavailable"));
|
|
||||||
|
|
||||||
const result = await costTrackingStore.getSummary(30);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(errorSpy).toHaveBeenCalledWith("Failed to get cost summary:", expect.any(Error));
|
|
||||||
errorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setAlertThresholds", () => {
|
|
||||||
it("persists new thresholds to the backend", async () => {
|
|
||||||
const thresholds = { daily: 2.0, weekly: 10.0, monthly: 40.0 };
|
|
||||||
|
|
||||||
await costTrackingStore.setAlertThresholds(thresholds);
|
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith("set_cost_alert_thresholds", {
|
|
||||||
daily: 2.0,
|
|
||||||
weekly: 10.0,
|
|
||||||
monthly: 40.0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logs an error when the backend call fails", async () => {
|
|
||||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("set_cost_alert_thresholds", new Error("Failed"));
|
|
||||||
|
|
||||||
await costTrackingStore.setAlertThresholds({ daily: 1.0, weekly: null, monthly: null });
|
|
||||||
|
|
||||||
expect(errorSpy).toHaveBeenCalledWith("Failed to set alert thresholds:", expect.any(Error));
|
|
||||||
errorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("exportCsv", () => {
|
|
||||||
it("returns the CSV string from the backend", async () => {
|
|
||||||
setMockInvokeResult("export_cost_csv", "date,cost\n2026-01-01,1.50");
|
|
||||||
|
|
||||||
const result = await costTrackingStore.exportCsv(7);
|
|
||||||
|
|
||||||
expect(result).toBe("date,cost\n2026-01-01,1.50");
|
|
||||||
expect(invoke).toHaveBeenCalledWith("export_cost_csv", { days: 7 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null and logs error on failure", async () => {
|
|
||||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("export_cost_csv", new Error("Export failed"));
|
|
||||||
|
|
||||||
const result = await costTrackingStore.exportCsv(30);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(errorSpy).toHaveBeenCalledWith("Failed to export CSV:", expect.any(Error));
|
|
||||||
errorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("reset", () => {
|
|
||||||
it("resets costs back to zero", async () => {
|
|
||||||
setMockInvokeResult("get_today_cost", 5.0);
|
|
||||||
setMockInvokeResult("get_week_cost", 15.0);
|
|
||||||
setMockInvokeResult("get_month_cost", 50.0);
|
|
||||||
setMockInvokeResult("get_cost_alerts", []);
|
|
||||||
await costTrackingStore.refresh();
|
|
||||||
|
|
||||||
costTrackingStore.reset();
|
|
||||||
|
|
||||||
const state = get(costTrackingStore);
|
|
||||||
expect(state.todayCost).toBe(0);
|
|
||||||
expect(state.weekCost).toBe(0);
|
|
||||||
expect(state.monthCost).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("formattedCosts", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
costTrackingStore.reset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats the initial zero costs correctly", () => {
|
|
||||||
const costs = get(formattedCosts);
|
|
||||||
expect(costs.today).toBe("$0.0000");
|
|
||||||
expect(costs.week).toBe("$0.0000");
|
|
||||||
expect(costs.month).toBe("$0.0000");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reflects updated costs after a refresh", async () => {
|
|
||||||
setMockInvokeResult("get_today_cost", 1.5);
|
|
||||||
setMockInvokeResult("get_week_cost", 5.0);
|
|
||||||
setMockInvokeResult("get_month_cost", 20.0);
|
|
||||||
setMockInvokeResult("get_cost_alerts", []);
|
|
||||||
await costTrackingStore.refresh();
|
|
||||||
|
|
||||||
const costs = get(formattedCosts);
|
|
||||||
expect(costs.today).toBe("$1.50");
|
|
||||||
expect(costs.week).toBe("$5.00");
|
|
||||||
expect(costs.month).toBe("$20.00");
|
|
||||||
expect(costs.todayRaw).toBe(1.5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,282 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
||||||
import { get } from "svelte/store";
|
|
||||||
import { emitMockEvent } from "../../../vitest.setup";
|
|
||||||
import { debugConsoleStore, filteredLogs } from "./debugConsole";
|
|
||||||
|
|
||||||
describe("debugConsoleStore", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
debugConsoleStore.clear();
|
|
||||||
debugConsoleStore.close();
|
|
||||||
debugConsoleStore.setFilterLevel("all");
|
|
||||||
debugConsoleStore.setAutoScroll(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
debugConsoleStore.restoreConsole();
|
|
||||||
debugConsoleStore.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("initializes with correct default state", () => {
|
|
||||||
const state = get(debugConsoleStore);
|
|
||||||
expect(state.logs).toEqual([]);
|
|
||||||
expect(state.isOpen).toBe(false);
|
|
||||||
expect(state.maxLogs).toBe(1000);
|
|
||||||
expect(state.filterLevel).toBe("all");
|
|
||||||
expect(state.autoScroll).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("toggle", () => {
|
|
||||||
it("opens when currently closed", () => {
|
|
||||||
debugConsoleStore.toggle();
|
|
||||||
expect(get(debugConsoleStore).isOpen).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes when currently open", () => {
|
|
||||||
debugConsoleStore.open();
|
|
||||||
debugConsoleStore.toggle();
|
|
||||||
expect(get(debugConsoleStore).isOpen).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("open", () => {
|
|
||||||
it("sets isOpen to true", () => {
|
|
||||||
debugConsoleStore.open();
|
|
||||||
expect(get(debugConsoleStore).isOpen).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("close", () => {
|
|
||||||
it("sets isOpen to false", () => {
|
|
||||||
debugConsoleStore.open();
|
|
||||||
debugConsoleStore.close();
|
|
||||||
expect(get(debugConsoleStore).isOpen).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("clear", () => {
|
|
||||||
it("removes all log entries", async () => {
|
|
||||||
await debugConsoleStore.setupBackendLogsListener();
|
|
||||||
emitMockEvent("debug:log", { level: "info", message: "test entry" });
|
|
||||||
expect(get(debugConsoleStore).logs.length).toBe(1);
|
|
||||||
debugConsoleStore.clear();
|
|
||||||
expect(get(debugConsoleStore).logs).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setFilterLevel", () => {
|
|
||||||
it("updates filterLevel to the specified level", () => {
|
|
||||||
debugConsoleStore.setFilterLevel("error");
|
|
||||||
expect(get(debugConsoleStore).filterLevel).toBe("error");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can reset filterLevel back to all", () => {
|
|
||||||
debugConsoleStore.setFilterLevel("warn");
|
|
||||||
debugConsoleStore.setFilterLevel("all");
|
|
||||||
expect(get(debugConsoleStore).filterLevel).toBe("all");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setAutoScroll", () => {
|
|
||||||
it("disables autoScroll", () => {
|
|
||||||
debugConsoleStore.setAutoScroll(false);
|
|
||||||
expect(get(debugConsoleStore).autoScroll).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-enables autoScroll", () => {
|
|
||||||
debugConsoleStore.setAutoScroll(false);
|
|
||||||
debugConsoleStore.setAutoScroll(true);
|
|
||||||
expect(get(debugConsoleStore).autoScroll).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setupConsoleCapture", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
debugConsoleStore.restoreConsole();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("captures console.log calls as info-level frontend logs", () => {
|
|
||||||
debugConsoleStore.setupConsoleCapture();
|
|
||||||
console.log("hello world");
|
|
||||||
const logs = get(debugConsoleStore).logs;
|
|
||||||
const captured = logs.find((l) => l.message === "hello world");
|
|
||||||
expect(captured).toBeDefined();
|
|
||||||
expect(captured?.level).toBe("info");
|
|
||||||
expect(captured?.source).toBe("frontend");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("captures console.info calls as info-level logs", () => {
|
|
||||||
debugConsoleStore.setupConsoleCapture();
|
|
||||||
console.info("info message");
|
|
||||||
const logs = get(debugConsoleStore).logs;
|
|
||||||
const captured = logs.find((l) => l.message === "info message");
|
|
||||||
expect(captured?.level).toBe("info");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("captures console.warn calls as warn-level logs", () => {
|
|
||||||
debugConsoleStore.setupConsoleCapture();
|
|
||||||
console.warn("warning message");
|
|
||||||
const logs = get(debugConsoleStore).logs;
|
|
||||||
const captured = logs.find((l) => l.message === "warning message");
|
|
||||||
expect(captured?.level).toBe("warn");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("captures console.error calls as error-level logs", () => {
|
|
||||||
debugConsoleStore.setupConsoleCapture();
|
|
||||||
console.error("error message");
|
|
||||||
const logs = get(debugConsoleStore).logs;
|
|
||||||
const captured = logs.find((l) => l.message === "error message");
|
|
||||||
expect(captured?.level).toBe("error");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("captures console.debug calls as debug-level logs", () => {
|
|
||||||
debugConsoleStore.setupConsoleCapture();
|
|
||||||
console.debug("debug message");
|
|
||||||
const logs = get(debugConsoleStore).logs;
|
|
||||||
const captured = logs.find((l) => l.message === "debug message");
|
|
||||||
expect(captured?.level).toBe("debug");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("joins multiple console arguments with spaces", () => {
|
|
||||||
debugConsoleStore.setupConsoleCapture();
|
|
||||||
console.log("hello", "world", 42);
|
|
||||||
const logs = get(debugConsoleStore).logs;
|
|
||||||
const captured = logs.find((l) => l.message === "hello world 42");
|
|
||||||
expect(captured).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("captures unhandled window error events", () => {
|
|
||||||
debugConsoleStore.setupConsoleCapture();
|
|
||||||
const errorEvent = new ErrorEvent("error", {
|
|
||||||
message: "Test unhandled error",
|
|
||||||
filename: "test.js",
|
|
||||||
lineno: 10,
|
|
||||||
colno: 5,
|
|
||||||
});
|
|
||||||
window.dispatchEvent(errorEvent);
|
|
||||||
const logs = get(debugConsoleStore).logs;
|
|
||||||
const captured = logs.find(
|
|
||||||
(l) => l.level === "error" && l.message.includes("[Unhandled Error]")
|
|
||||||
);
|
|
||||||
expect(captured).toBeDefined();
|
|
||||||
expect(captured?.message).toContain("Test unhandled error");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("captures unhandled promise rejection events", () => {
|
|
||||||
debugConsoleStore.setupConsoleCapture();
|
|
||||||
const rejectionEvent = Object.assign(new Event("unhandledrejection"), {
|
|
||||||
reason: "test rejection reason",
|
|
||||||
});
|
|
||||||
window.dispatchEvent(rejectionEvent);
|
|
||||||
const logs = get(debugConsoleStore).logs;
|
|
||||||
const captured = logs.find(
|
|
||||||
(l) => l.level === "error" && l.message.includes("[Unhandled Promise Rejection]")
|
|
||||||
);
|
|
||||||
expect(captured).toBeDefined();
|
|
||||||
expect(captured?.message).toContain("test rejection reason");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("restoreConsole", () => {
|
|
||||||
it("stops capturing console output after restore", () => {
|
|
||||||
debugConsoleStore.setupConsoleCapture();
|
|
||||||
debugConsoleStore.restoreConsole();
|
|
||||||
const countBefore = get(debugConsoleStore).logs.length;
|
|
||||||
console.log("this should not be captured");
|
|
||||||
expect(get(debugConsoleStore).logs.length).toBe(countBefore);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setupBackendLogsListener", () => {
|
|
||||||
it("captures backend logs emitted via debug:log event", async () => {
|
|
||||||
await debugConsoleStore.setupBackendLogsListener();
|
|
||||||
emitMockEvent("debug:log", { level: "info", message: "backend message" });
|
|
||||||
const logs = get(debugConsoleStore).logs;
|
|
||||||
expect(logs.length).toBe(1);
|
|
||||||
expect(logs[0].level).toBe("info");
|
|
||||||
expect(logs[0].message).toBe("backend message");
|
|
||||||
expect(logs[0].source).toBe("backend");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles different log levels from backend", async () => {
|
|
||||||
await debugConsoleStore.setupBackendLogsListener();
|
|
||||||
emitMockEvent("debug:log", { level: "error", message: "backend error" });
|
|
||||||
const logs = get(debugConsoleStore).logs;
|
|
||||||
expect(logs[0].level).toBe("error");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("circular buffer", () => {
|
|
||||||
it("drops oldest log when exceeding 1000-entry limit", async () => {
|
|
||||||
await debugConsoleStore.setupBackendLogsListener();
|
|
||||||
for (let i = 0; i < 1001; i++) {
|
|
||||||
emitMockEvent("debug:log", { level: "info", message: `log ${i}` });
|
|
||||||
}
|
|
||||||
const logs = get(debugConsoleStore).logs;
|
|
||||||
expect(logs.length).toBe(1000);
|
|
||||||
expect(logs[0].message).toBe("log 1");
|
|
||||||
expect(logs[999].message).toBe("log 1000");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("filteredLogs derived store", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
debugConsoleStore.clear();
|
|
||||||
debugConsoleStore.setFilterLevel("all");
|
|
||||||
await debugConsoleStore.setupBackendLogsListener();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
debugConsoleStore.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns all logs when filterLevel is all", () => {
|
|
||||||
emitMockEvent("debug:log", { level: "debug", message: "d" });
|
|
||||||
emitMockEvent("debug:log", { level: "info", message: "i" });
|
|
||||||
emitMockEvent("debug:log", { level: "warn", message: "w" });
|
|
||||||
emitMockEvent("debug:log", { level: "error", message: "e" });
|
|
||||||
expect(get(filteredLogs).length).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns only error logs when filterLevel is error", () => {
|
|
||||||
emitMockEvent("debug:log", { level: "debug", message: "d" });
|
|
||||||
emitMockEvent("debug:log", { level: "info", message: "i" });
|
|
||||||
emitMockEvent("debug:log", { level: "warn", message: "w" });
|
|
||||||
emitMockEvent("debug:log", { level: "error", message: "e" });
|
|
||||||
debugConsoleStore.setFilterLevel("error");
|
|
||||||
const logs = get(filteredLogs);
|
|
||||||
expect(logs.length).toBe(1);
|
|
||||||
expect(logs[0].level).toBe("error");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns warn and error logs when filterLevel is warn", () => {
|
|
||||||
emitMockEvent("debug:log", { level: "debug", message: "d" });
|
|
||||||
emitMockEvent("debug:log", { level: "info", message: "i" });
|
|
||||||
emitMockEvent("debug:log", { level: "warn", message: "w" });
|
|
||||||
emitMockEvent("debug:log", { level: "error", message: "e" });
|
|
||||||
debugConsoleStore.setFilterLevel("warn");
|
|
||||||
const logs = get(filteredLogs);
|
|
||||||
expect(logs.length).toBe(2);
|
|
||||||
expect(logs.every((l) => l.level === "warn" || l.level === "error")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes debug logs when filterLevel is info", () => {
|
|
||||||
emitMockEvent("debug:log", { level: "debug", message: "d" });
|
|
||||||
emitMockEvent("debug:log", { level: "info", message: "i" });
|
|
||||||
emitMockEvent("debug:log", { level: "warn", message: "w" });
|
|
||||||
emitMockEvent("debug:log", { level: "error", message: "e" });
|
|
||||||
debugConsoleStore.setFilterLevel("info");
|
|
||||||
const logs = get(filteredLogs);
|
|
||||||
expect(logs.length).toBe(3);
|
|
||||||
expect(logs.some((l) => l.level === "debug")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns all log levels when filterLevel is debug", () => {
|
|
||||||
emitMockEvent("debug:log", { level: "debug", message: "d" });
|
|
||||||
emitMockEvent("debug:log", { level: "info", message: "i" });
|
|
||||||
emitMockEvent("debug:log", { level: "warn", message: "w" });
|
|
||||||
emitMockEvent("debug:log", { level: "error", message: "e" });
|
|
||||||
debugConsoleStore.setFilterLevel("debug");
|
|
||||||
expect(get(filteredLogs).length).toBe(4);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { get } from "svelte/store";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { setMockInvokeResult } from "../../../vitest.setup";
|
|
||||||
import { draftsStore, type Draft } from "./drafts";
|
|
||||||
|
|
||||||
const makeDraft = (id: string, content: string, saved_at: string): Draft => ({
|
|
||||||
id,
|
|
||||||
content,
|
|
||||||
saved_at,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Draft interface", () => {
|
|
||||||
it("defines all required fields", () => {
|
|
||||||
const draft: Draft = {
|
|
||||||
id: "draft-123",
|
|
||||||
content: "Hello world",
|
|
||||||
saved_at: "2026-01-01T00:00:00+00:00",
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(draft.id).toBe("draft-123");
|
|
||||||
expect(draft.content).toBe("Hello world");
|
|
||||||
expect(draft.saved_at).toBe("2026-01-01T00:00:00+00:00");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports multiline content", () => {
|
|
||||||
const draft: Draft = {
|
|
||||||
id: "multi",
|
|
||||||
content: "Line 1\nLine 2\nLine 3",
|
|
||||||
saved_at: "2026-01-01T00:00:00+00:00",
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(draft.content.includes("\n")).toBe(true);
|
|
||||||
expect(draft.content.split("\n")).toHaveLength(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("draftsStore", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("store structure", () => {
|
|
||||||
it("has all expected methods", () => {
|
|
||||||
expect(typeof draftsStore.loadDrafts).toBe("function");
|
|
||||||
expect(typeof draftsStore.saveDraft).toBe("function");
|
|
||||||
expect(typeof draftsStore.deleteDraft).toBe("function");
|
|
||||||
expect(typeof draftsStore.deleteAllDrafts).toBe("function");
|
|
||||||
expect(typeof draftsStore.formatTimestamp).toBe("function");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has subscribable stores", () => {
|
|
||||||
expect(typeof draftsStore.drafts.subscribe).toBe("function");
|
|
||||||
expect(typeof draftsStore.isLoading.subscribe).toBe("function");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("loadDrafts", () => {
|
|
||||||
it("loads drafts from backend", async () => {
|
|
||||||
const mockDrafts: Draft[] = [
|
|
||||||
makeDraft("draft-1", "Hello world", "2026-01-01T00:00:00+00:00"),
|
|
||||||
];
|
|
||||||
|
|
||||||
setMockInvokeResult("list_drafts", mockDrafts);
|
|
||||||
|
|
||||||
await draftsStore.loadDrafts();
|
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith("list_drafts");
|
|
||||||
expect(get(draftsStore.drafts)).toEqual(mockDrafts);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles load errors gracefully", async () => {
|
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("list_drafts", new Error("Failed to load"));
|
|
||||||
|
|
||||||
await draftsStore.loadDrafts();
|
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to load drafts:", expect.any(Error));
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets isLoading during load", async () => {
|
|
||||||
const loadingStates: boolean[] = [];
|
|
||||||
const unsubscribe = draftsStore.isLoading.subscribe((val) => loadingStates.push(val));
|
|
||||||
|
|
||||||
setMockInvokeResult("list_drafts", []);
|
|
||||||
await draftsStore.loadDrafts();
|
|
||||||
|
|
||||||
unsubscribe();
|
|
||||||
expect(loadingStates).toContain(true);
|
|
||||||
expect(loadingStates.at(-1)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("saveDraft", () => {
|
|
||||||
it("saves draft and reloads list", async () => {
|
|
||||||
const mockDraft = makeDraft("new-id", "My draft content", "2026-01-01T00:00:00+00:00");
|
|
||||||
|
|
||||||
setMockInvokeResult("save_draft", mockDraft);
|
|
||||||
setMockInvokeResult("list_drafts", [mockDraft]);
|
|
||||||
|
|
||||||
const result = await draftsStore.saveDraft("My draft content");
|
|
||||||
|
|
||||||
expect(result).toEqual(mockDraft);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("save_draft", { content: "My draft content" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null on error", async () => {
|
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("save_draft", new Error("Save failed"));
|
|
||||||
|
|
||||||
const result = await draftsStore.saveDraft("content");
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to save draft:", expect.any(Error));
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("deleteDraft", () => {
|
|
||||||
it("deletes draft by ID and reloads", async () => {
|
|
||||||
setMockInvokeResult("delete_draft", undefined);
|
|
||||||
setMockInvokeResult("list_drafts", []);
|
|
||||||
|
|
||||||
const result = await draftsStore.deleteDraft("draft-123");
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("delete_draft", { draftId: "draft-123" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles delete errors gracefully", async () => {
|
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("delete_draft", new Error("Delete failed"));
|
|
||||||
|
|
||||||
const result = await draftsStore.deleteDraft("draft-123");
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to delete draft:", expect.any(Error));
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("deleteAllDrafts", () => {
|
|
||||||
it("deletes all drafts and clears store", async () => {
|
|
||||||
// First populate the store
|
|
||||||
setMockInvokeResult("list_drafts", [makeDraft("d1", "Draft 1", "2026-01-01T00:00:00+00:00")]);
|
|
||||||
await draftsStore.loadDrafts();
|
|
||||||
expect(get(draftsStore.drafts)).toHaveLength(1);
|
|
||||||
|
|
||||||
setMockInvokeResult("delete_all_drafts", undefined);
|
|
||||||
const result = await draftsStore.deleteAllDrafts();
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("delete_all_drafts");
|
|
||||||
expect(get(draftsStore.drafts)).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles delete-all errors gracefully", async () => {
|
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("delete_all_drafts", new Error("Delete all failed"));
|
|
||||||
|
|
||||||
const result = await draftsStore.deleteAllDrafts();
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to delete all drafts:", expect.any(Error));
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("formatTimestamp", () => {
|
|
||||||
it("formats a valid ISO timestamp", () => {
|
|
||||||
const result = draftsStore.formatTimestamp("2026-01-15T14:30:00+00:00");
|
|
||||||
// Should produce a human-readable string (locale-dependent)
|
|
||||||
expect(typeof result).toBe("string");
|
|
||||||
expect(result.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to raw string on invalid timestamp", () => {
|
|
||||||
const invalid = "not-a-date";
|
|
||||||
const result = draftsStore.formatTimestamp(invalid);
|
|
||||||
expect(result).toBe(invalid);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to raw string when toLocaleString throws", () => {
|
|
||||||
const spy = vi.spyOn(Date.prototype, "toLocaleString").mockImplementation(() => {
|
|
||||||
throw new Error("Locale not supported");
|
|
||||||
});
|
|
||||||
const ts = "2026-01-15T14:30:00.000Z";
|
|
||||||
const result = draftsStore.formatTimestamp(ts);
|
|
||||||
expect(result).toBe(ts);
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("draft content handling", () => {
|
|
||||||
it("supports content with special characters", () => {
|
|
||||||
const draft = makeDraft(
|
|
||||||
"special",
|
|
||||||
"echo \"Hello\" && echo 'World'",
|
|
||||||
"2026-01-01T00:00:00+00:00"
|
|
||||||
);
|
|
||||||
expect(draft.content).toContain('"');
|
|
||||||
expect(draft.content).toContain("'");
|
|
||||||
expect(draft.content).toContain("&&");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports very long content", () => {
|
|
||||||
const longContent = "a".repeat(1000);
|
|
||||||
const draft = makeDraft("long", longContent, "2026-01-01T00:00:00+00:00");
|
|
||||||
expect(draft.content).toHaveLength(1000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { writable } from "svelte/store";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
|
|
||||||
export interface Draft {
|
|
||||||
id: string;
|
|
||||||
content: string;
|
|
||||||
saved_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDraftsStore() {
|
|
||||||
const drafts = writable<Draft[]>([]);
|
|
||||||
const isLoading = writable(false);
|
|
||||||
|
|
||||||
async function loadDrafts(): Promise<void> {
|
|
||||||
isLoading.set(true);
|
|
||||||
try {
|
|
||||||
const list = await invoke<Draft[]>("list_drafts");
|
|
||||||
drafts.set(list);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load drafts:", error);
|
|
||||||
} finally {
|
|
||||||
isLoading.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveDraft(content: string): Promise<Draft | null> {
|
|
||||||
try {
|
|
||||||
const draft = await invoke<Draft>("save_draft", { content });
|
|
||||||
await loadDrafts();
|
|
||||||
return draft;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save draft:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteDraft(draftId: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await invoke("delete_draft", { draftId });
|
|
||||||
await loadDrafts();
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete draft:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAllDrafts(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await invoke("delete_all_drafts");
|
|
||||||
drafts.set([]);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete all drafts:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(isoString: string): string {
|
|
||||||
const date = new Date(isoString);
|
|
||||||
if (isNaN(date.getTime())) {
|
|
||||||
return isoString;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return date.toLocaleString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return isoString;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
drafts: { subscribe: drafts.subscribe },
|
|
||||||
isLoading: { subscribe: isLoading.subscribe },
|
|
||||||
loadDrafts,
|
|
||||||
saveDraft,
|
|
||||||
deleteDraft,
|
|
||||||
deleteAllDrafts,
|
|
||||||
formatTimestamp,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const draftsStore = createDraftsStore();
|
|
||||||
@@ -1,639 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { get } from "svelte/store";
|
|
||||||
import { editorStore } from "$lib/stores/editor";
|
|
||||||
import { setMockInvokeResult } from "../../../vitest.setup";
|
|
||||||
|
|
||||||
const FILE_CONTENT = "// test file content";
|
|
||||||
|
|
||||||
// Reset tabs between tests
|
|
||||||
afterEach(async () => {
|
|
||||||
const tabs = get(editorStore.tabs);
|
|
||||||
for (const tab of tabs) {
|
|
||||||
editorStore.closeTab(tab.id);
|
|
||||||
}
|
|
||||||
editorStore.hideEditor();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - openFile (tests getLanguageFromPath)", () => {
|
|
||||||
const testCases: [string, string][] = [
|
|
||||||
["file.ts", "typescript"],
|
|
||||||
["file.tsx", "typescript"],
|
|
||||||
["file.js", "javascript"],
|
|
||||||
["file.jsx", "javascript"],
|
|
||||||
["file.py", "python"],
|
|
||||||
["file.rs", "rust"],
|
|
||||||
["file.go", "go"],
|
|
||||||
["file.java", "java"],
|
|
||||||
["file.c", "c"],
|
|
||||||
["file.cpp", "cpp"],
|
|
||||||
["file.h", "c"],
|
|
||||||
["file.hpp", "cpp"],
|
|
||||||
["file.cs", "csharp"],
|
|
||||||
["file.rb", "ruby"],
|
|
||||||
["file.php", "php"],
|
|
||||||
["file.swift", "swift"],
|
|
||||||
["file.kt", "kotlin"],
|
|
||||||
["file.scala", "scala"],
|
|
||||||
["file.html", "html"],
|
|
||||||
["file.htm", "html"],
|
|
||||||
["file.css", "css"],
|
|
||||||
["file.scss", "scss"],
|
|
||||||
["file.sass", "sass"],
|
|
||||||
["file.less", "less"],
|
|
||||||
["file.json", "json"],
|
|
||||||
["file.xml", "xml"],
|
|
||||||
["file.yaml", "yaml"],
|
|
||||||
["file.yml", "yaml"],
|
|
||||||
["file.toml", "toml"],
|
|
||||||
["file.md", "markdown"],
|
|
||||||
["file.markdown", "markdown"],
|
|
||||||
["file.sql", "sql"],
|
|
||||||
["file.sh", "shell"],
|
|
||||||
["file.bash", "shell"],
|
|
||||||
["file.zsh", "shell"],
|
|
||||||
["file.ps1", "powershell"],
|
|
||||||
["file.svelte", "svelte"],
|
|
||||||
["file.vue", "vue"],
|
|
||||||
["file.graphql", "graphql"],
|
|
||||||
["file.gql", "graphql"],
|
|
||||||
["file.lua", "lua"],
|
|
||||||
["file.r", "r"],
|
|
||||||
["file.dart", "dart"],
|
|
||||||
["file.elm", "elm"],
|
|
||||||
["file.ex", "elixir"],
|
|
||||||
["file.exs", "elixir"],
|
|
||||||
["file.erl", "erlang"],
|
|
||||||
["file.hs", "haskell"],
|
|
||||||
["file.clj", "clojure"],
|
|
||||||
["file.lisp", "lisp"],
|
|
||||||
["file.ml", "ocaml"],
|
|
||||||
["file.fs", "fsharp"],
|
|
||||||
["file.zig", "zig"],
|
|
||||||
["file.nim", "nim"],
|
|
||||||
["file.v", "v"],
|
|
||||||
["file.wasm", "wasm"],
|
|
||||||
["file.wat", "wasm"],
|
|
||||||
["dockerfile", "plaintext"],
|
|
||||||
["file.unknown", "plaintext"],
|
|
||||||
["file", "plaintext"],
|
|
||||||
];
|
|
||||||
|
|
||||||
it.each(testCases)("maps %s to language %s", async (filename, expectedLanguage) => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile(`/path/to/${filename}`);
|
|
||||||
const activeTab = get(editorStore.activeTab);
|
|
||||||
expect(activeTab?.language).toBe(expectedLanguage);
|
|
||||||
if (activeTab) editorStore.closeTab(activeTab.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens a new tab and makes it active", async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/file.ts");
|
|
||||||
const tabs = get(editorStore.tabs);
|
|
||||||
const activeTab = get(editorStore.activeTab);
|
|
||||||
expect(tabs).toHaveLength(1);
|
|
||||||
expect(activeTab?.fileName).toBe("file.ts");
|
|
||||||
expect(activeTab?.content).toBe(FILE_CONTENT);
|
|
||||||
expect(activeTab?.isDirty).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("switches to existing tab instead of opening a duplicate", async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/file.ts");
|
|
||||||
const firstTabId = get(editorStore.activeTabId);
|
|
||||||
|
|
||||||
// Open another file to change active tab
|
|
||||||
await editorStore.openFile("/path/to/other.ts");
|
|
||||||
const secondTabId = get(editorStore.activeTabId);
|
|
||||||
expect(secondTabId).not.toBe(firstTabId);
|
|
||||||
|
|
||||||
// Re-open first file — should switch to existing tab
|
|
||||||
await editorStore.openFile("/path/to/file.ts");
|
|
||||||
expect(get(editorStore.activeTabId)).toBe(firstTabId);
|
|
||||||
expect(get(editorStore.tabs)).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles open file errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("read_file_content", new Error("File not found"));
|
|
||||||
await editorStore.openFile("/path/to/missing.ts");
|
|
||||||
expect(get(editorStore.tabs)).toHaveLength(0);
|
|
||||||
expect(get(editorStore.saveError)).toContain("Failed to open file");
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("extracts filename from path correctly", async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/deep/nested/path/component.svelte");
|
|
||||||
expect(get(editorStore.activeTab)?.fileName).toBe("component.svelte");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - saveFile", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/file.ts");
|
|
||||||
// Make it dirty first
|
|
||||||
editorStore.updateTabContent(get(editorStore.activeTabId)!, "modified content");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("saves the active tab successfully", async () => {
|
|
||||||
setMockInvokeResult("write_file_content", undefined);
|
|
||||||
await editorStore.saveFile();
|
|
||||||
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
|
|
||||||
expect(get(editorStore.activeTab)?.originalContent).toBe("modified content");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("saves a specific tab by ID", async () => {
|
|
||||||
setMockInvokeResult("write_file_content", undefined);
|
|
||||||
const tabId = get(editorStore.activeTabId)!;
|
|
||||||
await editorStore.saveFile(tabId);
|
|
||||||
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing when no tab ID matches", async () => {
|
|
||||||
await editorStore.saveFile("non-existent-tab");
|
|
||||||
// No error = pass
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - saveFile error", () => {
|
|
||||||
it("sets saveError when write fails", async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/file-err.ts");
|
|
||||||
editorStore.updateTabContent(get(editorStore.activeTabId)!, "modified content");
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("write_file_content", new Error("Permission denied"));
|
|
||||||
await editorStore.saveFile().catch(() => {});
|
|
||||||
expect(get(editorStore.saveError)).toContain("Failed to save file");
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - updateTabContent", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/file.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates content and marks tab as dirty", () => {
|
|
||||||
const tabId = get(editorStore.activeTabId)!;
|
|
||||||
editorStore.updateTabContent(tabId, "new content");
|
|
||||||
const tab = get(editorStore.activeTab);
|
|
||||||
expect(tab?.content).toBe("new content");
|
|
||||||
expect(tab?.isDirty).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("marks tab as clean when content matches original", () => {
|
|
||||||
const tabId = get(editorStore.activeTabId)!;
|
|
||||||
editorStore.updateTabContent(tabId, "modified");
|
|
||||||
expect(get(editorStore.activeTab)?.isDirty).toBe(true);
|
|
||||||
editorStore.updateTabContent(tabId, FILE_CONTENT);
|
|
||||||
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hasDirtyTabs is true when any tab is dirty", () => {
|
|
||||||
const tabId = get(editorStore.activeTabId)!;
|
|
||||||
expect(get(editorStore.hasDirtyTabs)).toBe(false);
|
|
||||||
editorStore.updateTabContent(tabId, "dirty content");
|
|
||||||
expect(get(editorStore.hasDirtyTabs)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - closeTab", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/file1.ts");
|
|
||||||
await editorStore.openFile("/path/to/file2.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes the specified tab", () => {
|
|
||||||
const tabs = get(editorStore.tabs);
|
|
||||||
editorStore.closeTab(tabs[0].id);
|
|
||||||
expect(get(editorStore.tabs)).toHaveLength(1);
|
|
||||||
expect(get(editorStore.tabs)[0].filePath).toBe("/path/to/file2.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets active tab to remaining tab when active tab is closed", () => {
|
|
||||||
const activeTabId = get(editorStore.activeTabId)!;
|
|
||||||
editorStore.closeTab(activeTabId);
|
|
||||||
expect(get(editorStore.activeTabId)).not.toBe(activeTabId);
|
|
||||||
expect(get(editorStore.tabs)).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - setActiveTab", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/file1.ts");
|
|
||||||
await editorStore.openFile("/path/to/file2.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets the active tab to the specified ID", () => {
|
|
||||||
const tabs = get(editorStore.tabs);
|
|
||||||
editorStore.setActiveTab(tabs[0].id);
|
|
||||||
expect(get(editorStore.activeTabId)).toBe(tabs[0].id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - visibility controls", () => {
|
|
||||||
it("showEditor sets isEditorVisible to true", () => {
|
|
||||||
expect(get(editorStore.isEditorVisible)).toBe(false);
|
|
||||||
editorStore.showEditor();
|
|
||||||
expect(get(editorStore.isEditorVisible)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hideEditor sets isEditorVisible to false", () => {
|
|
||||||
editorStore.showEditor();
|
|
||||||
editorStore.hideEditor();
|
|
||||||
expect(get(editorStore.isEditorVisible)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("toggleEditor toggles isEditorVisible", () => {
|
|
||||||
expect(get(editorStore.isEditorVisible)).toBe(false);
|
|
||||||
editorStore.toggleEditor();
|
|
||||||
expect(get(editorStore.isEditorVisible)).toBe(true);
|
|
||||||
editorStore.toggleEditor();
|
|
||||||
expect(get(editorStore.isEditorVisible)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - toggleFileBrowser", () => {
|
|
||||||
it("toggles the file browser visibility", () => {
|
|
||||||
const initial = get(editorStore.isFileBrowserOpen);
|
|
||||||
editorStore.toggleFileBrowser();
|
|
||||||
expect(get(editorStore.isFileBrowserOpen)).toBe(!initial);
|
|
||||||
editorStore.toggleFileBrowser();
|
|
||||||
expect(get(editorStore.isFileBrowserOpen)).toBe(initial);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - file system operations", () => {
|
|
||||||
it("createFile invokes backend and refreshes directory", async () => {
|
|
||||||
setMockInvokeResult("create_file", undefined);
|
|
||||||
setMockInvokeResult("list_directory", []);
|
|
||||||
await editorStore.createFile("/path/to", "new-file.ts");
|
|
||||||
// No error = success
|
|
||||||
});
|
|
||||||
|
|
||||||
it("createFile handles errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("create_file", new Error("Permission denied"));
|
|
||||||
await editorStore.createFile("/path/to", "new-file.ts");
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to create file:", expect.any(Error));
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("createDirectory invokes backend and refreshes", async () => {
|
|
||||||
setMockInvokeResult("create_directory", undefined);
|
|
||||||
setMockInvokeResult("list_directory", []);
|
|
||||||
await editorStore.createDirectory("/path/to", "new-dir");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("createDirectory handles errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("create_directory", new Error("Failed"));
|
|
||||||
await editorStore.createDirectory("/path/to", "new-dir");
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to create directory:", expect.any(Error));
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deleteFile invokes backend and refreshes", async () => {
|
|
||||||
setMockInvokeResult("delete_file", undefined);
|
|
||||||
setMockInvokeResult("list_directory", []);
|
|
||||||
await editorStore.deleteFile("/path/to/file.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deleteFile handles errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("delete_file", new Error("Failed"));
|
|
||||||
await editorStore.deleteFile("/path/to/file.ts");
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to delete file:", expect.any(Error));
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deleteDirectory invokes backend and refreshes", async () => {
|
|
||||||
setMockInvokeResult("delete_directory", undefined);
|
|
||||||
setMockInvokeResult("list_directory", []);
|
|
||||||
await editorStore.deleteDirectory("/path/to/dir");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deleteDirectory handles errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("delete_directory", new Error("Failed"));
|
|
||||||
await editorStore.deleteDirectory("/path/to/dir");
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to delete directory:", expect.any(Error));
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renamePath invokes backend and refreshes", async () => {
|
|
||||||
setMockInvokeResult("rename_path", undefined);
|
|
||||||
setMockInvokeResult("list_directory", []);
|
|
||||||
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renamePath handles errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("rename_path", new Error("Failed"));
|
|
||||||
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to rename:", expect.any(Error));
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - initializeFileTree", () => {
|
|
||||||
it("loads directory entries and updates file tree", async () => {
|
|
||||||
const entries = [
|
|
||||||
{ path: "/path/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", entries);
|
|
||||||
await editorStore.initializeFileTree("/path");
|
|
||||||
expect(get(editorStore.fileTree)).toEqual(entries);
|
|
||||||
expect(get(editorStore.currentDirectory)).toBe("/path");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("list_directory", new Error("Failed"));
|
|
||||||
await editorStore.initializeFileTree("/path");
|
|
||||||
expect(get(editorStore.isLoadingTree)).toBe(false);
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - toggleDirectory", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
const entries = [
|
|
||||||
{
|
|
||||||
path: "/path/dir",
|
|
||||||
name: "dir",
|
|
||||||
isDirectory: true,
|
|
||||||
isExpanded: false,
|
|
||||||
children: undefined,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", entries);
|
|
||||||
await editorStore.initializeFileTree("/path");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing when entry is not a directory", async () => {
|
|
||||||
const nonDirEntry = {
|
|
||||||
path: "/path/file.ts",
|
|
||||||
name: "file.ts",
|
|
||||||
isDirectory: false,
|
|
||||||
};
|
|
||||||
const treeBefore = get(editorStore.fileTree);
|
|
||||||
await editorStore.toggleDirectory(nonDirEntry);
|
|
||||||
expect(get(editorStore.fileTree)).toEqual(treeBefore);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("expands a collapsed directory and loads children asynchronously", async () => {
|
|
||||||
const children = [
|
|
||||||
{ path: "/path/dir/child.ts", name: "child.ts", isDirectory: false, children: undefined },
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", children);
|
|
||||||
|
|
||||||
const dirEntry = get(editorStore.fileTree)[0];
|
|
||||||
await editorStore.toggleDirectory(dirEntry);
|
|
||||||
|
|
||||||
const updatedEntry = get(editorStore.fileTree)[0];
|
|
||||||
expect(updatedEntry.isExpanded).toBe(true);
|
|
||||||
expect(updatedEntry.children).toEqual(children);
|
|
||||||
expect(updatedEntry.isLoading).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("collapses an expanded directory", async () => {
|
|
||||||
const children = [
|
|
||||||
{ path: "/path/dir/child.ts", name: "child.ts", isDirectory: false, children: undefined },
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", children);
|
|
||||||
|
|
||||||
// Expand first
|
|
||||||
const dirEntry = get(editorStore.fileTree)[0];
|
|
||||||
await editorStore.toggleDirectory(dirEntry);
|
|
||||||
expect(get(editorStore.fileTree)[0].isExpanded).toBe(true);
|
|
||||||
|
|
||||||
// Now collapse
|
|
||||||
const expandedEntry = get(editorStore.fileTree)[0];
|
|
||||||
await editorStore.toggleDirectory(expandedEntry);
|
|
||||||
expect(get(editorStore.fileTree)[0].isExpanded).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("expands a directory that already has children without reloading", async () => {
|
|
||||||
// Re-init with a directory that already has children
|
|
||||||
const entries = [
|
|
||||||
{
|
|
||||||
path: "/path/dir",
|
|
||||||
name: "dir",
|
|
||||||
isDirectory: true,
|
|
||||||
isExpanded: false,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: "/path/dir/child.ts",
|
|
||||||
name: "child.ts",
|
|
||||||
isDirectory: false,
|
|
||||||
children: undefined,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", entries);
|
|
||||||
await editorStore.initializeFileTree("/path");
|
|
||||||
|
|
||||||
// Toggle should expand without loading (children already present)
|
|
||||||
const dirEntry = get(editorStore.fileTree)[0];
|
|
||||||
await editorStore.toggleDirectory(dirEntry);
|
|
||||||
|
|
||||||
const updatedEntry = get(editorStore.fileTree)[0];
|
|
||||||
expect(updatedEntry.isExpanded).toBe(true);
|
|
||||||
expect(updatedEntry.children).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles tree with multiple entries when toggling — exercises non-matching leaf fallback", async () => {
|
|
||||||
// Tree has dir + a sibling file; toggling dir exercises the `return e` branch for the file
|
|
||||||
const entries = [
|
|
||||||
{
|
|
||||||
path: "/path/dir",
|
|
||||||
name: "dir",
|
|
||||||
isDirectory: true,
|
|
||||||
isExpanded: false,
|
|
||||||
children: undefined,
|
|
||||||
},
|
|
||||||
{ path: "/path/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", entries);
|
|
||||||
await editorStore.initializeFileTree("/path");
|
|
||||||
|
|
||||||
const children: never[] = [];
|
|
||||||
setMockInvokeResult("list_directory", children);
|
|
||||||
const dirEntry = get(editorStore.fileTree)[0];
|
|
||||||
await editorStore.toggleDirectory(dirEntry);
|
|
||||||
|
|
||||||
// The sibling file should remain unchanged
|
|
||||||
expect(get(editorStore.fileTree)[1].path).toBe("/path/file.ts");
|
|
||||||
expect(get(editorStore.fileTree)[0].isExpanded).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles nested directory toggle — exercises recursive updateTree branches", async () => {
|
|
||||||
// Tree: outer_dir → [inner_dir (no children), sibling_file]
|
|
||||||
// Toggling inner_dir covers the recursive path through outer's children
|
|
||||||
const entries = [
|
|
||||||
{
|
|
||||||
path: "/path/outer",
|
|
||||||
name: "outer",
|
|
||||||
isDirectory: true,
|
|
||||||
isExpanded: true,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: "/path/outer/inner",
|
|
||||||
name: "inner",
|
|
||||||
isDirectory: true,
|
|
||||||
isExpanded: false,
|
|
||||||
children: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/path/outer/sibling.ts",
|
|
||||||
name: "sibling.ts",
|
|
||||||
isDirectory: false,
|
|
||||||
children: undefined,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", entries);
|
|
||||||
await editorStore.initializeFileTree("/path");
|
|
||||||
|
|
||||||
const innerChildren = [
|
|
||||||
{
|
|
||||||
path: "/path/outer/inner/deep.ts",
|
|
||||||
name: "deep.ts",
|
|
||||||
isDirectory: false,
|
|
||||||
children: undefined,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", innerChildren);
|
|
||||||
|
|
||||||
const innerEntry = get(editorStore.fileTree)[0].children![0];
|
|
||||||
await editorStore.toggleDirectory(innerEntry);
|
|
||||||
|
|
||||||
const outer = get(editorStore.fileTree)[0];
|
|
||||||
expect(outer.children![0].isExpanded).toBe(true);
|
|
||||||
expect(outer.children![0].children).toEqual(innerChildren);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - deleteFile closes open tab", () => {
|
|
||||||
it("closes the tab when the deleted file is open", async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/file.ts");
|
|
||||||
expect(get(editorStore.tabs)).toHaveLength(1);
|
|
||||||
|
|
||||||
setMockInvokeResult("delete_file", undefined);
|
|
||||||
setMockInvokeResult("list_directory", []);
|
|
||||||
await editorStore.deleteFile("/path/to/file.ts");
|
|
||||||
expect(get(editorStore.tabs)).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - deleteDirectory closes open tabs", () => {
|
|
||||||
it("closes all tabs inside the deleted directory", async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/dir/file1.ts");
|
|
||||||
await editorStore.openFile("/path/to/dir/nested/file2.ts");
|
|
||||||
await editorStore.openFile("/path/to/other.ts");
|
|
||||||
expect(get(editorStore.tabs)).toHaveLength(3);
|
|
||||||
|
|
||||||
setMockInvokeResult("delete_directory", undefined);
|
|
||||||
setMockInvokeResult("list_directory", []);
|
|
||||||
await editorStore.deleteDirectory("/path/to/dir");
|
|
||||||
|
|
||||||
const remainingTabs = get(editorStore.tabs);
|
|
||||||
expect(remainingTabs).toHaveLength(1);
|
|
||||||
expect(remainingTabs[0].filePath).toBe("/path/to/other.ts");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - renamePath updates open tabs", () => {
|
|
||||||
it("updates filePath and fileName when the renamed file is open in a tab", async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/old-name.ts");
|
|
||||||
|
|
||||||
setMockInvokeResult("rename_path", undefined);
|
|
||||||
setMockInvokeResult("list_directory", []);
|
|
||||||
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
|
|
||||||
|
|
||||||
const tab = get(editorStore.activeTab);
|
|
||||||
expect(tab?.filePath).toBe("/path/to/new-name.ts");
|
|
||||||
expect(tab?.fileName).toBe("new-name.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates filePaths for tabs inside a renamed directory", async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/old-dir/file.ts");
|
|
||||||
|
|
||||||
setMockInvokeResult("rename_path", undefined);
|
|
||||||
setMockInvokeResult("list_directory", []);
|
|
||||||
await editorStore.renamePath("/path/to/old-dir", "new-dir");
|
|
||||||
|
|
||||||
const tab = get(editorStore.activeTab);
|
|
||||||
expect(tab?.filePath).toBe("/path/to/new-dir/file.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves unrelated tabs unchanged when renaming — exercises return-t fallback", async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/unrelated.ts");
|
|
||||||
await editorStore.openFile("/path/to/old-name.ts");
|
|
||||||
|
|
||||||
setMockInvokeResult("rename_path", undefined);
|
|
||||||
setMockInvokeResult("list_directory", []);
|
|
||||||
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
|
|
||||||
|
|
||||||
const tabs = get(editorStore.tabs);
|
|
||||||
const unrelated = tabs.find((t) => t.filePath === "/path/to/unrelated.ts");
|
|
||||||
expect(unrelated).toBeDefined();
|
|
||||||
expect(unrelated?.fileName).toBe("unrelated.ts");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - refreshDirectory", () => {
|
|
||||||
it("reloads the entire tree when refreshing the root directory", async () => {
|
|
||||||
const initialEntries = [
|
|
||||||
{ path: "/root/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", initialEntries);
|
|
||||||
await editorStore.initializeFileTree("/root");
|
|
||||||
expect(get(editorStore.fileTree)).toHaveLength(1);
|
|
||||||
|
|
||||||
const updatedEntries = [
|
|
||||||
{ path: "/root/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
|
||||||
{ path: "/root/new.ts", name: "new.ts", isDirectory: false, children: undefined },
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", updatedEntries);
|
|
||||||
await editorStore.refreshDirectory("/root");
|
|
||||||
expect(get(editorStore.fileTree)).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates a subdirectory entry in the tree", async () => {
|
|
||||||
const initialEntries = [
|
|
||||||
{
|
|
||||||
path: "/root/subdir",
|
|
||||||
name: "subdir",
|
|
||||||
isDirectory: true,
|
|
||||||
isExpanded: true,
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", initialEntries);
|
|
||||||
await editorStore.initializeFileTree("/root");
|
|
||||||
|
|
||||||
const subChildren = [
|
|
||||||
{ path: "/root/subdir/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", subChildren);
|
|
||||||
await editorStore.refreshDirectory("/root/subdir");
|
|
||||||
|
|
||||||
const tree = get(editorStore.fileTree);
|
|
||||||
expect(tree[0].children).toEqual(subChildren);
|
|
||||||
expect(tree[0].isExpanded).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
|
||||||
import {
|
|
||||||
setShouldRestoreHistory,
|
|
||||||
setSavedHistory,
|
|
||||||
getShouldRestoreHistory,
|
|
||||||
getSavedHistory,
|
|
||||||
clearHistoryRestore,
|
|
||||||
} from "./historyRestore";
|
|
||||||
|
|
||||||
describe("historyRestore module", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
clearHistoryRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("initial state", () => {
|
|
||||||
it("shouldRestoreHistory is false by default", () => {
|
|
||||||
expect(getShouldRestoreHistory()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("savedHistory is null by default", () => {
|
|
||||||
expect(getSavedHistory()).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setShouldRestoreHistory", () => {
|
|
||||||
it("sets shouldRestoreHistory to true", () => {
|
|
||||||
setShouldRestoreHistory(true);
|
|
||||||
expect(getShouldRestoreHistory()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets shouldRestoreHistory to false", () => {
|
|
||||||
setShouldRestoreHistory(true);
|
|
||||||
setShouldRestoreHistory(false);
|
|
||||||
expect(getShouldRestoreHistory()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setSavedHistory", () => {
|
|
||||||
it("sets the saved history string", () => {
|
|
||||||
setSavedHistory("some history content");
|
|
||||||
expect(getSavedHistory()).toBe("some history content");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets the saved history to null", () => {
|
|
||||||
setSavedHistory("some history content");
|
|
||||||
setSavedHistory(null);
|
|
||||||
expect(getSavedHistory()).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("clearHistoryRestore", () => {
|
|
||||||
it("resets shouldRestoreHistory to false", () => {
|
|
||||||
setShouldRestoreHistory(true);
|
|
||||||
clearHistoryRestore();
|
|
||||||
expect(getShouldRestoreHistory()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resets savedHistory to null", () => {
|
|
||||||
setSavedHistory("some content");
|
|
||||||
clearHistoryRestore();
|
|
||||||
expect(getSavedHistory()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears both values at once", () => {
|
|
||||||
setShouldRestoreHistory(true);
|
|
||||||
setSavedHistory("history");
|
|
||||||
clearHistoryRestore();
|
|
||||||
expect(getShouldRestoreHistory()).toBe(false);
|
|
||||||
expect(getSavedHistory()).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user