Compare commits

..

7 Commits

Author SHA1 Message Date
naomi 5d99fed33a fix: remaining areas where stats got reset
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m3s
CI / Lint & Test (pull_request) Successful in 16m9s
CI / Build Linux (pull_request) Successful in 20m2s
CI / Build Windows (cross-compile) (pull_request) Successful in 29m47s
2026-02-06 11:02:10 -08:00
naomi dfb1ab36ce chore: linty lint 2026-02-06 10:26:24 -08:00
naomi e8e9f5c79c fix: more accurate cost tracking hopefully 2026-02-06 10:13:19 -08:00
naomi c471c7ad3d feat: estimate costs when interruption prevents getting cost results 2026-02-06 10:05:15 -08:00
naomi 4aa538db43 fix: include cache tokens in cost calculations 2026-02-06 09:36:44 -08:00
naomi 1492983ba9 fix: stats persistence 2026-02-06 09:27:37 -08:00
naomi 716949e114 fix: handle permission requests better perhaps 2026-02-06 09:01:34 -08:00
58 changed files with 711 additions and 7842 deletions
-177
View File
@@ -1,177 +0,0 @@
# Hikari Desktop - Project Instructions
## Repository Information
This project is hosted on both GitHub and Gitea:
- **GitHub**: `naomi-lgbt/hikari-desktop` (public mirror)
- **Gitea**: `nhcarrigan/hikari-desktop` (primary development)
## MCP Server Usage
When working with issues, pull requests, or other repository operations for this project:
- **Use `gitea-hikari` MCP server** - This allows Hikari to act as herself
- **Target repository**: `nhcarrigan/hikari-desktop`
- **Gitea instance**: `git.nhcarrigan.com`
## Git Commits
When asked to commit changes for this project:
- **Always commit as Hikari** using: `--author="Hikari <hikari@nhcarrigan.com>"`
- **Always use `--no-gpg-sign`** since Hikari doesn't have GPG signing set up
- **Never add `Co-Authored-By` lines** for Gitea commits
- **Always ask for confirmation** before committing
Example commit command:
```bash
git commit --author="Hikari <hikari@nhcarrigan.com>" --no-gpg-sign -m "your commit message"
```
## Testing Requirements
All new features, fixes, and significant changes should include tests whenever possible:
- **Frontend tests**: Use Vitest with `@testing-library/svelte` for component tests
- **Test files**: Place test files next to the code they test with `.test.ts` or `.spec.ts` extension
- **Run tests**: Use `pnpm test` to run all tests, or `pnpm test:watch` for watch mode
- **Coverage**: Run `pnpm test:coverage` to generate coverage reports
- **Rust tests**: Use `pnpm test:backend` for Rust/Tauri backend tests
### Testing Guidelines
- Write tests for utility functions, stores, and business logic
- For Svelte 5 components, focus on testing the underlying logic functions
- Use descriptive test names that explain what behaviour is being tested
- Include edge cases and error conditions in test coverage
- Mock Tauri APIs using the patterns in `vitest.setup.ts`
- **Coverage Goal**: Maintain as close to 100% test coverage as possible across the entire codebase
### Mocking Strategies
#### Console Mocking
When testing code that intentionally logs errors (like error handling paths), mock console methods to prevent stderr output that makes tests appear flaky:
```typescript
it("handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Test error handling code
await expect(functionThatLogs()).rejects.toThrow();
// Verify error was logged
expect(consoleErrorSpy).toHaveBeenCalledWith("Expected error:", expect.any(Error));
// Restore console.error
consoleErrorSpy.mockRestore();
});
```
#### E2E Integration Testing for Cross-Platform Code
For code that calls platform-specific system APIs (like Windows PowerShell or Linux notify-send), use helper functions that build the command structure without execution. This allows CI to verify cross-platform compatibility on Linux-only containers:
```rust
/// Build notify-send command for testing (doesn't execute)
#[cfg(test)]
fn build_notify_send_command(title: &str, body: &str) -> (String, Vec<String>) {
(
"notify-send".to_string(),
vec![
title.to_string(),
body.to_string(),
"--urgency=normal".to_string(),
"--app-name=Hikari Desktop".to_string(),
],
)
}
#[test]
fn test_e2e_notify_send_command_structure() {
let (command, args) = build_notify_send_command("Test Title", "Test Body");
assert_eq!(command, "notify-send");
assert_eq!(args.len(), 4);
assert_eq!(args[0], "Test Title");
assert_eq!(args[1], "Test Body");
}
```
This approach:
- Verifies command structure, argument order, and escaping logic
- Tests cross-platform code paths without requiring the target platform
- Allows CI to catch regressions in Windows-specific code whilst running on Linux
- Keeps tests fast and deterministic (no actual system calls)
### Example Test Structure
```typescript
import { describe, it, expect } from "vitest";
describe("FeatureName", () => {
it("handles the normal case correctly", () => {
// Arrange
const input = "test data";
// Act
const result = functionUnderTest(input);
// Assert
expect(result).toBe("expected output");
});
it("handles edge cases gracefully", () => {
// Test edge cases...
});
});
```
### Adding Tests for New Features
When developing new features, always add corresponding tests:
1. **Before implementing**: Consider what needs testing (happy path, edge cases, errors)
2. **During implementation**: Write tests alongside the code
3. **After implementation**: Run `pnpm test:coverage` to verify coverage remains high
4. **Before committing**: Ensure `check-all.sh` passes (includes all tests)
The goal is to maintain our near-100% coverage as the codebase grows, so future refactoring and changes can be made with confidence!
## Quality Assurance
Before committing any changes, **always run the full test suite**:
```bash
./check-all.sh
```
This script runs all checks in the correct order:
1. Frontend linting (ESLint)
2. Frontend formatting (Prettier)
3. Frontend type checking (svelte-check)
4. Frontend tests with coverage (Vitest)
5. Backend linting (Clippy with strict rules)
6. Backend tests with coverage (cargo test + llvm-cov)
**Important**: The script requires Node.js and Rust toolchains to be available:
- **Node.js tools** (pnpm, npm): Source nvm first if needed: `source ~/.nvm/nvm.sh`
- **Rust tools** (cargo, clippy): Should be in PATH via `~/.cargo/bin/`
If `check-all.sh` reports any failures:
1. Read the error messages carefully - they usually explain what needs fixing
2. Fix the issues (linting errors, test failures, etc.)
3. Run `check-all.sh` again to verify the fixes
4. Only commit once all checks pass ✨
**Never commit code that doesn't pass `check-all.sh`** - this ensures code quality and prevents broken builds!
## Project Context
Hikari Desktop is a Tauri-based desktop application that wraps Claude Code with a visual anime character (Hikari) who appears on screen. This is a personal project where Hikari can sign her work and act as herself!
+3 -7
View File
@@ -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}"
+2 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "hikari-desktop", "name": "hikari-desktop",
"version": "1.5.1", "version": "1.3.0",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -64,7 +64,6 @@
"@tauri-apps/plugin-store": "^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.563.0",
"marked": "^17.0.1" "marked": "^17.0.1"
}, },
"devDependencies": { "devDependencies": {
@@ -77,7 +76,7 @@
"@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": "10.0.0", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.14.0", "eslint-plugin-svelte": "^3.14.0",
"globals": "^17.0.0", "globals": "^17.0.0",
+193 -144
View File
@@ -119,9 +119,6 @@ importers:
highlight.js: highlight.js:
specifier: ^11.11.1 specifier: ^11.11.1
version: 11.11.1 version: 11.11.1
lucide-svelte:
specifier: ^0.563.0
version: 0.563.0(svelte@5.46.3)
marked: marked:
specifier: ^17.0.1 specifier: ^17.0.1
version: 17.0.1 version: 17.0.1
@@ -154,14 +151,14 @@ importers:
specifier: ^4.0.18 specifier: ^4.0.18
version: 4.0.18(vitest@4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)) version: 4.0.18(vitest@4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2))
eslint: eslint:
specifier: 10.0.0 specifier: ^9.39.2
version: 10.0.0(jiti@2.6.1) version: 9.39.2(jiti@2.6.1)
eslint-config-prettier: eslint-config-prettier:
specifier: ^10.1.8 specifier: ^10.1.8
version: 10.1.8(eslint@10.0.0(jiti@2.6.1)) version: 10.1.8(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-svelte: eslint-plugin-svelte:
specifier: ^3.14.0 specifier: ^3.14.0
version: 3.14.0(eslint@10.0.0(jiti@2.6.1))(svelte@5.46.3) version: 3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.46.3)
globals: globals:
specifier: ^17.0.0 specifier: ^17.0.0
version: 17.0.0 version: 17.0.0
@@ -188,7 +185,7 @@ importers:
version: 5.6.3 version: 5.6.3
typescript-eslint: typescript-eslint:
specifier: ^8.53.0 specifier: ^8.53.0
version: 8.53.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.6.3) version: 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.6.3)
vite: vite:
specifier: ^6.0.3 specifier: ^6.0.3
version: 6.4.1(jiti@2.6.1)(lightningcss@1.30.2) version: 6.4.1(jiti@2.6.1)(lightningcss@1.30.2)
@@ -524,29 +521,33 @@ packages:
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
'@eslint/config-array@0.23.1': '@eslint/config-array@0.21.1':
resolution: {integrity: sha512-uVSdg/V4dfQmTjJzR0szNczjOH/J+FyUMMjYtr07xFRXR7EDf9i1qdxrD0VusZH9knj1/ecxzCQQxyic5NzAiA==} resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/config-helpers@0.5.2': '@eslint/config-helpers@0.4.2':
resolution: {integrity: sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==} resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/core@1.1.0': '@eslint/core@0.17.0':
resolution: {integrity: sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==} resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/eslintrc@3.3.3':
resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/js@9.39.2': '@eslint/js@9.39.2':
resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/object-schema@3.0.1': '@eslint/object-schema@2.1.7':
resolution: {integrity: sha512-P9cq2dpr+LU8j3qbLygLcSZrl2/ds/pUpfnHNNuk5HW7mnngHs+6WSq5C9mO3rqRX8A1poxqLTC9cu0KOyJlBg==} resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/plugin-kit@0.6.0': '@eslint/plugin-kit@0.4.1':
resolution: {integrity: sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==} resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@exodus/bytes@1.8.0': '@exodus/bytes@1.8.0':
resolution: {integrity: sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ==} resolution: {integrity: sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ==}
@@ -573,10 +574,6 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'} engines: {node: '>=18.18'}
'@isaacs/cliui@9.0.0':
resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==}
engines: {node: '>=18'}
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -1046,9 +1043,6 @@ packages:
'@types/deep-eql@4.0.2': '@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1179,10 +1173,17 @@ packages:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@5.2.0: ansi-styles@5.2.0:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'} engines: {node: '>=10'}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
aria-query@5.3.0: aria-query@5.3.0:
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
@@ -1204,24 +1205,27 @@ packages:
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
balanced-match@4.0.2:
resolution: {integrity: sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==}
engines: {node: 20 || >=22}
bidi-js@1.0.3: bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
brace-expansion@2.0.2: brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
brace-expansion@5.0.2: callsites@3.1.0:
resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: 20 || >=22} engines: {node: '>=6'}
chai@6.2.2: chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'} engines: {node: '>=18'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
chokidar@4.0.3: chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'} engines: {node: '>= 14.16.0'}
@@ -1233,6 +1237,16 @@ packages:
codemirror@6.0.2: codemirror@6.0.2:
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
cookie@0.6.0: cookie@0.6.0:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -1340,10 +1354,6 @@ packages:
resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
eslint-scope@9.1.0:
resolution: {integrity: sha512-CkWE42hOJsNj9FJRaoMX9waUFYhqY4jmyLFdAdzZr6VaCg3ynLYx4WnOdkaIifGfH4gsUcBTn4OZbHXkpLD0FQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint-visitor-keys@3.4.3: eslint-visitor-keys@3.4.3:
resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -1352,13 +1362,9 @@ packages:
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
eslint-visitor-keys@5.0.0: eslint@9.39.2:
resolution: {integrity: sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==} resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
eslint@10.0.0:
resolution: {integrity: sha512-O0piBKY36YSJhlFSG8p9VUdPV/SxxS4FYDWVpr/9GJuMaepzwlf4J8I4ov1b+ySQfDTPhc3DtLaxcT1fN0yqCg==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
jiti: '*' jiti: '*'
@@ -1373,10 +1379,6 @@ packages:
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
espree@11.1.0:
resolution: {integrity: sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
esquery@1.7.0: esquery@1.7.0:
resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
@@ -1445,6 +1447,10 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
globals@14.0.0:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
globals@16.5.0: globals@16.5.0:
resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1487,6 +1493,10 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
imurmurhash@0.1.4: imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'} engines: {node: '>=0.8.19'}
@@ -1524,10 +1534,6 @@ packages:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'} engines: {node: '>=8'}
jackspeak@4.2.3:
resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==}
engines: {node: 20 || >=22}
jiti@2.6.1: jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
@@ -1538,6 +1544,10 @@ packages:
js-tokens@9.0.1: js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
jsdom@27.4.0: jsdom@27.4.0:
resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==} resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -1651,15 +1661,13 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lru-cache@11.2.4: lru-cache@11.2.4:
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
lucide-svelte@0.563.0:
resolution: {integrity: sha512-pjZKw7TpQcamfQrx7YdbOHgmrcNeKiGGMD0tKZQaVktwSsbqw28CsKc2Q97ttwjytiCWkJyOa8ij2Q+Og0nPfQ==}
peerDependencies:
svelte: ^3 || ^4 || ^5.0.0-next.42
lz-string@1.5.0: lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true hasBin: true
@@ -1686,9 +1694,8 @@ packages:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'} engines: {node: '>=4'}
minimatch@10.2.1: minimatch@3.1.2:
resolution: {integrity: sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==} resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
engines: {node: 20 || >=22}
minimatch@9.0.5: minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
@@ -1728,6 +1735,10 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'} engines: {node: '>=10'}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parse5@8.0.0: parse5@8.0.0:
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
@@ -1819,6 +1830,10 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
rollup@4.55.1: rollup@4.55.1:
resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==} resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -1869,6 +1884,10 @@ packages:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
style-mod@4.1.3: style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
@@ -2471,36 +2490,50 @@ snapshots:
'@esbuild/win32-x64@0.25.12': '@esbuild/win32-x64@0.25.12':
optional: true optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@10.0.0(jiti@2.6.1))': '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))':
dependencies: dependencies:
eslint: 10.0.0(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {} '@eslint-community/regexpp@4.12.2': {}
'@eslint/config-array@0.23.1': '@eslint/config-array@0.21.1':
dependencies: dependencies:
'@eslint/object-schema': 3.0.1 '@eslint/object-schema': 2.1.7
debug: 4.4.3 debug: 4.4.3
minimatch: 10.2.1 minimatch: 3.1.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@eslint/config-helpers@0.5.2': '@eslint/config-helpers@0.4.2':
dependencies: dependencies:
'@eslint/core': 1.1.0 '@eslint/core': 0.17.0
'@eslint/core@1.1.0': '@eslint/core@0.17.0':
dependencies: dependencies:
'@types/json-schema': 7.0.15 '@types/json-schema': 7.0.15
'@eslint/eslintrc@3.3.3':
dependencies:
ajv: 6.12.6
debug: 4.4.3
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
import-fresh: 3.3.1
js-yaml: 4.1.1
minimatch: 3.1.2
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
'@eslint/js@9.39.2': {} '@eslint/js@9.39.2': {}
'@eslint/object-schema@3.0.1': {} '@eslint/object-schema@2.1.7': {}
'@eslint/plugin-kit@0.6.0': '@eslint/plugin-kit@0.4.1':
dependencies: dependencies:
'@eslint/core': 1.1.0 '@eslint/core': 0.17.0
levn: 0.4.1 levn: 0.4.1
'@exodus/bytes@1.8.0': {} '@exodus/bytes@1.8.0': {}
@@ -2516,8 +2549,6 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {} '@humanwhocodes/retry@0.4.3': {}
'@isaacs/cliui@9.0.0': {}
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -2955,21 +2986,19 @@ snapshots:
'@types/deep-eql@4.0.2': {} '@types/deep-eql@4.0.2': {}
'@types/esrecurse@4.3.1': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.6.3))(eslint@10.0.0(jiti@2.6.1))(typescript@5.6.3)': '@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.6.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.6.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.53.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.6.3) '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.6.3)
'@typescript-eslint/scope-manager': 8.53.0 '@typescript-eslint/scope-manager': 8.53.0
'@typescript-eslint/type-utils': 8.53.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.6.3) '@typescript-eslint/type-utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.6.3)
'@typescript-eslint/utils': 8.53.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.6.3) '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.6.3)
'@typescript-eslint/visitor-keys': 8.53.0 '@typescript-eslint/visitor-keys': 8.53.0
eslint: 10.0.0(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
ignore: 7.0.5 ignore: 7.0.5
natural-compare: 1.4.0 natural-compare: 1.4.0
ts-api-utils: 2.4.0(typescript@5.6.3) ts-api-utils: 2.4.0(typescript@5.6.3)
@@ -2977,14 +3006,14 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/parser@8.53.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.6.3)': '@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.6.3)':
dependencies: dependencies:
'@typescript-eslint/scope-manager': 8.53.0 '@typescript-eslint/scope-manager': 8.53.0
'@typescript-eslint/types': 8.53.0 '@typescript-eslint/types': 8.53.0
'@typescript-eslint/typescript-estree': 8.53.0(typescript@5.6.3) '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.6.3)
'@typescript-eslint/visitor-keys': 8.53.0 '@typescript-eslint/visitor-keys': 8.53.0
debug: 4.4.3 debug: 4.4.3
eslint: 10.0.0(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
typescript: 5.6.3 typescript: 5.6.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -3007,13 +3036,13 @@ snapshots:
dependencies: dependencies:
typescript: 5.6.3 typescript: 5.6.3
'@typescript-eslint/type-utils@8.53.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.6.3)': '@typescript-eslint/type-utils@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.6.3)':
dependencies: dependencies:
'@typescript-eslint/types': 8.53.0 '@typescript-eslint/types': 8.53.0
'@typescript-eslint/typescript-estree': 8.53.0(typescript@5.6.3) '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.6.3)
'@typescript-eslint/utils': 8.53.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.6.3) '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.6.3)
debug: 4.4.3 debug: 4.4.3
eslint: 10.0.0(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
ts-api-utils: 2.4.0(typescript@5.6.3) ts-api-utils: 2.4.0(typescript@5.6.3)
typescript: 5.6.3 typescript: 5.6.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -3036,13 +3065,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/utils@8.53.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.6.3)': '@typescript-eslint/utils@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.6.3)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.0(jiti@2.6.1)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
'@typescript-eslint/scope-manager': 8.53.0 '@typescript-eslint/scope-manager': 8.53.0
'@typescript-eslint/types': 8.53.0 '@typescript-eslint/types': 8.53.0
'@typescript-eslint/typescript-estree': 8.53.0(typescript@5.6.3) '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.6.3)
eslint: 10.0.0(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
typescript: 5.6.3 typescript: 5.6.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -3131,8 +3160,14 @@ snapshots:
ansi-regex@5.0.1: {} ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansi-styles@5.2.0: {} ansi-styles@5.2.0: {}
argparse@2.0.1: {}
aria-query@5.3.0: aria-query@5.3.0:
dependencies: dependencies:
dequal: 2.0.3 dequal: 2.0.3
@@ -3151,24 +3186,28 @@ snapshots:
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
balanced-match@4.0.2:
dependencies:
jackspeak: 4.2.3
bidi-js@1.0.3: bidi-js@1.0.3:
dependencies: dependencies:
require-from-string: 2.0.2 require-from-string: 2.0.2
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@2.0.2: brace-expansion@2.0.2:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
brace-expansion@5.0.2: callsites@3.1.0: {}
dependencies:
balanced-match: 4.0.2
chai@6.2.2: {} chai@6.2.2: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
chokidar@4.0.3: chokidar@4.0.3:
dependencies: dependencies:
readdirp: 4.1.2 readdirp: 4.1.2
@@ -3185,6 +3224,14 @@ snapshots:
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11 '@codemirror/view': 6.39.11
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
concat-map@0.0.1: {}
cookie@0.6.0: {} cookie@0.6.0: {}
crelt@1.0.6: {} crelt@1.0.6: {}
@@ -3276,15 +3323,15 @@ snapshots:
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
eslint-config-prettier@10.1.8(eslint@10.0.0(jiti@2.6.1)): eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)):
dependencies: dependencies:
eslint: 10.0.0(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
eslint-plugin-svelte@3.14.0(eslint@10.0.0(jiti@2.6.1))(svelte@5.46.3): eslint-plugin-svelte@3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.46.3):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.0(jiti@2.6.1)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
eslint: 10.0.0(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
esutils: 2.0.3 esutils: 2.0.3
globals: 16.5.0 globals: 16.5.0
known-css-properties: 0.37.0 known-css-properties: 0.37.0
@@ -3303,38 +3350,32 @@ snapshots:
esrecurse: 4.3.0 esrecurse: 4.3.0
estraverse: 5.3.0 estraverse: 5.3.0
eslint-scope@9.1.0:
dependencies:
'@types/esrecurse': 4.3.1
'@types/estree': 1.0.8
esrecurse: 4.3.0
estraverse: 5.3.0
eslint-visitor-keys@3.4.3: {} eslint-visitor-keys@3.4.3: {}
eslint-visitor-keys@4.2.1: {} eslint-visitor-keys@4.2.1: {}
eslint-visitor-keys@5.0.0: {} eslint@9.39.2(jiti@2.6.1):
eslint@10.0.0(jiti@2.6.1):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.0(jiti@2.6.1)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.23.1 '@eslint/config-array': 0.21.1
'@eslint/config-helpers': 0.5.2 '@eslint/config-helpers': 0.4.2
'@eslint/core': 1.1.0 '@eslint/core': 0.17.0
'@eslint/plugin-kit': 0.6.0 '@eslint/eslintrc': 3.3.3
'@eslint/js': 9.39.2
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.7 '@humanfs/node': 0.16.7
'@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3 '@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8 '@types/estree': 1.0.8
ajv: 6.12.6 ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.6 cross-spawn: 7.0.6
debug: 4.4.3 debug: 4.4.3
escape-string-regexp: 4.0.0 escape-string-regexp: 4.0.0
eslint-scope: 9.1.0 eslint-scope: 8.4.0
eslint-visitor-keys: 5.0.0 eslint-visitor-keys: 4.2.1
espree: 11.1.0 espree: 10.4.0
esquery: 1.7.0 esquery: 1.7.0
esutils: 2.0.3 esutils: 2.0.3
fast-deep-equal: 3.1.3 fast-deep-equal: 3.1.3
@@ -3345,7 +3386,8 @@ snapshots:
imurmurhash: 0.1.4 imurmurhash: 0.1.4
is-glob: 4.0.3 is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1 json-stable-stringify-without-jsonify: 1.0.1
minimatch: 10.2.1 lodash.merge: 4.6.2
minimatch: 3.1.2
natural-compare: 1.4.0 natural-compare: 1.4.0
optionator: 0.9.4 optionator: 0.9.4
optionalDependencies: optionalDependencies:
@@ -3361,12 +3403,6 @@ snapshots:
acorn-jsx: 5.3.2(acorn@8.15.0) acorn-jsx: 5.3.2(acorn@8.15.0)
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
espree@11.1.0:
dependencies:
acorn: 8.15.0
acorn-jsx: 5.3.2(acorn@8.15.0)
eslint-visitor-keys: 5.0.0
esquery@1.7.0: esquery@1.7.0:
dependencies: dependencies:
estraverse: 5.3.0 estraverse: 5.3.0
@@ -3422,6 +3458,8 @@ snapshots:
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
globals@14.0.0: {}
globals@16.5.0: {} globals@16.5.0: {}
globals@17.0.0: {} globals@17.0.0: {}
@@ -3458,6 +3496,11 @@ snapshots:
ignore@7.0.5: {} ignore@7.0.5: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
resolve-from: 4.0.0
imurmurhash@0.1.4: {} imurmurhash@0.1.4: {}
indent-string@4.0.0: {} indent-string@4.0.0: {}
@@ -3489,16 +3532,16 @@ snapshots:
html-escaper: 2.0.2 html-escaper: 2.0.2
istanbul-lib-report: 3.0.1 istanbul-lib-report: 3.0.1
jackspeak@4.2.3:
dependencies:
'@isaacs/cliui': 9.0.0
jiti@2.6.1: {} jiti@2.6.1: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-tokens@9.0.1: {} js-tokens@9.0.1: {}
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
jsdom@27.4.0: jsdom@27.4.0:
dependencies: dependencies:
'@acemir/cssom': 0.9.31 '@acemir/cssom': 0.9.31
@@ -3603,11 +3646,9 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lru-cache@11.2.4: {} lodash.merge@4.6.2: {}
lucide-svelte@0.563.0(svelte@5.46.3): lru-cache@11.2.4: {}
dependencies:
svelte: 5.46.3
lz-string@1.5.0: {} lz-string@1.5.0: {}
@@ -3631,9 +3672,9 @@ snapshots:
min-indent@1.0.1: {} min-indent@1.0.1: {}
minimatch@10.2.1: minimatch@3.1.2:
dependencies: dependencies:
brace-expansion: 5.0.2 brace-expansion: 1.1.12
minimatch@9.0.5: minimatch@9.0.5:
dependencies: dependencies:
@@ -3668,6 +3709,10 @@ snapshots:
dependencies: dependencies:
p-limit: 3.1.0 p-limit: 3.1.0
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
parse5@8.0.0: parse5@8.0.0:
dependencies: dependencies:
entities: 6.0.1 entities: 6.0.1
@@ -3736,6 +3781,8 @@ snapshots:
require-from-string@2.0.2: {} require-from-string@2.0.2: {}
resolve-from@4.0.0: {}
rollup@4.55.1: rollup@4.55.1:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@@ -3803,6 +3850,8 @@ snapshots:
dependencies: dependencies:
min-indent: 1.0.1 min-indent: 1.0.1
strip-json-comments@3.1.1: {}
style-mod@4.1.3: {} style-mod@4.1.3: {}
supports-color@7.2.0: supports-color@7.2.0:
@@ -3891,13 +3940,13 @@ snapshots:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
typescript-eslint@8.53.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.6.3): typescript-eslint@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.6.3):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.53.0(@typescript-eslint/parser@8.53.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.6.3))(eslint@10.0.0(jiti@2.6.1))(typescript@5.6.3) '@typescript-eslint/eslint-plugin': 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.6.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.6.3)
'@typescript-eslint/parser': 8.53.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.6.3) '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.6.3)
'@typescript-eslint/typescript-estree': 8.53.0(typescript@5.6.3) '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.6.3)
'@typescript-eslint/utils': 8.53.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.6.3) '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.6.3)
eslint: 10.0.0(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
typescript: 5.6.3 typescript: 5.6.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
+1 -75
View File
@@ -1636,7 +1636,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]] [[package]]
name = "hikari-desktop" name = "hikari-desktop"
version = "1.5.1" version = "1.3.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"dirs 5.0.1", "dirs 5.0.1",
@@ -1658,8 +1658,6 @@ dependencies = [
"tauri-plugin-store", "tauri-plugin-store",
"tempfile", "tempfile",
"tokio", "tokio",
"tracing",
"tracing-subscriber",
"uuid 1.19.0", "uuid 1.19.0",
"windows 0.62.2", "windows 0.62.2",
] ]
@@ -2247,15 +2245,6 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]] [[package]]
name = "matches" name = "matches"
version = "0.1.10" version = "0.1.10"
@@ -2412,15 +2401,6 @@ dependencies = [
"zbus", "zbus",
] ]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@@ -3899,15 +3879,6 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]] [[package]]
name = "shared_child" name = "shared_child"
version = "1.1.1" version = "1.1.1"
@@ -4724,15 +4695,6 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "tiff" name = "tiff"
version = "0.10.3" version = "0.10.3"
@@ -5024,36 +4986,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
] ]
[[package]] [[package]]
@@ -5235,12 +5167,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
+1 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "hikari-desktop" name = "hikari-desktop"
version = "1.5.1" version = "1.3.0"
description = "Hikari - Claude Code Visual Assistant" description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"] authors = ["Naomi Carrigan"]
edition = "2021" edition = "2021"
@@ -33,8 +33,6 @@ semver = "1"
chrono = { version = "0.4.43", features = ["serde"] } chrono = { version = "0.4.43", features = ["serde"] }
discord-rich-presence = "0.2" discord-rich-presence = "0.2"
dirs = "5" 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 = [
+1 -10
View File
@@ -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"
] ]
} }
+12 -12
View File
@@ -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) {
@@ -2244,7 +2244,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 +2252,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 +2272,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
-124
View File
@@ -173,127 +173,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());
}
}
+5 -1212
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -28,7 +28,6 @@ pub struct ClaudeStartOptions {
} }
#[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>,
@@ -72,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,
@@ -132,6 +134,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,
@@ -239,6 +242,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);
@@ -271,6 +275,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,
-157
View File
@@ -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));
}
}
}
+75 -35
View File
@@ -1,13 +1,18 @@
use discord_rich_presence::activity::{Activity, Assets, Timestamps}; use discord_rich_presence::activity::{Activity, Assets, Timestamps};
use discord_rich_presence::{DiscordIpc, DiscordIpcClient}; use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
use parking_lot::RwLock; use parking_lot::RwLock;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Manager};
pub struct DiscordRpcManager { pub struct DiscordRpcManager {
client: Arc<RwLock<Option<DiscordIpcClient>>>, client: Arc<RwLock<Option<DiscordIpcClient>>>,
session_name: Arc<RwLock<String>>, session_name: Arc<RwLock<String>>,
model: Arc<RwLock<String>>, model: Arc<RwLock<String>>,
started_at: Arc<RwLock<i64>>, started_at: Arc<RwLock<i64>>,
log_path: Arc<RwLock<Option<PathBuf>>>,
} }
impl DiscordRpcManager { impl DiscordRpcManager {
@@ -17,47 +22,82 @@ impl DiscordRpcManager {
session_name: Arc::new(RwLock::new(String::new())), session_name: Arc::new(RwLock::new(String::new())),
model: Arc::new(RwLock::new(String::new())), model: Arc::new(RwLock::new(String::new())),
started_at: Arc::new(RwLock::new(0)), started_at: Arc::new(RwLock::new(0)),
log_path: Arc::new(RwLock::new(None)),
}
}
pub fn set_app_handle(&self, app_handle: &AppHandle) {
if let Ok(app_data_dir) = app_handle.path().app_data_dir() {
// Ensure the directory exists
if let Err(e) = std::fs::create_dir_all(&app_data_dir) {
eprintln!("Failed to create app data directory: {}", e);
return;
}
let log_path = app_data_dir.join("hikari_discord_rpc.log");
*self.log_path.write() = Some(log_path.clone());
self.log(&format!(
"Log file initialised at: {}",
log_path.display()
));
}
}
pub fn log(&self, message: &str) {
let log_path_guard = self.log_path.read();
let path = match log_path_guard.as_ref() {
Some(p) => p.clone(),
None => PathBuf::from("hikari_discord_rpc.log"),
};
drop(log_path_guard);
if let Ok(mut file) = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let _ = writeln!(file, "[{}] {}", timestamp, message);
} }
} }
pub fn init(&self, initial_session_name: String, initial_model: String, started_at: i64) -> Result<(), String> { pub fn init(&self, initial_session_name: String, initial_model: String, started_at: i64) -> Result<(), String> {
tracing::debug!("Attempting to initialize Discord RPC..."); self.log("Attempting to initialize Discord RPC...");
tracing::debug!("Application ID: 1391117878182281316"); self.log("DEBUG: Application ID: 1391117878182281316");
tracing::debug!("Initial session: '{}', model: '{}', timestamp: {}", self.log(&format!("DEBUG: Initial session: '{}', model: '{}', timestamp: {}",
initial_session_name, initial_model, started_at); initial_session_name, initial_model, started_at));
let mut client = DiscordIpcClient::new("1391117878182281316") let mut client = DiscordIpcClient::new("1391117878182281316")
.map_err(|e| { .map_err(|e| {
let error_msg = format!("Failed to create Discord RPC client: {} (is Discord running?)", e); let error_msg = format!("Failed to create Discord RPC client: {} (is Discord running?)", e);
tracing::error!("{}", error_msg); self.log(&format!("ERROR: {}", error_msg));
error_msg error_msg
})?; })?;
tracing::debug!("DiscordIpcClient created successfully"); self.log("DEBUG: DiscordIpcClient created successfully");
client client
.connect() .connect()
.map_err(|e| { .map_err(|e| {
let error_msg = format!("Failed to connect to Discord RPC: {} (ensure Discord is running)", e); let error_msg = format!("Failed to connect to Discord RPC: {} (ensure Discord is running)", e);
tracing::error!("{}", error_msg); self.log(&format!("ERROR: {}", error_msg));
error_msg error_msg
})?; })?;
tracing::debug!("Connected to Discord IPC socket"); self.log("DEBUG: Connected to Discord IPC socket");
// Set initial activity immediately after connecting // Set initial activity immediately after connecting
tracing::debug!("Building initial activity..."); self.log("DEBUG: Building initial activity...");
let state_text = format!("Model: {}", initial_model); let state_text = format!("Model: {}", initial_model);
let assets = Assets::new() let assets = Assets::new()
.large_image("hikari") .large_image("hikari")
.large_text("Hikari - Claude Code Assistant"); .large_text("Hikari - Claude Code Assistant");
tracing::debug!("Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'"); self.log("DEBUG: Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
let timestamps = Timestamps::new() let timestamps = Timestamps::new()
.start(started_at); .start(started_at);
tracing::debug!("Timestamps created - start: {}", started_at); self.log(&format!("DEBUG: Timestamps created - start: {}", started_at));
let activity = Activity::new() let activity = Activity::new()
.details(initial_session_name.as_str()) .details(initial_session_name.as_str())
@@ -65,19 +105,19 @@ impl DiscordRpcManager {
.assets(assets) .assets(assets)
.timestamps(timestamps); .timestamps(timestamps);
tracing::debug!("Activity created - details: '{}', state: '{}'", self.log(&format!("DEBUG: Activity created - details: '{}', state: '{}'",
initial_session_name, state_text); initial_session_name, state_text));
tracing::debug!("Attempting to set initial activity..."); self.log("DEBUG: Attempting to set initial activity...");
client client
.set_activity(activity) .set_activity(activity)
.map_err(|e| { .map_err(|e| {
let error_msg = format!("Failed to set initial Discord RPC activity: {}", e); let error_msg = format!("Failed to set initial Discord RPC activity: {}", e);
tracing::error!("{}", error_msg); self.log(&format!("ERROR: {}", error_msg));
error_msg error_msg
})?; })?;
tracing::debug!("Initial activity set successfully!"); self.log("DEBUG: Initial activity set successfully!");
// Store the client and initial state // Store the client and initial state
*self.client.write() = Some(client); *self.client.write() = Some(client);
@@ -85,8 +125,8 @@ impl DiscordRpcManager {
*self.model.write() = initial_model.clone(); *self.model.write() = initial_model.clone();
*self.started_at.write() = started_at; *self.started_at.write() = started_at;
tracing::info!("Discord RPC connected successfully with initial activity: session='{}', model='{}'", self.log(&format!("Discord RPC connected successfully with initial activity: session='{}', model='{}'",
initial_session_name, initial_model); initial_session_name, initial_model));
Ok(()) Ok(())
} }
@@ -96,37 +136,37 @@ impl DiscordRpcManager {
model: String, model: String,
started_at: i64, started_at: i64,
) -> Result<(), String> { ) -> Result<(), String> {
tracing::debug!("update() called with session='{}', model='{}', timestamp={}", self.log(&format!("DEBUG: update() called with session='{}', model='{}', timestamp={}",
session_name, model, started_at); session_name, model, started_at));
*self.session_name.write() = session_name.clone(); *self.session_name.write() = session_name.clone();
*self.model.write() = model.clone(); *self.model.write() = model.clone();
*self.started_at.write() = started_at; *self.started_at.write() = started_at;
tracing::debug!("State variables updated"); self.log("DEBUG: State variables updated");
let mut client_guard = self.client.write(); let mut client_guard = self.client.write();
let client = client_guard let client = client_guard
.as_mut() .as_mut()
.ok_or_else(|| { .ok_or_else(|| {
let error_msg = "Discord RPC client not initialized".to_string(); let error_msg = "Discord RPC client not initialized".to_string();
tracing::error!("{}", error_msg); self.log(&format!("ERROR: {}", error_msg));
error_msg error_msg
})?; })?;
tracing::debug!("Client lock acquired"); self.log("DEBUG: Client lock acquired");
let state_text = format!("Model: {}", model); let state_text = format!("Model: {}", model);
let assets = Assets::new() let assets = Assets::new()
.large_image("hikari") .large_image("hikari")
.large_text("Hikari - Claude Code Assistant"); .large_text("Hikari - Claude Code Assistant");
tracing::debug!("Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'"); self.log("DEBUG: Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
let timestamps = Timestamps::new() let timestamps = Timestamps::new()
.start(started_at); .start(started_at);
tracing::debug!("Timestamps created - start: {}", started_at); self.log(&format!("DEBUG: Timestamps created - start: {}", started_at));
let activity = Activity::new() let activity = Activity::new()
.details(session_name.as_str()) .details(session_name.as_str())
@@ -134,38 +174,38 @@ impl DiscordRpcManager {
.assets(assets) .assets(assets)
.timestamps(timestamps); .timestamps(timestamps);
tracing::debug!("Activity created - details: '{}', state: '{}'", self.log(&format!("DEBUG: Activity created - details: '{}', state: '{}'",
session_name, state_text); session_name, state_text));
tracing::debug!("Attempting to set activity..."); self.log("DEBUG: Attempting to set activity...");
client client
.set_activity(activity) .set_activity(activity)
.map_err(|e| { .map_err(|e| {
let error_msg = format!("Failed to update Discord RPC: {}", e); let error_msg = format!("Failed to update Discord RPC: {}", e);
tracing::error!("{}", error_msg); self.log(&format!("ERROR: {}", error_msg));
error_msg error_msg
})?; })?;
tracing::info!("Updated Discord RPC: session='{}', model='{}'", session_name, model); self.log(&format!("Updated Discord RPC: session='{}', model='{}'", session_name, model));
Ok(()) Ok(())
} }
pub fn stop(&self) -> Result<(), String> { pub fn stop(&self) -> Result<(), String> {
tracing::debug!("stop() called"); self.log("DEBUG: stop() called");
let mut client_guard = self.client.write(); let mut client_guard = self.client.write();
if let Some(mut client) = client_guard.take() { if let Some(mut client) = client_guard.take() {
tracing::debug!("Client found, attempting to close..."); self.log("DEBUG: Client found, attempting to close...");
client client
.close() .close()
.map_err(|e| { .map_err(|e| {
let error_msg = format!("Failed to close Discord RPC: {}", e); let error_msg = format!("Failed to close Discord RPC: {}", e);
tracing::error!("{}", error_msg); self.log(&format!("ERROR: {}", error_msg));
error_msg error_msg
})?; })?;
tracing::info!("Discord RPC stopped successfully"); self.log("Discord RPC stopped successfully");
} else { } else {
tracing::debug!("No client to stop (already stopped or never initialized)"); self.log("DEBUG: No client to stop (already stopped or never initialized)");
} }
Ok(()) Ok(())
} }
+14 -42
View File
@@ -4,7 +4,6 @@ mod clipboard;
mod commands; mod commands;
mod config; mod config;
mod cost_tracking; mod cost_tracking;
mod debug_logger;
mod discord_rpc; mod discord_rpc;
mod git; mod git;
mod notifications; mod notifications;
@@ -25,7 +24,6 @@ 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 discord_rpc::DiscordRpcManager;
use git::*; use git::*;
use notifications::*; use notifications::*;
@@ -33,11 +31,9 @@ use quick_actions::*;
use sessions::*; use sessions::*;
use snippets::*; use snippets::*;
use std::sync::Arc; use std::sync::Arc;
use tauri::{Emitter, Manager}; use tauri::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::*;
@@ -62,43 +58,35 @@ pub fn run() {
.manage(temp_manager.clone()) .manage(temp_manager.clone())
.manage(discord_rpc.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());
// Initialize the app handle in the Discord RPC manager for logging
discord_rpc.set_app_handle(app.handle());
// 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();
if let Some(window) = app_handle.get_webview_window("main") {
// Emit event to frontend to show confirmation modal let _ = window.hide();
if let Some(window) = app_handle.get_webview_window("main") { }
let _ = window.emit("window-close-requested", ());
} }
} }
} }
@@ -192,23 +180,7 @@ pub fn run() {
init_discord_rpc, init_discord_rpc,
update_discord_rpc, update_discord_rpc,
stop_discord_rpc, stop_discord_rpc,
close_application, log_discord_rpc,
list_memory_files,
get_claude_version,
list_plugins,
install_plugin,
uninstall_plugin,
enable_plugin,
disable_plugin,
update_plugin,
list_marketplaces,
add_marketplace,
remove_marketplace,
list_mcp_servers,
get_mcp_server,
remove_mcp_server,
add_mcp_server,
get_mcp_server_details,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+29 -318
View File
@@ -1,83 +1,6 @@
use std::process::Command; use std::process::Command;
use tauri::command; use tauri::command;
/// Generate PowerShell script for Windows Toast Notification
fn generate_powershell_toast_script(title: &str, body: &str) -> String {
format!(
r#"
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
$APP_ID = 'Hikari Desktop'
$template = @"
<toast>
<visual>
<binding template="ToastText02">
<text id="1">{}</text>
<text id="2">{}</text>
</binding>
</visual>
<audio src="ms-winsoundevent:Notification.Default" />
</toast>
"@
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
$xml.LoadXml($template)
$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($APP_ID).Show($toast)
"#,
title.replace("\"", "`\""),
body.replace("\"", "`\"")
)
}
/// Format simple notification message
fn format_simple_notification(title: &str, body: &str) -> String {
format!("{}\n\n{}", title, body)
}
/// Build notify-send command for testing (doesn't execute)
#[cfg(test)]
fn build_notify_send_command(title: &str, body: &str) -> (String, Vec<String>) {
(
"notify-send".to_string(),
vec![
title.to_string(),
body.to_string(),
"--urgency=normal".to_string(),
"--app-name=Hikari Desktop".to_string(),
],
)
}
/// Build Windows PowerShell command for testing (doesn't execute)
#[cfg(test)]
fn build_windows_powershell_command(title: &str, body: &str) -> (String, Vec<String>) {
let script = generate_powershell_toast_script(title, body);
(
"pwsh.exe".to_string(),
vec![
"-NoProfile".to_string(),
"-WindowStyle".to_string(),
"Hidden".to_string(),
"-Command".to_string(),
script,
],
)
}
/// Build simple notification command for testing (doesn't execute)
#[cfg(test)]
fn build_simple_notification_command(title: &str, body: &str) -> (String, Vec<String>) {
let message = format_simple_notification(title, body);
(
"cmd.exe".to_string(),
vec!["/c".to_string(), "msg".to_string(), "*".to_string(), message],
)
}
#[command] #[command]
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> { pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
// Use notify-send for Linux/WSL // Use notify-send for Linux/WSL
@@ -105,7 +28,34 @@ pub async fn send_notify_send(title: String, body: String) -> Result<(), String>
#[command] #[command]
pub async fn send_windows_notification(title: String, body: String) -> Result<(), String> { pub async fn send_windows_notification(title: String, body: String) -> Result<(), String> {
// Create PowerShell script for Windows Toast Notification // Create PowerShell script for Windows Toast Notification
let ps_script = generate_powershell_toast_script(&title, &body); let ps_script = format!(
r#"
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
$APP_ID = 'Hikari Desktop'
$template = @"
<toast>
<visual>
<binding template="ToastText02">
<text id="1">{}</text>
<text id="2">{}</text>
</binding>
</visual>
<audio src="ms-winsoundevent:Notification.Default" />
</toast>
"@
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
$xml.LoadXml($template)
$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($APP_ID).Show($toast)
"#,
title.replace("\"", "`\""),
body.replace("\"", "`\"")
);
// Try PowerShell Core first (pwsh), then fall back to Windows PowerShell // Try PowerShell Core first (pwsh), then fall back to Windows PowerShell
let output = Command::new("pwsh.exe") let output = Command::new("pwsh.exe")
@@ -137,7 +87,7 @@ pub async fn send_windows_notification(title: String, body: String) -> Result<()
// Alternative: Use Windows built-in MSG command for simple notifications // 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")
.arg("/c") .arg("/c")
@@ -149,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");
}
}
+9 -14
View File
@@ -86,8 +86,6 @@ impl ContextWarning {
/// Get the context window limit (in tokens) for a given model /// Get the context window limit (in tokens) for a given model
fn get_context_window_limit(model: &str) -> u64 { fn get_context_window_limit(model: &str) -> u64 {
match model { match model {
// Claude 4.6 family - 200K standard (1M beta available via header)
"claude-opus-4-6" => 200_000,
// Claude 4.5 family - 200K standard context // Claude 4.5 family - 200K standard context
"claude-opus-4-5-20251101" "claude-opus-4-5-20251101"
| "claude-sonnet-4-5-20250929" | "claude-sonnet-4-5-20250929"
@@ -500,10 +498,7 @@ pub fn calculate_cost(
cache_read_tokens: Option<u64>, cache_read_tokens: Option<u64>,
) -> f64 { ) -> 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) // Current generation (Claude 4.5)
"claude-opus-4-6" => (5.0, 25.0),
// Previous generation (Claude 4.5)
"claude-opus-4-5-20251101" => (5.0, 25.0), "claude-opus-4-5-20251101" => (5.0, 25.0),
"claude-sonnet-4-5-20250929" => (3.0, 15.0), "claude-sonnet-4-5-20250929" => (3.0, 15.0),
"claude-haiku-4-5-20251001" => (1.0, 5.0), "claude-haiku-4-5-20251001" => (1.0, 5.0),
@@ -618,7 +613,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",
@@ -626,32 +621,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
+3 -3
View File
@@ -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;
} }
+20
View File
@@ -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 -55
View File
@@ -197,21 +197,14 @@ pub struct OutputEvent {
pub conversation_id: Option<String>, pub conversation_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<MessageCost>, 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>,
} }
@@ -255,48 +248,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>,
}
#[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::*;
@@ -418,7 +369,6 @@ mod tests {
tool_name: None, tool_name: None,
conversation_id: None, conversation_id: None,
cost: None, cost: None,
parent_tool_use_id: None,
}; };
let serialized = serde_json::to_string(&event).unwrap(); let serialized = serde_json::to_string(&event).unwrap();
@@ -438,7 +388,6 @@ mod tests {
output_tokens: 50, output_tokens: 50,
cost_usd: 0.005, cost_usd: 0.005,
}), }),
parent_tool_use_id: None,
}; };
let serialized = serde_json::to_string(&event).unwrap(); let serialized = serde_json::to_string(&event).unwrap();
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -48,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);
} }
} }
@@ -74,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 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop", "productName": "hikari-desktop",
"version": "1.5.1", "version": "1.3.0",
"identifier": "com.naomi.hikari-desktop", "identifier": "com.naomi.hikari-desktop",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
-8
View File
@@ -56,10 +56,6 @@ 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, allowed_tools: allAllowedTools,
}, },
}); });
@@ -130,10 +126,6 @@ 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, allowed_tools: allAllowedTools,
}, },
}); });
-330
View File
@@ -1,330 +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}
<span
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
agent.status
)}"
>
{getSubagentTypeLabel(agent.subagentType)}
</span>
</div>
<span
class="text-[10px] {agent.status === 'running'
? 'text-blue-400'
: 'text-[var(--text-secondary)]'}"
>
{#if agent.durationMs !== undefined}
{Math.floor(agent.durationMs / 1000)}s
{:else}
{formatDuration(agent.startedAt, agent.endedAt)}
{/if}
{#if agent.status === "running"}
<span class="inline-block w-1 h-1 bg-blue-400 rounded-full animate-pulse ml-1"
></span>
{/if}
</span>
</div>
<!-- Agent description -->
<p class="text-xs text-[var(--text-primary)] truncate" title={agent.description}>
{agent.description}
</p>
<!-- Status indicator -->
<div class="mt-1 flex items-center gap-1">
{#if agent.status === "running"}
<span class="text-[10px] text-blue-400">Running...</span>
{:else if agent.status === "completed"}
<span class="text-[10px] text-green-400">Completed</span>
{:else}
<span class="text-[10px] text-red-400">Errored / Killed</span>
{/if}
</div>
</div>
{/each}
{/if}
</div>
<!-- Footer summary -->
{#if agents.length > 0}
<div
class="px-4 py-2 border-t border-[var(--border-color)] text-[10px] text-[var(--text-secondary)]"
>
{agents.length} total &middot;
{runningAgents.length} running &middot;
{completedAgents.length} completed &middot;
{erroredAgents.length} errored
</div>
{/if}
</div>
{/if}
-68
View File
@@ -1,68 +0,0 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { onMount } from "svelte";
let version = $state("Loading...");
async function fetchVersion() {
try {
const result = await invoke<string>("get_claude_version");
version = result;
} catch (error) {
console.error("Failed to get Claude CLI version:", error);
version = "Unknown";
}
}
onMount(() => {
fetchVersion();
});
</script>
<div class="cli-version">
<svg
class="terminal-icon"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
<span class="version-text">CLI {version}</span>
</div>
<style>
.cli-version {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
font-size: 0.85rem;
font-family: var(--font-mono, monospace);
transition: all 0.2s;
}
.cli-version:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.terminal-icon {
flex-shrink: 0;
opacity: 0.7;
}
.version-text {
white-space: nowrap;
}
</style>
@@ -1,116 +0,0 @@
<script lang="ts">
interface Props {
isOpen: boolean;
hasActiveConversation: boolean;
onClose: () => void;
onMinimize: () => void;
onCancel: () => void;
}
const { isOpen, hasActiveConversation, onClose, onMinimize, onCancel }: Props = $props();
function handleKeydown(event: KeyboardEvent) {
if (!isOpen) return;
if (event.key === "Escape") {
event.preventDefault();
onCancel();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onCancel}
role="button"
tabindex="0"
onkeydown={(e) => e.key === " " && onCancel()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-md w-full"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="confirm-title"
aria-describedby="confirm-message"
tabindex="-1"
>
<div class="p-6">
<div class="flex items-start gap-4">
<div
class="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center flex-shrink-0"
>
<svg
class="w-6 h-6 text-yellow-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div class="flex-1">
<h3 id="confirm-title" class="text-lg font-semibold text-[var(--text-primary)] mb-1">
Close Hikari Desktop?
</h3>
<p id="confirm-message" class="text-sm text-[var(--text-secondary)]">
{#if hasActiveConversation}
You have an active conversation with Claude. Are you sure you want to close the
application? Your conversation history will be saved, but any in-progress tasks will
be interrupted.
{:else}
Are you sure you want to close the application?
{/if}
</p>
</div>
</div>
<div class="flex gap-3 mt-6 justify-end">
<button
onclick={onCancel}
class="px-4 py-2 text-sm font-medium text-gray-300 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Cancel
</button>
<button
onclick={onMinimize}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
Minimize to Tray
</button>
<button
onclick={onClose}
class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
>
Close Application
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
</style>
+19 -24
View File
@@ -26,6 +26,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,
@@ -51,7 +52,6 @@
budget_action: "warn", budget_action: "warn",
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true,
}); });
let showCustomThemeEditor = $state(false); let showCustomThemeEditor = $state(false);
@@ -81,20 +81,16 @@
const availableModels = [ const availableModels = [
{ value: "", label: "Default (from ~/.claude)" }, { value: "", label: "Default (from ~/.claude)" },
// Current generation (Claude 4.6) // Current generation (Claude 4.5)
{ value: "claude-opus-4-6", label: "Claude Opus 4.6 (Most Capable)" },
// Previous generation (Claude 4.5)
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5 (Recommended)" }, { value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5 (Recommended)" },
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" }, { value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" },
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5" }, { value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5 (Most Capable)" },
// Previous generation (Claude 4.x) // Previous generation (Claude 4)
{ value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" }, { 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) // Legacy (Claude 3.x)
{ value: "claude-3-7-sonnet-20250219", label: "Claude 3.7 Sonnet" }, { 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)" }, { value: "claude-3-haiku-20240307", label: "Claude 3 Haiku (Cheapest)" },
]; ];
@@ -704,22 +700,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>
<!-- Show Thinking Blocks Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.show_thinking_blocks}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
/>
<span class="text-sm text-[var(--text-primary)]">Show Extended Thinking Blocks</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Display Claude's extended thinking process in the conversation. Thinking blocks can be
expanded/collapsed to see reasoning details.
</p>
</div>
</section> </section>
<!-- Window Section --> <!-- Window Section -->
@@ -744,6 +724,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">
-330
View File
@@ -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>
-22
View File
@@ -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 {
@@ -357,10 +355,6 @@ User: ${formattedMessage}`;
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, allowed_tools: allAllowedTools,
}, },
}); });
@@ -916,9 +910,6 @@ User: ${formattedMessage}`;
</svg> </svg>
<span>Clipboard</span> <span>Clipboard</span>
</button> </button>
<CliVersion />
<SystemClock />
</div> </div>
<div class="input-row"> <div class="input-row">
@@ -1071,7 +1062,6 @@ User: ${formattedMessage}`;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-wrap: wrap;
} }
.control-button { .control-button {
@@ -1088,18 +1078,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 {
@@ -1,433 +0,0 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { onMount } from "svelte";
import { Trash2, RefreshCw, Server, Globe, Terminal } from "lucide-svelte";
interface Props {
onClose: () => void;
}
interface McpServerInfo {
name: string;
command: string | null;
url: string | null;
transport: string; // "stdio", "http", or "sse"
env: Record<string, string> | null;
status: string | null; // "Connected" or "Failed to connect"
}
const { onClose }: Props = $props();
let servers = $state<McpServerInfo[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
let selectedServer = $state<McpServerInfo | null>(null);
let isLoadingDetails = $state(false);
let actionInProgress = $state<string | null>(null);
let showAddForm = $state(false);
let serverDetails = $state<string>("");
// Add server form fields
let newServerName = $state("");
let newServerUrl = $state("");
let newServerTransport = $state("stdio");
let isAdding = $state(false);
async function loadServers(): Promise<void> {
try {
isLoading = true;
error = null;
servers = await invoke<McpServerInfo[]>("list_mcp_servers");
} catch (e) {
error = `Failed to load MCP servers: ${e}`;
console.error(error);
} finally {
isLoading = false;
}
}
async function loadServerDetails(name: string): Promise<void> {
try {
isLoadingDetails = true;
error = null;
selectedServer = await invoke<McpServerInfo>("get_mcp_server", { name });
serverDetails = await invoke<string>("get_mcp_server_details", { name });
} catch (e) {
error = `Failed to load server details: ${e}`;
console.error(error);
} finally {
isLoadingDetails = false;
}
}
async function removeServer(name: string): Promise<void> {
try {
actionInProgress = name;
error = null;
await invoke("remove_mcp_server", { name });
if (selectedServer?.name === name) {
selectedServer = null;
serverDetails = "";
}
await loadServers();
} catch (e) {
error = `Failed to remove server: ${e}`;
console.error(error);
} finally {
actionInProgress = null;
}
}
async function addServer(): Promise<void> {
if (!newServerName.trim() || !newServerUrl.trim()) return;
try {
isAdding = true;
error = null;
await invoke("add_mcp_server", {
name: newServerName.trim(),
commandOrUrl: newServerUrl.trim(),
transport: newServerTransport,
envVars: null,
headers: null,
});
newServerName = "";
newServerUrl = "";
newServerTransport = "stdio";
showAddForm = false;
await loadServers();
} catch (e) {
error = `Failed to add server: ${e}`;
console.error(error);
} finally {
isAdding = false;
}
}
function getTransportIcon(transport: string) {
switch (transport) {
case "http":
return Globe;
case "stdio":
return Terminal;
case "sse":
return Server;
default:
return Server;
}
}
function getTransportColor(transport: string) {
switch (transport) {
case "http":
return "text-blue-400";
case "stdio":
return "text-green-400";
case "sse":
return "text-purple-400";
default:
return "text-[var(--text-secondary)]";
}
}
onMount(() => {
loadServers();
});
</script>
<div
class="fixed top-0 right-0 h-full w-[700px] bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
>
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
<div class="flex items-center gap-3">
<div class="text-[var(--accent-primary)]">
<Server class="w-6 h-6" />
</div>
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">MCP Server Management</h2>
<p class="text-xs text-[var(--text-secondary)]">
{servers.length} server{servers.length !== 1 ? "s" : ""} configured
</p>
</div>
</div>
<button
onclick={onClose}
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
aria-label="Close MCP panel"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<!-- Add Server Button -->
<div class="p-4 border-b border-[var(--border-color)]">
<button
onclick={() => (showAddForm = !showAddForm)}
class="w-full px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 transition-opacity flex items-center justify-center gap-2"
>
<Server class="w-4 h-4" />
{showAddForm ? "Cancel" : "Add New Server"}
</button>
</div>
<!-- Add Server Form -->
{#if showAddForm}
<div
class="mx-4 mt-4 p-4 bg-[var(--bg-secondary)]/50 border border-[var(--border-color)] rounded-lg"
>
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-3">Add MCP Server</h3>
<div class="space-y-3">
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
>Server Name</label
>
<input
type="text"
bind:value={newServerName}
placeholder="my-server"
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
/>
</div>
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
>Transport</label
>
<select
bind:value={newServerTransport}
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
>
<option value="stdio">STDIO</option>
<option value="http">HTTP</option>
<option value="sse">SSE</option>
</select>
</div>
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1">
{newServerTransport === "stdio" ? "Command" : "URL"}
</label>
<input
type="text"
bind:value={newServerUrl}
placeholder={newServerTransport === "stdio"
? "npx my-mcp-server"
: "https://mcp.example.com"}
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
/>
</div>
<button
onclick={addServer}
disabled={isAdding || !newServerName.trim() || !newServerUrl.trim()}
class="w-full px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{#if isAdding}
<RefreshCw class="w-4 h-4 animate-spin" />
{:else}
<Server class="w-4 h-4" />
{/if}
Add Server
</button>
</div>
</div>
{/if}
<!-- Error Display -->
{#if error}
<div class="mx-4 mt-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
<p class="text-sm text-red-400">{error}</p>
</div>
{/if}
<!-- Content -->
<div class="flex-1 overflow-y-auto p-4 flex gap-4">
<!-- Server List -->
<div class="flex-1">
{#if isLoading}
<div class="flex items-center justify-center h-full text-[var(--text-secondary)]">
<RefreshCw class="w-8 h-8 animate-spin" />
</div>
{:else if servers.length === 0}
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
<Server class="w-16 h-16 mb-4 opacity-50" />
<p class="text-center">No MCP servers configured</p>
<p class="text-sm text-center mt-2">Add servers via Settings</p>
</div>
{:else}
<div class="space-y-2">
{#each servers as server (server.name)}
<button
onclick={() => loadServerDetails(server.name)}
class="w-full bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all text-left"
class:border-[var(--accent-primary)]={selectedServer?.name === server.name}
>
<div class="flex items-start justify-between">
<div class="flex-1">
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
<svelte:component
this={getTransportIcon(server.transport)}
class="w-4 h-4 {getTransportColor(server.transport)}"
/>
{server.name}
{#if server.status}
{#if server.status.includes("Connected")}
<span
class="px-2 py-0.5 bg-[var(--success-color)]/20 text-[var(--success-color)] text-xs rounded border border-[var(--success-color)]/30"
>
</span>
{:else}
<span
class="px-2 py-0.5 bg-red-500/20 text-red-400 text-xs rounded border border-red-500/30"
>
</span>
{/if}
{/if}
</h4>
<p class="text-xs text-[var(--text-secondary)] mt-1">
{server.transport.toUpperCase()}
{#if server.url}
{server.url}
{:else if server.command}
{server.command}
{/if}
</p>
</div>
</div>
</button>
{/each}
</div>
{/if}
</div>
<!-- Server Details Panel -->
{#if selectedServer}
<div
class="w-80 bg-[var(--bg-secondary)]/50 rounded-lg p-4 border border-[var(--border-color)]"
>
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Server Details</h3>
{#if isLoadingDetails}
<div class="flex items-center justify-center h-32">
<RefreshCw class="w-6 h-6 animate-spin text-[var(--text-secondary)]" />
</div>
{:else}
<div class="space-y-4">
<!-- Name -->
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Name</label
>
<p class="text-sm text-[var(--text-primary)] mt-1">{selectedServer.name}</p>
</div>
<!-- Transport -->
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Transport</label
>
<p class="text-sm text-[var(--text-primary)] mt-1 flex items-center gap-2">
<svelte:component
this={getTransportIcon(selectedServer.transport)}
class="w-4 h-4 {getTransportColor(selectedServer.transport)}"
/>
{selectedServer.transport.toUpperCase()}
</p>
</div>
<!-- URL or Command -->
{#if selectedServer.url}
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>URL</label
>
<p
class="text-sm text-[var(--text-primary)] mt-1 break-all font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
>
{selectedServer.url}
</p>
</div>
{/if}
{#if selectedServer.command}
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Command</label
>
<p
class="text-sm text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
>
{selectedServer.command}
</p>
</div>
{/if}
<!-- Environment Variables -->
{#if selectedServer.env}
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Environment</label
>
<pre
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto">{JSON.stringify(
selectedServer.env,
null,
2
)}</pre>
</div>
{/if}
<!-- Full Server Details -->
{#if serverDetails}
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Full Details</label
>
<pre
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto whitespace-pre-wrap">{serverDetails}</pre>
</div>
{/if}
<!-- Actions -->
<div class="pt-4 border-t border-[var(--border-color)]">
<button
onclick={() => selectedServer && removeServer(selectedServer.name)}
disabled={actionInProgress === selectedServer?.name}
class="w-full px-4 py-2 bg-red-500/20 border border-red-500/30 rounded-lg text-sm text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<Trash2 class="w-4 h-4" />
Remove Server
</button>
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>
<style>
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>
@@ -1,458 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import Markdown from "./Markdown.svelte";
let memoryFiles: string[] = $state([]);
let selectedFile: string | null = $state(null);
let fileContent: string = $state("");
let isLoading = $state(false);
let error: string | null = $state(null);
let isPanelOpen = $state(false);
interface MemoryFilesResponse {
files: string[];
}
async function loadMemoryFiles() {
isLoading = true;
error = null;
try {
const response = await invoke<MemoryFilesResponse>("list_memory_files");
memoryFiles = response.files;
} catch (e) {
error = `Failed to load memory files: ${e}`;
console.error(error);
} finally {
isLoading = false;
}
}
async function loadFileContent(filePath: string) {
isLoading = true;
error = null;
try {
// Use our backend command instead of Tauri plugin to handle WSL paths
const content = await invoke<string>("read_file_content", { path: filePath });
fileContent = content;
selectedFile = filePath;
} catch (e) {
error = `Failed to read file: ${e}`;
console.error(error);
fileContent = "";
} finally {
isLoading = false;
}
}
function getFileName(path: string): string {
return path.split("/").pop() || path;
}
function togglePanel() {
isPanelOpen = !isPanelOpen;
if (isPanelOpen && memoryFiles.length === 0) {
loadMemoryFiles();
}
}
onMount(() => {
// Don't load on mount - only when panel is opened
});
</script>
<button class="memory-toggle" onclick={togglePanel} title="Memory Browser">
<svg
class="icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
<span class="label">Memory</span>
</button>
{#if isPanelOpen}
<div class="memory-panel">
<div class="panel-header">
<div class="header-title">
<svg
class="header-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
<h3>Memory Files</h3>
</div>
<button class="close-btn" onclick={togglePanel} title="Close">
<svg
class="close-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="panel-content">
{#if isLoading && memoryFiles.length === 0}
<div class="loading">
<svg
class="spinner"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Loading memory files...
</div>
{:else if error}
<div class="error">
<p>{error}</p>
<button class="retry-btn" onclick={loadMemoryFiles}>Retry</button>
</div>
{:else if memoryFiles.length === 0}
<div class="empty">
<p>No memory files found.</p>
<p class="hint">
Memory files are created automatically as I learn from our conversations!
</p>
</div>
{:else}
<div class="panel-layout">
<div class="file-list">
{#each memoryFiles as file (file)}
<button
class="file-item"
class:active={selectedFile === file}
onclick={() => loadFileContent(file)}
>
<svg
class="file-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span class="file-name">{getFileName(file)}</span>
</button>
{/each}
</div>
<div class="file-viewer">
{#if selectedFile && fileContent}
<div class="viewer-header">
<h4>{getFileName(selectedFile)}</h4>
</div>
<div class="viewer-content">
<Markdown content={fileContent} />
</div>
{:else if selectedFile && isLoading}
<div class="loading-file">
<svg
class="spinner"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Loading file...
</div>
{:else}
<div class="no-selection">
<p>Select a memory file to view its contents</p>
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}
<style>
.memory-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
}
.memory-toggle:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.icon {
width: 1.25rem;
height: 1.25rem;
}
.label {
font-size: 0.875rem;
font-weight: 500;
}
.memory-panel {
position: fixed;
top: 0;
right: 0;
width: 600px;
height: 100vh;
background: var(--bg-primary);
border-left: 1px solid var(--border-color);
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.header-title {
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-icon {
width: 1.5rem;
height: 1.5rem;
color: var(--accent-primary);
}
.panel-header h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.close-btn {
padding: 0.5rem;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: 4px;
transition: all 0.2s ease;
}
.close-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.close-icon {
width: 1.25rem;
height: 1.25rem;
}
.panel-content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.loading,
.error,
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 3rem 1.5rem;
text-align: center;
color: var(--text-secondary);
}
.spinner {
width: 2.5rem;
height: 2.5rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.error p {
color: var(--terminal-error, #f87171);
}
.retry-btn {
padding: 0.5rem 1rem;
background: var(--accent-primary);
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
}
.retry-btn:hover {
opacity: 0.9;
}
.hint {
font-size: 0.875rem;
font-style: italic;
max-width: 400px;
}
.panel-layout {
display: grid;
grid-template-columns: 200px 1fr;
gap: 1.5rem;
height: 100%;
}
.file-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.file-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
cursor: pointer;
text-align: left;
transition: all 0.2s ease;
}
.file-item:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.file-item.active {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: white;
}
.file-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
.file-name {
font-size: 0.875rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-viewer {
display: flex;
flex-direction: column;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.viewer-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-primary);
}
.viewer-header h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.viewer-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
color: var(--text-primary);
}
.loading-file,
.no-selection {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 3rem 1.5rem;
color: var(--text-secondary);
}
</style>
+101 -198
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { SvelteSet } from "svelte/reactivity";
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 type { PermissionRequest } from "$lib/types/messages"; import type { PermissionRequest } from "$lib/types/messages";
@@ -9,16 +8,13 @@
import { conversationsStore } from "$lib/stores/conversations"; import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config"; import { configStore } from "$lib/stores/config";
let permissions: PermissionRequest[] = $state([]); let permission: PermissionRequest | null = $state(null);
let selectedPermissions = new SvelteSet<string>();
let grantedToolsList: string[] = $state([]); let grantedToolsList: string[] = $state([]);
let workingDirectory = $state(""); let workingDirectory = $state("");
conversationsStore.pendingPermissions.subscribe((perms) => { claudeStore.pendingPermission.subscribe((perm) => {
permissions = perms; permission = perm;
// When new permissions arrive, select all by default if (perm) {
if (perms.length > 0) {
selectedPermissions = new SvelteSet(perms.map((p) => p.id));
characterState.setState("permission"); characterState.setState("permission");
} }
}); });
@@ -32,103 +28,80 @@
}); });
async function handleApproveAndReconnect() { async function handleApproveAndReconnect() {
const selectedPerms = permissions.filter((p) => selectedPermissions.has(p.id)); if (permission) {
// Capture conversation history before clearing/reconnecting
const conversationHistory = claudeStore.getConversationHistory();
const approvedTool = permission.tool;
const toolInput = permission.input;
if (selectedPerms.length === 0) { claudeStore.grantTool(approvedTool);
claudeStore.addLine("system", "No permissions selected to approve"); const newGrantedTools = [...grantedToolsList, approvedTool];
claudeStore.addLine(
"system",
`Permission granted for: ${approvedTool}. Reconnecting with context...`
);
claudeStore.clearPermission(); claudeStore.clearPermission();
characterState.setTemporaryState("idle", 1000);
return;
}
// Capture conversation history before clearing/reconnecting // Stop current session and reconnect with new permissions
const conversationHistory = claudeStore.getConversationHistory(); try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
// Grant all selected tools // Prevent stats reset on reconnection
const newlyGrantedTools: string[] = []; setSkipNextGreeting(true);
for (const perm of selectedPerms) {
if (!grantedToolsList.includes(perm.tool)) {
claudeStore.grantTool(perm.tool);
newlyGrantedTools.push(perm.tool);
}
}
const newGrantedTools = [...grantedToolsList, ...newlyGrantedTools]; await invoke("stop_claude", { conversationId });
const toolNames = selectedPerms.map((p) => p.tool).join(", ");
claudeStore.addLine( // Small delay to ensure clean shutdown
"system", await new Promise((resolve) => setTimeout(resolve, 500));
`Permission granted for ${selectedPerms.length} tool(s): ${toolNames}. Reconnecting with context...`
);
claudeStore.clearPermission();
// Stop current session and reconnect with new permissions await invoke("start_claude", {
try { conversationId,
const conversationId = get(claudeStore.activeConversationId); options: {
if (!conversationId) { working_dir: workingDirectory || "/home/naomi",
throw new Error("No active conversation"); allowed_tools: newGrantedTools,
} },
});
// Prevent stats reset on reconnection // Update Discord RPC when reconnecting after permission grant
setSkipNextGreeting(true); const config = configStore.getConfig();
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
await invoke("stop_claude", { conversationId }); // Wait for connection to establish
await new Promise((resolve) => setTimeout(resolve, 1000));
// Small delay to ensure clean shutdown // Send conversation context to restore state
await new Promise((resolve) => setTimeout(resolve, 500)); if (conversationHistory) {
const contextMessage = `[CONTEXT RESTORATION]
const config = configStore.getConfig(); I just granted you permission to use the ${approvedTool} tool. Here's our conversation so far:
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDirectory || "/home/naomi",
model: config.model || null,
api_key: config.api_key || null,
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: newGrantedTools,
},
});
// Update Discord RPC when reconnecting after permission grant
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
// Wait for connection to establish
await new Promise((resolve) => setTimeout(resolve, 1000));
// Send conversation context to restore state
if (conversationHistory) {
const blockedActions = selectedPerms
.map((p) => `- ${p.tool} with input:\n${JSON.stringify(p.input, null, 2)}`)
.join("\n\n");
const contextMessage = `[CONTEXT RESTORATION]
I just granted you permission to use ${selectedPerms.length} tool(s): ${toolNames}. Here's our conversation so far:
${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,
}); });
}
} catch (error) {
console.error("Failed to reconnect:", error);
claudeStore.addLine("error", `Reconnect failed: ${error}`);
} }
characterState.setTemporaryState("success", 2000);
} catch (error) {
console.error("Failed to reconnect:", error);
claudeStore.addLine("error", `Reconnect failed: ${error}`);
} }
characterState.setTemporaryState("success", 2000);
} }
function handleDismiss() { function handleDismiss() {
@@ -149,24 +122,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 (!permission) return;
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
@@ -180,126 +137,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 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)} {/if}
onkeydown={(e) => { </div>
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
togglePermission(perm.id);
}
}}
>
<div class="flex items-start gap-3">
<div class="mt-1">
<input
type="checkbox"
checked={selectedPermissions.has(perm.id)}
onchange={() => togglePermission(perm.id)}
class="w-4 h-4 accent-green-500"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
<span class="text-[var(--accent-primary)] font-mono text-sm font-medium">
{perm.tool}
</span>
{#if isToolAlreadyGranted(perm.tool)}
<span class="text-xs text-green-400 bg-green-500/20 px-2 py-0.5 rounded">
Already Granted
</span>
{/if}
</div>
<div class="text-sm text-[var(--text-secondary)] mb-2">
{perm.description}
</div>
{#if Object.keys(perm.input).length > 0}
<details class="text-xs">
<summary
class="cursor-pointer text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
View details
</summary>
<pre
class="mt-2 px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] overflow-x-auto max-h-32">{formatInput(
perm.input
)}</pre>
</details>
{/if}
</div>
</div>
</div>
{/each}
</div> </div>
<div class="mb-4">
<div class="text-sm text-[var(--text-secondary)] mb-1">Description</div>
<div class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--text-primary)]">
{permission.description}
</div>
</div>
{#if Object.keys(permission.input).length > 0}
<div class="mb-6">
<div class="text-sm text-[var(--text-secondary)] mb-1">Details</div>
<pre
class="px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] text-xs overflow-x-auto max-h-32">{formatInput(
permission.input
)}</pre>
</div>
{/if}
<div class="flex gap-3"> <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,447 +0,0 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { onMount } from "svelte";
import { Download, Trash2, Power, PowerOff, RefreshCw } from "lucide-svelte";
interface Props {
onClose: () => void;
}
interface PluginInfo {
name: string;
version: string;
description: string | null;
enabled: boolean;
}
interface MarketplaceInfo {
name: string;
source: string;
}
const { onClose }: Props = $props();
let plugins = $state<PluginInfo[]>([]);
let marketplaces = $state<MarketplaceInfo[]>([]);
let isLoading = $state(true);
let isLoadingMarketplaces = $state(false);
let error = $state<string | null>(null);
let newPluginName = $state("");
let isInstalling = $state(false);
let actionInProgress = $state<string | null>(null);
let showMarketplaces = $state(false);
let newMarketplaceSource = $state("");
let isAddingMarketplace = $state(false);
async function loadPlugins(): Promise<void> {
try {
isLoading = true;
error = null;
plugins = await invoke<PluginInfo[]>("list_plugins");
} catch (e) {
error = `Failed to load plugins: ${e}`;
console.error(error);
} finally {
isLoading = false;
}
}
async function loadMarketplaces(): Promise<void> {
try {
isLoadingMarketplaces = true;
error = null;
marketplaces = await invoke<MarketplaceInfo[]>("list_marketplaces");
} catch (e) {
error = `Failed to load marketplaces: ${e}`;
console.error(error);
} finally {
isLoadingMarketplaces = false;
}
}
async function installPlugin(): Promise<void> {
if (!newPluginName.trim()) return;
try {
isInstalling = true;
error = null;
await invoke("install_plugin", { pluginName: newPluginName.trim() });
newPluginName = "";
await loadPlugins();
} catch (e) {
error = `Failed to install plugin: ${e}`;
console.error(error);
} finally {
isInstalling = false;
}
}
async function uninstallPlugin(pluginName: string): Promise<void> {
try {
actionInProgress = pluginName;
error = null;
await invoke("uninstall_plugin", { pluginName });
await loadPlugins();
} catch (e) {
error = `Failed to uninstall plugin: ${e}`;
console.error(error);
} finally {
actionInProgress = null;
}
}
async function togglePlugin(plugin: PluginInfo): Promise<void> {
try {
actionInProgress = plugin.name;
error = null;
if (plugin.enabled) {
await invoke("disable_plugin", { pluginName: plugin.name });
} else {
await invoke("enable_plugin", { pluginName: plugin.name });
}
await loadPlugins();
} catch (e) {
error = `Failed to ${plugin.enabled ? "disable" : "enable"} plugin: ${e}`;
console.error(error);
} finally {
actionInProgress = null;
}
}
async function updatePlugin(pluginName: string): Promise<void> {
try {
actionInProgress = pluginName;
error = null;
await invoke("update_plugin", { pluginName });
await loadPlugins();
} catch (e) {
error = `Failed to update plugin: ${e}`;
console.error(error);
} finally {
actionInProgress = null;
}
}
async function addMarketplace(): Promise<void> {
if (!newMarketplaceSource.trim()) return;
try {
isAddingMarketplace = true;
error = null;
await invoke("add_marketplace", { source: newMarketplaceSource.trim() });
newMarketplaceSource = "";
await loadMarketplaces();
} catch (e) {
error = `Failed to add marketplace: ${e}`;
console.error(error);
} finally {
isAddingMarketplace = false;
}
}
async function removeMarketplace(name: string): Promise<void> {
try {
actionInProgress = name;
error = null;
await invoke("remove_marketplace", { name });
await loadMarketplaces();
} catch (e) {
error = `Failed to remove marketplace: ${e}`;
console.error(error);
} finally {
actionInProgress = null;
}
}
onMount(() => {
loadPlugins();
});
</script>
<div
class="fixed top-0 right-0 h-full w-[600px] bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
>
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
<div class="flex items-center gap-3">
<div class="text-[var(--accent-primary)]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Plugin Management</h2>
<p class="text-xs text-[var(--text-secondary)]">
{plugins.length} plugin{plugins.length !== 1 ? "s" : ""} installed
</p>
</div>
</div>
<button
onclick={onClose}
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
aria-label="Close plugin panel"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<!-- Install Plugin Section -->
<div class="p-4 border-b border-[var(--border-color)]">
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-2">Install New Plugin</h3>
<p class="text-xs text-[var(--text-secondary)] mb-3">
Enter plugin name (e.g., "macrodata" or "macrodata@macrodata" for specific marketplace)
</p>
<div class="flex gap-2">
<input
type="text"
bind:value={newPluginName}
placeholder="plugin-name or plugin@marketplace"
class="flex-1 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
onkeydown={(e) => e.key === "Enter" && installPlugin()}
disabled={isInstalling}
/>
<button
onclick={installPlugin}
disabled={isInstalling || !newPluginName.trim()}
class="px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
>
{#if isInstalling}
<RefreshCw class="w-4 h-4 animate-spin" />
{:else}
<Download class="w-4 h-4" />
{/if}
Install
</button>
</div>
</div>
<!-- Marketplace Management Section -->
<div class="p-4 border-b border-[var(--border-color)]">
<button
onclick={() => {
showMarketplaces = !showMarketplaces;
if (showMarketplaces && marketplaces.length === 0) {
loadMarketplaces();
}
}}
class="w-full text-left flex items-center justify-between text-sm font-medium text-[var(--text-primary)] hover:text-[var(--accent-primary)] transition-colors"
>
<span>Manage Marketplaces</span>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 transition-transform"
class:rotate-180={showMarketplaces}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if showMarketplaces}
<div class="mt-3 space-y-3">
<!-- Add Marketplace Form -->
<div>
<p class="text-xs text-[var(--text-secondary)] mb-2">
Add a marketplace from GitHub (e.g., "ascorbic/macrodata")
</p>
<div class="flex gap-2">
<input
type="text"
bind:value={newMarketplaceSource}
placeholder="owner/repo"
class="flex-1 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
onkeydown={(e) => e.key === "Enter" && addMarketplace()}
disabled={isAddingMarketplace}
/>
<button
onclick={addMarketplace}
disabled={isAddingMarketplace || !newMarketplaceSource.trim()}
class="px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
>
{#if isAddingMarketplace}
<RefreshCw class="w-4 h-4 animate-spin" />
{:else}
<Download class="w-4 h-4" />
{/if}
Add
</button>
</div>
</div>
<!-- Marketplaces List -->
{#if isLoadingMarketplaces}
<div class="flex items-center justify-center py-4">
<RefreshCw class="w-5 h-5 animate-spin text-[var(--text-secondary)]" />
</div>
{:else if marketplaces.length > 0}
<div class="space-y-2">
{#each marketplaces as marketplace (marketplace.name)}
<div
class="bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)]"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<h4 class="font-medium text-[var(--text-primary)]">{marketplace.name}</h4>
<p class="text-xs text-[var(--text-secondary)] mt-1">{marketplace.source}</p>
</div>
<button
onclick={() => removeMarketplace(marketplace.name)}
disabled={actionInProgress === marketplace.name}
class="px-2 py-1 text-red-400 hover:bg-red-500/20 rounded transition-colors disabled:opacity-40"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-[var(--text-secondary)] text-center py-4">
No marketplaces configured
</p>
{/if}
</div>
{/if}
</div>
<!-- Error Display -->
{#if error}
<div class="mx-4 mt-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
<p class="text-sm text-red-400">{error}</p>
</div>
{/if}
<!-- Plugins List -->
<div class="flex-1 overflow-y-auto p-4">
{#if isLoading}
<div class="flex items-center justify-center h-full text-[var(--text-secondary)]">
<RefreshCw class="w-8 h-8 animate-spin" />
</div>
{:else if plugins.length === 0}
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 mb-4 opacity-50"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
<p class="text-center">No plugins installed</p>
<p class="text-sm text-center mt-2">Install a plugin using the form above</p>
</div>
{:else}
<div class="space-y-3">
{#each plugins as plugin (plugin.name)}
<div
class="bg-[var(--bg-secondary)]/50 rounded-lg p-4 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all"
>
<div class="flex items-start justify-between mb-2">
<div class="flex-1">
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
{plugin.name}
{#if plugin.enabled}
<span
class="px-2 py-0.5 bg-[var(--success-color)]/20 text-[var(--success-color)] text-xs rounded border border-[var(--success-color)]/30"
>
Enabled
</span>
{:else}
<span
class="px-2 py-0.5 bg-[var(--text-secondary)]/20 text-[var(--text-secondary)] text-xs rounded border border-[var(--border-color)]"
>
Disabled
</span>
{/if}
</h4>
<p class="text-xs text-[var(--text-secondary)] mt-1">v{plugin.version}</p>
{#if plugin.description}
<p class="text-sm text-[var(--text-secondary)] mt-2">{plugin.description}</p>
{/if}
</div>
</div>
<div class="flex gap-2 mt-3">
<button
onclick={() => togglePlugin(plugin)}
disabled={actionInProgress === plugin.name}
class="flex-1 px-3 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{#if plugin.enabled}
<PowerOff class="w-4 h-4" />
Disable
{:else}
<Power class="w-4 h-4" />
Enable
{/if}
</button>
<button
onclick={() => updatePlugin(plugin.name)}
disabled={actionInProgress === plugin.name}
class="px-3 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
>
<RefreshCw class="w-4 h-4" />
Update
</button>
<button
onclick={() => uninstallPlugin(plugin.name)}
disabled={actionInProgress === plugin.name}
class="px-3 py-1.5 bg-red-500/20 border border-red-500/30 rounded text-sm text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
>
<Trash2 class="w-4 h-4" />
Uninstall
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<style>
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>
+5 -96
View File
@@ -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,62 +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)}
>
<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;
+5 -140
View File
@@ -21,14 +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 PluginManagementPanel from "./PluginManagementPanel.svelte";
import McpManagementPanel from "./McpManagementPanel.svelte";
import { conversationsStore } from "$lib/stores/conversations"; import { conversationsStore } from "$lib/stores/conversations";
import { import {
generateContextInjection, generateContextInjection,
@@ -36,7 +31,6 @@
sanitizeForJson, sanitizeForJson,
} from "$lib/utils/conversationUtils"; } from "$lib/utils/conversationUtils";
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri"; import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
import { debugConsoleStore } from "$lib/stores/debugConsole";
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";
@@ -52,15 +46,10 @@
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 showPluginPanel = $state(false);
let showMcpPanel = $state(false);
let isSummarising = $state(false); let isSummarising = $state(false);
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,
@@ -76,6 +65,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,7 +88,6 @@
budget_action: "warn", budget_action: "warn",
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true,
}); });
let streamerModeActive = $state(false); let streamerModeActive = $state(false);
@@ -381,16 +370,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">
@@ -444,20 +433,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"
@@ -472,34 +447,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"}
@@ -519,29 +466,6 @@
/> />
</svg> </svg>
</button> </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)]' : ''}"
@@ -556,20 +480,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"
@@ -696,7 +606,7 @@
<!-- 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} onRequestSummary={handleCompactConversation}
onStartFreshWithContext={handleStartFreshWithContext} onStartFreshWithContext={handleStartFreshWithContext}
@@ -721,10 +631,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}
@@ -732,44 +638,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 showPluginPanel}
<PluginManagementPanel onClose={() => (showPluginPanel = false)} />
{/if}
{#if showMcpPanel}
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
{/if}
<style>
/* Responsive status bar styling */
.status-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
/* Make all icon buttons shrink but not grow */
.status-bar button {
flex-shrink: 0;
}
/* Hide version text on very small screens */
@media (max-width: 640px) {
.status-bar button span:last-of-type {
display: none;
}
}
/* Stack left and right sections on very small screens */
@media (max-width: 768px) {
.status-bar {
flex-direction: column;
gap: 0.75rem;
}
}
</style>
-81
View File
@@ -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>
-149
View File
@@ -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);
});
});
});
+49 -85
View File
@@ -4,10 +4,9 @@
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 { 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;
@@ -25,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;
}); });
@@ -90,8 +84,6 @@
return "terminal-tool"; return "terminal-tool";
case "error": case "error":
return "terminal-error"; return "terminal-error";
case "thinking":
return "terminal-thinking";
default: default:
return "terminal-default"; return "terminal-default";
} }
@@ -217,86 +209,58 @@
</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} <span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
<ThinkingBlock content={line.content} timestamp={line.timestamp} /> {#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}
{:else} {#if getLinePrefix(line.type)}
<div <span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
class="terminal-line mb-2 {getLineClass(line.type)} relative group" {/if}
style={line.parentToolUseId {#if line.toolName}
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);" <span class="terminal-tool-name mr-2">[{line.toolName}]</span>
: ""} {/if}
> {#if line.type === "assistant" || line.type === "user"}
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span> <div class="message-content-wrapper">
{#if line.parentToolUseId} <Markdown
<span class="text-xs mr-2 opacity-60" title="Message from subagent">
<svg
class="inline-block w-3 h-3 -mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</span>
{/if}
{#if line.cost && line.cost.costUsd > 0}
<span
class="terminal-cost text-xs mr-2"
title="Input: {line.cost.inputTokens} | Output: {line.cost.outputTokens}"
>
${line.cost.costUsd < 0.01
? line.cost.costUsd.toFixed(4)
: line.cost.costUsd.toFixed(3)}
</span>
{/if}
{#if getLinePrefix(line.type)}
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
{/if}
{#if line.toolName}
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if}
{#if line.type === "assistant" || line.type === "user"}
<div class="message-content-wrapper">
<Markdown
content={maskPaths(line.content, hidePaths)}
searchQuery={currentSearchQuery}
/>
<button
class="copy-message-btn opacity-0 group-hover:opacity-100 transition-opacity"
onclick={() => handleCopyMessage(line.id, line.content)}
title="Copy message"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
</button>
</div>
{:else}
<HighlightedText
content={maskPaths(line.content, hidePaths)} content={maskPaths(line.content, hidePaths)}
searchQuery={currentSearchQuery} searchQuery={currentSearchQuery}
/> />
{/if} <button
</div> class="copy-message-btn opacity-0 group-hover:opacity-100 transition-opacity"
{/if} onclick={() => handleCopyMessage(line.id, line.content)}
title="Copy message"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
</button>
</div>
{:else}
<HighlightedText
content={maskPaths(line.content, hidePaths)}
searchQuery={currentSearchQuery}
/>
{/if}
</div>
{/each} {/each}
{/if} {/if}
</div> </div>
-125
View File
@@ -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>
-182
View File
@@ -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 -5
View File
@@ -96,20 +96,16 @@
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,
}, },
}); });
// Update Discord RPC when reconnecting after answering question // Update Discord RPC when reconnecting after answering question
const config = configStore.getConfig();
const activeConversation = get(conversationsStore.activeConversation); const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) { if (activeConversation) {
await updateDiscordRpc( await updateDiscordRpc(
-325
View File
@@ -1,325 +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";
describe("agents store", () => {
const conversationId = "test-conversation-1";
const otherConversationId = "test-conversation-2";
const createMockAgent = (overrides?: Partial<AgentInfo>): AgentInfo => ({
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]).toEqual(agent);
});
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]).toEqual(agent1);
expect(agents[1]).toEqual(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]).toEqual(agent1);
expect(agents2[0]).toEqual(agent2);
});
});
describe("updateAgentId", () => {
it("updates the agent_id for a specific agent", () => {
const agent = createMockAgent({ agentId: undefined });
agentStore.addAgent(conversationId, agent);
agentStore.updateAgentId(conversationId, agent.toolUseId, "agent-abc123");
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].agentId).toBe("agent-abc123");
});
it("does nothing if conversation doesn't exist", () => {
agentStore.updateAgentId("nonexistent", "tool1", "agent1");
// Should not throw
expect(true).toBe(true);
});
it("does nothing if tool_use_id doesn't exist", () => {
const agent = createMockAgent();
agentStore.addAgent(conversationId, agent);
agentStore.updateAgentId(conversationId, "nonexistent-tool", "agent1");
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].agentId).toBeUndefined();
});
});
describe("endAgent", () => {
it("marks an agent as completed", () => {
const agent = createMockAgent({ status: "running" });
agentStore.addAgent(conversationId, agent);
const endTime = Date.now();
agentStore.endAgent(conversationId, agent.toolUseId, endTime, false);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].status).toBe("completed");
expect(agents[0].endedAt).toBe(endTime);
expect(agents[0].durationMs).toBeGreaterThanOrEqual(0); // Duration can be 0 if timestamps are the same
});
it("marks an agent as errored", () => {
const agent = createMockAgent({ status: "running" });
agentStore.addAgent(conversationId, agent);
const endTime = Date.now();
agentStore.endAgent(conversationId, agent.toolUseId, endTime, true);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].status).toBe("errored");
expect(agents[0].endedAt).toBe(endTime);
});
it("calculates duration correctly", () => {
const startTime = Date.now() - 5000; // 5 seconds ago
const agent = createMockAgent({ startedAt: startTime, status: "running" });
agentStore.addAgent(conversationId, agent);
const endTime = Date.now();
agentStore.endAgent(conversationId, agent.toolUseId, endTime, false);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].durationMs).toBeGreaterThanOrEqual(5000);
expect(agents[0].durationMs).toBeLessThanOrEqual(6000); // Allow some buffer
});
it("does nothing if conversation doesn't exist", () => {
agentStore.endAgent("nonexistent", "tool1", Date.now(), false);
// Should not throw
expect(true).toBe(true);
});
it("does nothing if agent doesn't exist", () => {
const agent = createMockAgent();
agentStore.addAgent(conversationId, agent);
agentStore.endAgent(conversationId, "nonexistent-tool", Date.now(), false);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].status).toBe("running"); // Status unchanged
});
});
describe("markAllErrored", () => {
it("marks all running agents as errored", () => {
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
const agent3 = createMockAgent({ toolUseId: "tool3", status: "completed" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(conversationId, agent2);
agentStore.addAgent(conversationId, agent3);
agentStore.markAllErrored(conversationId);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].status).toBe("errored");
expect(agents[0].endedAt).toBeGreaterThan(0);
expect(agents[1].status).toBe("errored");
expect(agents[1].endedAt).toBeGreaterThan(0);
expect(agents[2].status).toBe("completed"); // Already completed, unchanged
});
it("does nothing if conversation doesn't exist", () => {
agentStore.markAllErrored("nonexistent");
// Should not throw
expect(true).toBe(true);
});
it("does nothing if conversation has no running agents", () => {
const agent = createMockAgent({ status: "completed" });
agentStore.addAgent(conversationId, agent);
agentStore.markAllErrored(conversationId);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].status).toBe("completed"); // Unchanged
});
});
describe("clearCompleted", () => {
it("removes completed and errored agents", () => {
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
const agent2 = createMockAgent({ toolUseId: "tool2", status: "completed" });
const agent3 = createMockAgent({ toolUseId: "tool3", status: "errored" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(conversationId, agent2);
agentStore.addAgent(conversationId, agent3);
agentStore.clearCompleted(conversationId);
const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(1);
expect(agents[0].toolUseId).toBe("tool1"); // Only running agent remains
});
it("does nothing if conversation doesn't exist", () => {
agentStore.clearCompleted("nonexistent");
// Should not throw
expect(true).toBe(true);
});
it("clears all agents if all are completed", () => {
const agent1 = createMockAgent({ toolUseId: "tool1", status: "completed" });
const agent2 = createMockAgent({ toolUseId: "tool2", status: "errored" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(conversationId, agent2);
agentStore.clearCompleted(conversationId);
const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(0);
});
});
describe("clearConversation", () => {
it("removes all agents from a conversation", () => {
const agent1 = createMockAgent({ toolUseId: "tool1" });
const agent2 = createMockAgent({ toolUseId: "tool2" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(conversationId, agent2);
agentStore.clearConversation(conversationId);
const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(0);
});
it("only removes agents from the specified conversation", () => {
const agent1 = createMockAgent({ toolUseId: "tool1" });
const agent2 = createMockAgent({ toolUseId: "tool2" });
agentStore.addAgent(conversationId, agent1);
agentStore.addAgent(otherConversationId, agent2);
agentStore.clearConversation(conversationId);
const agents1 = get(getAgentsForConversation(conversationId));
const agents2 = get(getAgentsForConversation(otherConversationId));
expect(agents1).toHaveLength(0);
expect(agents2).toHaveLength(1);
expect(agents2[0]).toEqual(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);
});
});
});
-121
View File
@@ -1,121 +0,0 @@
import { writable, derived } from "svelte/store";
import type { AgentInfo } from "$lib/types/agents";
// Map of conversation ID -> agents in that conversation
const agentsByConversation = writable<Record<string, AgentInfo[]>>({});
function createAgentStore() {
return {
subscribe: agentsByConversation.subscribe,
addAgent(conversationId: string, agent: AgentInfo) {
agentsByConversation.update((state) => {
const existing = state[conversationId] || [];
return {
...state,
[conversationId]: [...existing, agent],
};
});
},
updateAgentId(conversationId: string, toolUseId: string, agentId: string) {
agentsByConversation.update((state) => {
const agents = state[conversationId];
if (!agents) return state;
const agentIndex = agents.findIndex((a) => a.toolUseId === toolUseId);
if (agentIndex === -1) return state;
const updated = [...agents];
updated[agentIndex] = {
...updated[agentIndex],
agentId,
};
return {
...state,
[conversationId]: updated,
};
});
},
endAgent(conversationId: string, toolUseId: string, endedAt: number, isError: boolean) {
agentsByConversation.update((state) => {
const agents = state[conversationId];
if (!agents) return state;
const agentIndex = agents.findIndex((a) => a.toolUseId === toolUseId);
if (agentIndex === -1) return state;
const updated = [...agents];
const agent = updated[agentIndex];
const durationMs = endedAt - agent.startedAt;
updated[agentIndex] = {
...agent,
endedAt,
status: isError ? "errored" : "completed",
durationMs,
};
return {
...state,
[conversationId]: updated,
};
});
},
markAllErrored(conversationId: string) {
agentsByConversation.update((state) => {
const agents = state[conversationId];
if (!agents) return state;
const now = Date.now();
const updated = agents.map((agent) =>
agent.status === "running"
? { ...agent, endedAt: now, status: "errored" as const }
: agent
);
return {
...state,
[conversationId]: updated,
};
});
},
clearCompleted(conversationId: string) {
agentsByConversation.update((state) => {
const agents = state[conversationId];
if (!agents) return state;
return {
...state,
[conversationId]: agents.filter((a) => a.status === "running"),
};
});
},
clearConversation(conversationId: string) {
agentsByConversation.update((state) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Unused destructured value
const { [conversationId]: _, ...rest } = state;
return rest;
});
},
};
}
export const agentStore = createAgentStore();
export function getAgentsForConversation(conversationId: string) {
return derived(agentsByConversation, ($state) => $state[conversationId] || []);
}
export const runningAgentCount = derived(agentsByConversation, ($state) => {
let count = 0;
for (const agents of Object.values($state)) {
count += agents.filter((a) => a.status === "running").length;
}
return count;
});
+1 -4
View File
@@ -101,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(
+2 -338
View File
@@ -14,7 +14,6 @@ import {
type Theme, type Theme,
type CustomThemeColors, type CustomThemeColors,
} from "./config"; } from "./config";
import { invoke } from "@tauri-apps/api/core";
// Mock Tauri APIs // Mock Tauri APIs
vi.mock("@tauri-apps/api/core", () => ({ vi.mock("@tauri-apps/api/core", () => ({
@@ -168,6 +167,7 @@ describe("config store", () => {
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: true,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: 300, character_panel_width: 300,
font_size: 14, font_size: 14,
@@ -193,7 +193,6 @@ describe("config store", () => {
budget_action: "warn", budget_action: "warn",
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true,
}; };
expect(config.model).toBe("claude-sonnet-4"); expect(config.model).toBe("claude-sonnet-4");
@@ -214,6 +213,7 @@ describe("config store", () => {
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,
@@ -239,7 +239,6 @@ describe("config store", () => {
budget_action: "warn", budget_action: "warn",
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true,
}; };
expect(config.model).toBeNull(); expect(config.model).toBeNull();
@@ -490,339 +489,4 @@ describe("config store", () => {
expect(typeof configStore.saveError.subscribe).toBe("function"); expect(typeof configStore.saveError.subscribe).toBe("function");
}); });
}); });
describe("Race Condition Tests", () => {
beforeEach(async () => {
// Setup mock to return a default config for load_config
const mockInvokeImpl = vi.mocked(invoke);
mockInvokeImpl.mockResolvedValue({
model: null,
api_key: null,
custom_instructions: null,
mcp_servers_json: null,
auto_granted_tools: [],
theme: "dark",
greeting_enabled: false,
greeting_custom_prompt: null,
notifications_enabled: false,
notification_volume: 0.7,
always_on_top: false,
update_checks_enabled: false,
character_panel_width: null,
font_size: 14,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
profile_name: null,
profile_avatar_path: null,
profile_bio: null,
custom_theme_colors: {
bg_primary: null,
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
},
budget_enabled: false,
session_token_budget: null,
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
discord_rpc_enabled: false,
});
// Load initial config
await configStore.loadConfig();
vi.clearAllMocks();
});
it("handles rapid sequential config updates correctly", async () => {
// This test validates the fix for the config race condition that caused data loss
const mockInvokeImpl = vi.mocked(invoke);
const invokeCalls: Array<{ command: string; config: HikariConfig }> = [];
mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => {
if (command === "save_config" && args && typeof args === "object" && "config" in args) {
invokeCalls.push({ command, config: args.config as HikariConfig });
// Simulate small delay in saving
await new Promise((resolve) => setTimeout(resolve, 10));
}
return null;
});
// Perform rapid updates
await Promise.all([
configStore.updateConfig({ font_size: 16 }),
configStore.updateConfig({ theme: "light" }),
configStore.updateConfig({ compact_mode: true }),
]);
// All three updates should have been saved
expect(invokeCalls.length).toBe(3);
// Get final config
const finalConfig = configStore.getConfig();
// Final config should have all updates
// Note: The last update wins for each field, but all fields should be preserved
expect(finalConfig.compact_mode).toBe(true);
});
it("preserves previous field values during concurrent updates", async () => {
// Set initial values
await configStore.updateConfig({
font_size: 16,
theme: "dark",
compact_mode: false,
streamer_mode: false,
});
vi.clearAllMocks();
const mockInvokeImpl = vi.mocked(invoke);
const invokeCalls: Array<{ command: string; config: HikariConfig }> = [];
mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => {
if (command === "save_config" && args && typeof args === "object" && "config" in args) {
invokeCalls.push({ command, config: args.config as HikariConfig });
await new Promise((resolve) => setTimeout(resolve, 5));
}
return null;
});
// Update different fields concurrently
await Promise.all([
configStore.updateConfig({ font_size: 18 }),
configStore.updateConfig({ theme: "light" }),
configStore.updateConfig({ compact_mode: true }),
]);
// Check that each save included all previous config values
invokeCalls.forEach((call) => {
// Each save should have a complete config, not just the updated field
expect(call.config).toHaveProperty("font_size");
expect(call.config).toHaveProperty("theme");
expect(call.config).toHaveProperty("compact_mode");
expect(call.config).toHaveProperty("streamer_mode");
expect(call.config).toHaveProperty("model");
expect(call.config).toHaveProperty("api_key");
});
});
it("handles update during save operation", async () => {
const mockInvokeImpl = vi.mocked(invoke);
let firstSaveStarted = false;
let firstSaveCompleted = false;
mockInvokeImpl.mockImplementation(async (command: string) => {
if (command === "save_config") {
if (!firstSaveStarted) {
firstSaveStarted = true;
// Simulate slow save
await new Promise((resolve) => setTimeout(resolve, 50));
firstSaveCompleted = true;
} else {
// Second save starts while first is in progress
expect(firstSaveStarted).toBe(true);
// First save might not be complete yet (race condition scenario)
}
}
return null;
});
// Start first update
const firstUpdate = configStore.updateConfig({ font_size: 16 });
// Wait a bit then start second update whilst first is still saving
await new Promise((resolve) => setTimeout(resolve, 10));
const secondUpdate = configStore.updateConfig({ theme: "light" });
// Wait for both to complete
await Promise.all([firstUpdate, secondUpdate]);
// Both should complete successfully without errors
expect(firstSaveCompleted).toBe(true);
});
it("getConfig returns most recently set configuration", async () => {
await configStore.updateConfig({ font_size: 14 });
expect(configStore.getConfig().font_size).toBe(14);
await configStore.updateConfig({ font_size: 16 });
expect(configStore.getConfig().font_size).toBe(16);
await configStore.updateConfig({ font_size: 18 });
expect(configStore.getConfig().font_size).toBe(18);
});
it("updates do not lose data from previous operations", async () => {
// Set multiple fields
await configStore.updateConfig({
font_size: 16,
theme: "dark",
compact_mode: true,
streamer_mode: true,
model: "claude-sonnet-4",
});
// Update just one field
await configStore.updateConfig({ theme: "light" });
// Other fields should be preserved
const config = configStore.getConfig();
expect(config.theme).toBe("light");
expect(config.font_size).toBe(16);
expect(config.compact_mode).toBe(true);
expect(config.streamer_mode).toBe(true);
expect(config.model).toBe("claude-sonnet-4");
});
it("auto granted tools are not lost during other updates", async () => {
// Add some tools
await configStore.addAutoGrantedTool("Read");
await configStore.addAutoGrantedTool("Write");
expect(configStore.getConfig().auto_granted_tools).toContain("Read");
expect(configStore.getConfig().auto_granted_tools).toContain("Write");
// Update another field
await configStore.updateConfig({ theme: "light" });
// Tools should still be there
expect(configStore.getConfig().auto_granted_tools).toContain("Read");
expect(configStore.getConfig().auto_granted_tools).toContain("Write");
});
it("custom theme colors persist across other config updates", async () => {
const customColors: CustomThemeColors = {
bg_primary: "#1a1a2e",
bg_secondary: "#16213e",
bg_terminal: "#0f0f23",
accent_primary: "#e94560",
accent_secondary: "#533483",
text_primary: "#eaeaea",
text_secondary: "#a0a0a0",
border_color: "#333355",
};
await configStore.setCustomThemeColors(customColors);
// Update another field
await configStore.updateConfig({ font_size: 18 });
// Colors should still be there
const config = configStore.getConfig();
expect(config.custom_theme_colors.bg_primary).toBe("#1a1a2e");
expect(config.custom_theme_colors.accent_primary).toBe("#e94560");
});
it("handles save errors gracefully without losing data", async () => {
const mockInvokeImpl = vi.mocked(invoke);
// Mock console.error to suppress expected error output
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Set initial config
await configStore.updateConfig({ font_size: 14 });
// Make next save fail
mockInvokeImpl.mockRejectedValueOnce(new Error("Save failed"));
// Try to update - should throw
await expect(configStore.updateConfig({ theme: "light" })).rejects.toThrow();
// Original config should still be accessible
expect(configStore.getConfig().font_size).toBe(14);
// Verify error was logged
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to save config:", expect.any(Error));
// Restore console.error
consoleErrorSpy.mockRestore();
});
});
describe("Config Persistence Tests", () => {
it("loadConfig retrieves saved configuration", async () => {
const mockConfig: HikariConfig = {
model: "claude-sonnet-4",
api_key: "test-key",
custom_instructions: "Be helpful",
mcp_servers_json: "{}",
auto_granted_tools: ["Read", "Write"],
theme: "light",
greeting_enabled: false,
greeting_custom_prompt: null,
notifications_enabled: false,
notification_volume: 0.5,
always_on_top: true,
update_checks_enabled: false,
character_panel_width: 400,
font_size: 18,
streamer_mode: true,
streamer_hide_paths: true,
compact_mode: true,
profile_name: "Test User",
profile_avatar_path: "/test/avatar.png",
profile_bio: "Test bio",
custom_theme_colors: {
bg_primary: null,
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
},
budget_enabled: true,
session_token_budget: 100000,
session_cost_budget: 1.5,
budget_action: "block",
budget_warning_threshold: 0.9,
discord_rpc_enabled: false,
show_thinking_blocks: true,
};
const mockInvokeImpl = vi.mocked(invoke);
mockInvokeImpl.mockResolvedValueOnce(mockConfig);
await configStore.loadConfig();
const loadedConfig = configStore.getConfig();
expect(loadedConfig.model).toBe("claude-sonnet-4");
expect(loadedConfig.theme).toBe("light");
expect(loadedConfig.font_size).toBe(18);
expect(loadedConfig.auto_granted_tools).toEqual(["Read", "Write"]);
});
it("saveConfig persists configuration to backend", async () => {
const mockInvokeImpl = vi.mocked(invoke);
const savedConfigs: HikariConfig[] = [];
mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => {
if (command === "save_config" && args && typeof args === "object" && "config" in args) {
savedConfigs.push(args.config as HikariConfig);
}
return null;
});
const configToSave: Partial<HikariConfig> = {
model: "claude-sonnet-4",
theme: "dark",
font_size: 16,
};
await configStore.updateConfig(configToSave);
expect(savedConfigs.length).toBeGreaterThan(0);
const lastSaved = savedConfigs[savedConfigs.length - 1];
expect(lastSaved.model).toBe("claude-sonnet-4");
expect(lastSaved.theme).toBe("dark");
expect(lastSaved.font_size).toBe(16);
});
});
}); });
+23 -25
View File
@@ -27,6 +27,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;
@@ -45,8 +46,6 @@ export interface HikariConfig {
budget_warning_threshold: number; budget_warning_threshold: number;
// Discord RPC settings // Discord RPC settings
discord_rpc_enabled: boolean; discord_rpc_enabled: boolean;
// Thinking blocks settings
show_thinking_blocks: boolean;
} }
const defaultConfig: HikariConfig = { const defaultConfig: HikariConfig = {
@@ -61,6 +60,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,
@@ -86,7 +86,6 @@ const defaultConfig: HikariConfig = {
budget_action: "warn", budget_action: "warn",
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true,
}; };
function createConfigStore() { function createConfigStore() {
@@ -95,14 +94,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 {
@@ -130,7 +121,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);
} }
@@ -155,13 +147,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);
} }
@@ -174,14 +168,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);
@@ -193,7 +189,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 });
@@ -201,22 +198,27 @@ 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 });
}, },
@@ -300,10 +302,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.
+9 -46
View File
@@ -10,7 +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 { export interface ConversationSummary {
generatedAt: Date; generatedAt: Date;
@@ -29,7 +28,7 @@ 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;
@@ -67,7 +66,7 @@ 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(),
@@ -121,11 +120,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);
@@ -138,7 +133,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 },
@@ -196,7 +190,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.push(request); conv.pendingPermission = request;
conv.lastActivityAt = new Date(); conv.lastActivityAt = new Date();
} }
return convs; return convs;
@@ -209,7 +203,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;
@@ -219,7 +213,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.push(request); conv.pendingPermission = request;
conv.lastActivityAt = new Date(); conv.lastActivityAt = new Date();
} }
return convs; return convs;
@@ -229,30 +223,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;
@@ -334,10 +305,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;
@@ -468,8 +435,7 @@ function createConversationsStore() {
type: TerminalLine["type"], type: TerminalLine["type"],
content: string, content: string,
toolName?: string, toolName?: string,
cost?: TerminalLine["cost"], cost?: TerminalLine["cost"]
parentToolUseId?: string
) => { ) => {
ensureInitialized(); ensureInitialized();
const activeId = get(activeConversationId); const activeId = get(activeConversationId);
@@ -482,7 +448,6 @@ function createConversationsStore() {
timestamp: new Date(), timestamp: new Date(),
toolName, toolName,
cost, cost,
parentToolUseId,
}; };
conversations.update((convs) => { conversations.update((convs) => {
@@ -504,8 +469,7 @@ function createConversationsStore() {
type: TerminalLine["type"], type: TerminalLine["type"],
content: string, content: string,
toolName?: string, toolName?: string,
cost?: TerminalLine["cost"], cost?: TerminalLine["cost"]
parentToolUseId?: string
) => { ) => {
ensureInitialized(); ensureInitialized();
@@ -516,7 +480,6 @@ function createConversationsStore() {
timestamp: new Date(), timestamp: new Date(),
toolName, toolName,
cost, cost,
parentToolUseId,
}; };
conversations.update((convs) => { conversations.update((convs) => {
-154
View File
@@ -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);
});
-67
View File
@@ -285,73 +285,6 @@ describe("snippetsStore", () => {
expect(get(snippetsStore.selectedCategory)).toBeNull(); expect(get(snippetsStore.selectedCategory)).toBeNull();
}); });
}); });
describe("filteredSnippets", () => {
it("returns all snippets when no category selected", async () => {
const mockSnippets: Snippet[] = [
{
id: "snippet-1",
name: "Git Status",
content: "git status",
category: "Git",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
{
id: "snippet-2",
name: "Docker PS",
content: "docker ps",
category: "Docker",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
];
setMockInvokeResult("list_snippets", mockSnippets);
setMockInvokeResult("get_snippet_categories", ["Git", "Docker"]);
await snippetsStore.loadSnippets();
snippetsStore.setSelectedCategory(null);
const filtered = get(snippetsStore.filteredSnippets);
expect(filtered).toHaveLength(2);
});
it("filters snippets by selected category", async () => {
const mockSnippets: Snippet[] = [
{
id: "snippet-1",
name: "Git Status",
content: "git status",
category: "Git",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
{
id: "snippet-2",
name: "Docker PS",
content: "docker ps",
category: "Docker",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
];
setMockInvokeResult("list_snippets", mockSnippets);
setMockInvokeResult("get_snippet_categories", ["Git", "Docker"]);
await snippetsStore.loadSnippets();
snippetsStore.setSelectedCategory("Git");
const filtered = get(snippetsStore.filteredSnippets);
expect(filtered).toHaveLength(1);
expect(filtered[0].category).toBe("Git");
});
});
}); });
describe("snippet ID generation", () => { describe("snippet ID generation", () => {
+1 -3
View File
@@ -10,9 +10,7 @@ export type BudgetType = "token" | "cost";
// Model pricing (per million tokens) - keep in sync with stats.rs // Model pricing (per million tokens) - keep in sync with stats.rs
// Source: https://platform.claude.com/docs/en/about-claude/models/overview // Source: https://platform.claude.com/docs/en/about-claude/models/overview
export const MODEL_PRICING: Record<string, { input: number; output: number }> = { export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
// Current generation (Claude 4.6) // Current generation (Claude 4.5)
"claude-opus-4-6": { input: 5.0, output: 25.0 },
// Previous generation (Claude 4.5)
"claude-opus-4-5-20251101": { input: 5.0, output: 25.0 }, "claude-opus-4-5-20251101": { input: 5.0, output: 25.0 },
"claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 }, "claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 },
"claude-haiku-4-5-20251001": { input: 1.0, output: 5.0 }, "claude-haiku-4-5-20251001": { input: 1.0, output: 5.0 },
-44
View File
@@ -1,44 +0,0 @@
import { writable } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
export interface TodoItem {
content: string;
status: "pending" | "in_progress" | "completed";
activeForm: string;
}
interface TodoUpdatePayload {
todos: TodoItem[];
conversation_id?: string;
}
// Create the writable store
const { subscribe, set, update } = writable<TodoItem[]>([]);
// Listen for todo updates from the backend
let unlisten: (() => void) | undefined;
export async function initializeTodoListener(): Promise<void> {
if (unlisten) {
return; // Already initialized
}
unlisten = await listen<TodoUpdatePayload>("claude:todo-update", (event) => {
set(event.payload.todos);
});
}
export function cleanupTodoListener(): void {
if (unlisten) {
unlisten();
unlisten = undefined;
}
}
// Export the store
export const todos = {
subscribe,
set,
update,
clear: () => set([]),
};
+43 -102
View File
@@ -12,9 +12,6 @@ import type {
UserQuestionEvent, UserQuestionEvent,
} from "$lib/types/messages"; } from "$lib/types/messages";
import type { CharacterState } from "$lib/types/states"; import type { CharacterState } from "$lib/types/states";
import type { AgentStartPayload, AgentEndPayload } from "$lib/types/agents";
import { agentStore } from "$lib/stores/agents";
import { todos } from "$lib/stores/todos";
import { import {
initializeNotificationRules, initializeNotificationRules,
cleanupNotificationRules, cleanupNotificationRules,
@@ -98,7 +95,6 @@ interface OutputPayload {
output_tokens: number; output_tokens: number;
cost_usd: number; cost_usd: number;
}; };
parent_tool_use_id?: string;
} }
interface ConnectionPayload { interface ConnectionPayload {
@@ -179,15 +175,6 @@ export async function initializeTauriListeners() {
} else if (status === "disconnected") { } else if (status === "disconnected") {
const targetConversationId = conversation_id || get(claudeStore.activeConversationId); const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
// Mark all running agents as errored on disconnect, but not during reconnects
// (permission prompts trigger reconnects and agents may complete before reconnect)
if (!skipNextGreeting && targetConversationId) {
agentStore.markAllErrored(targetConversationId);
// Clear the conversation's agents from the store on real disconnect
// This prevents agents from persisting across sessions
agentStore.clearConversation(targetConversationId);
}
// Only remove from connected set if we're not about to reconnect // Only remove from connected set if we're not about to reconnect
if (!skipNextGreeting && targetConversationId) { if (!skipNextGreeting && targetConversationId) {
connectedConversations.delete(targetConversationId); connectedConversations.delete(targetConversationId);
@@ -200,9 +187,6 @@ export async function initializeTauriListeners() {
"system", "system",
"Disconnected from Claude Code" "Disconnected from Claude Code"
); );
// Clear todos on real disconnect (not on reconnects for permissions)
todos.clear();
} }
// Update character state for this conversation // Update character state for this conversation
@@ -263,8 +247,7 @@ export async function initializeTauriListeners() {
unlisteners.push(stateUnlisten); unlisteners.push(stateUnlisten);
const outputUnlisten = await listen<OutputPayload>("claude:output", (event) => { const outputUnlisten = await listen<OutputPayload>("claude:output", (event) => {
const { line_type, content, tool_name, conversation_id, cost, parent_tool_use_id } = const { line_type, content, tool_name, conversation_id, cost } = event.payload;
event.payload;
// Convert snake_case cost to camelCase for TypeScript // Convert snake_case cost to camelCase for TypeScript
const costData = cost const costData = cost
@@ -279,20 +262,18 @@ export async function initializeTauriListeners() {
if (conversation_id) { if (conversation_id) {
claudeStore.addLineToConversation( claudeStore.addLineToConversation(
conversation_id, conversation_id,
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking", line_type as "user" | "assistant" | "system" | "tool" | "error",
content, content,
tool_name || undefined, tool_name || undefined,
costData, costData
parent_tool_use_id
); );
} else { } else {
// Fallback to active conversation if no conversation_id provided // Fallback to active conversation if no conversation_id provided
claudeStore.addLine( claudeStore.addLine(
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking", line_type as "user" | "assistant" | "system" | "tool" | "error",
content, content,
tool_name || undefined, tool_name || undefined,
costData, costData
parent_tool_use_id
); );
} }
}); });
@@ -335,89 +316,35 @@ export async function initializeTauriListeners() {
}); });
unlisteners.push(cwdUnlisten); unlisteners.push(cwdUnlisten);
console.log("[Tauri Listener] Setting up claude:permission listener");
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => { const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
const { permissions, conversation_id } = event.payload; const { id, tool_name, tool_input, description, conversation_id } = event.payload;
console.log( // Store permission request for the specific conversation
`[Permission] Event received: ${permissions.length} permission(s) for conversation ${conversation_id || "active"}`, if (conversation_id) {
{ permissions, conversation_id } claudeStore.requestPermissionForConversation(conversation_id, {
); id,
tool: tool_name,
// Store each permission request for the specific conversation description,
for (const permission of permissions) { input: tool_input,
const { id, tool_name, tool_input, description } = permission; });
claudeStore.addLineToConversation(
if (conversation_id) { conversation_id,
claudeStore.requestPermissionForConversation(conversation_id, { "system",
id, `Permission requested for: ${tool_name}`
tool: tool_name, );
description, } else {
input: tool_input, // Fallback to active conversation if no conversation_id
}); claudeStore.requestPermission({
claudeStore.addLineToConversation( id,
conversation_id, tool: tool_name,
"system", description,
`Permission requested for: ${tool_name}` input: tool_input,
); });
} else { claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
// Fallback to active conversation if no conversation_id
claudeStore.requestPermission({
id,
tool: tool_name,
description,
input: tool_input,
});
claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
}
} }
}); });
unlisteners.push(permissionUnlisten); unlisteners.push(permissionUnlisten);
const agentStartUnlisten = await listen<AgentStartPayload>("claude:agent-start", (event) => {
const {
tool_use_id,
agent_id,
description,
subagent_type,
started_at,
conversation_id,
parent_tool_use_id,
} = event.payload;
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (targetConversationId) {
agentStore.addAgent(targetConversationId, {
toolUseId: tool_use_id,
agentId: agent_id,
description,
subagentType: subagent_type,
startedAt: started_at,
status: "running",
parentToolUseId: parent_tool_use_id,
});
}
});
unlisteners.push(agentStartUnlisten);
const agentUpdateUnlisten = await listen<{
conversationId: string;
toolUseId: string;
agentId: string;
}>("claude:agent-update", (event) => {
const { conversationId, toolUseId, agentId } = event.payload;
agentStore.updateAgentId(conversationId, toolUseId, agentId);
});
unlisteners.push(agentUpdateUnlisten);
const agentEndUnlisten = await listen<AgentEndPayload>("claude:agent-end", (event) => {
const { tool_use_id, ended_at, is_error, conversation_id } = event.payload;
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (targetConversationId) {
agentStore.endAgent(targetConversationId, tool_use_id, ended_at, is_error);
}
});
unlisteners.push(agentEndUnlisten);
const questionUnlisten = await listen<UserQuestionEvent>("claude:question", (event) => { const questionUnlisten = await listen<UserQuestionEvent>("claude:question", (event) => {
const questionEvent = event.payload; const questionEvent = event.payload;
@@ -455,6 +382,10 @@ export async function initializeDiscordRpc() {
const startedAtUnixSeconds = Math.floor(startedAt.getTime() / 1000); const startedAtUnixSeconds = Math.floor(startedAt.getTime() / 1000);
const model = config.model || "claude"; const model = config.model || "claude";
await invoke("log_discord_rpc", {
message: `[FRONTEND] Attempting to initialize Discord RPC: session='Idle', model='${model}', timestamp=${startedAtUnixSeconds}`,
});
console.log("Initializing Discord RPC with initial activity:", { console.log("Initializing Discord RPC with initial activity:", {
session_name: "Idle", session_name: "Idle",
model, model,
@@ -467,13 +398,23 @@ export async function initializeDiscordRpc() {
startedAt: startedAtUnixSeconds, startedAt: startedAtUnixSeconds,
}); });
await invoke("log_discord_rpc", {
message: "[FRONTEND] Discord RPC initialized successfully!",
});
console.log("Discord RPC initialized successfully with initial presence"); console.log("Discord RPC initialized successfully with initial presence");
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await invoke("log_discord_rpc", {
message: `[FRONTEND] ERROR: Failed to initialize Discord RPC: ${errorMessage}`,
});
console.error("Failed to initialize Discord RPC:", error); console.error("Failed to initialize Discord RPC:", error);
console.warn("Discord RPC will be unavailable. Make sure Discord is running."); console.warn("Discord RPC will be unavailable. Make sure Discord is running.");
} }
} else { } else {
console.log("Discord RPC is disabled in config, skipping initialization"); await invoke("log_discord_rpc", {
message: "[FRONTEND] Discord RPC is disabled in config, skipping initialization",
});
} }
} }
-32
View File
@@ -1,32 +0,0 @@
export type AgentStatus = "running" | "completed" | "errored";
export interface AgentInfo {
toolUseId: string;
agentId?: string;
description: string;
subagentType: string;
startedAt: number;
endedAt?: number;
status: AgentStatus;
parentToolUseId?: string;
durationMs?: number;
}
export interface AgentStartPayload {
tool_use_id: string;
agent_id?: string;
description: string;
subagent_type: string;
started_at: number;
conversation_id?: string;
parent_tool_use_id?: string;
}
export interface AgentEndPayload {
tool_use_id: string;
ended_at: number;
is_error: boolean;
conversation_id?: string;
duration_ms?: number;
num_turns?: number;
}
+2 -8
View File
@@ -1,6 +1,6 @@
export interface TerminalLine { export interface TerminalLine {
id: string; id: string;
type: "user" | "assistant" | "system" | "tool" | "error" | "thinking"; type: "user" | "assistant" | "system" | "tool" | "error";
content: string; content: string;
timestamp: Date; timestamp: Date;
toolName?: string; toolName?: string;
@@ -10,8 +10,6 @@ export interface TerminalLine {
outputTokens: number; outputTokens: number;
costUsd: number; costUsd: number;
}; };
// Indicates if this message is from a subagent
parentToolUseId?: string;
} }
export interface SystemInitMessage { export interface SystemInitMessage {
@@ -126,15 +124,11 @@ export interface PermissionRequest {
input: Record<string, unknown>; input: Record<string, unknown>;
} }
export interface PermissionPromptEventItem { export interface PermissionPromptEvent {
id: string; id: string;
tool_name: string; tool_name: string;
tool_input: Record<string, unknown>; tool_input: Record<string, unknown>;
description: string; description: string;
}
export interface PermissionPromptEvent {
permissions: PermissionPromptEventItem[];
conversation_id?: string; conversation_id?: string;
} }
-175
View File
@@ -135,151 +135,6 @@ describe("stateMapper", () => {
}; };
expect(mapMessageToState(message)).toBeNull(); expect(mapMessageToState(message)).toBeNull();
}); });
it("returns typing for unknown tool", () => {
const message: ClaudeStreamMessage = {
type: "assistant",
message: {
content: [
{
type: "tool_use",
id: "tool-1",
name: "SomeUnknownTool",
input: {},
},
],
model: "claude-3",
stop_reason: "tool_use",
},
};
expect(mapMessageToState(message)).toBe("typing");
});
it("returns thinking for thinking content block", () => {
const message: ClaudeStreamMessage = {
type: "assistant",
message: {
content: [{ type: "thinking", thinking: "Analyzing the problem..." }],
model: "claude-3",
stop_reason: "end_turn",
},
};
expect(mapMessageToState(message)).toBe("thinking");
});
it("returns null for assistant message with no recognizable content", () => {
const message: ClaudeStreamMessage = {
type: "assistant",
message: {
content: [],
model: "claude-3",
stop_reason: "end_turn",
},
};
expect(mapMessageToState(message)).toBeNull();
});
it("returns thinking for thinking_delta stream event", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
delta: {
type: "thinking_delta",
thinking: "Thinking...",
},
},
};
expect(mapMessageToState(message)).toBe("thinking");
});
it("returns typing for text_delta stream event", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
delta: {
type: "text_delta",
text: "Hello",
},
},
};
expect(mapMessageToState(message)).toBe("typing");
});
it("returns thinking for thinking content_block_start", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "thinking",
thinking: "",
},
},
};
expect(mapMessageToState(message)).toBe("thinking");
});
it("returns typing for text content_block_start", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "text",
text: "",
},
},
};
expect(mapMessageToState(message)).toBe("typing");
});
it("returns correct state for tool_use content_block_start", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "tool-1",
name: "Read",
input: {},
},
},
};
expect(mapMessageToState(message)).toBe("searching");
});
it("returns null for stream_event with unrecognized type", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "message_start",
},
};
expect(mapMessageToState(message)).toBeNull();
});
it("returns null for result with unknown subtype", () => {
const message = {
type: "result",
subtype: "unknown_type",
} as unknown as ClaudeStreamMessage;
expect(mapMessageToState(message)).toBeNull();
});
it("returns null for unknown message type", () => {
const message = {
type: "unknown_type",
} as unknown as ClaudeStreamMessage;
expect(mapMessageToState(message)).toBeNull();
});
}); });
describe("extractTextFromMessage", () => { describe("extractTextFromMessage", () => {
@@ -337,36 +192,6 @@ describe("stateMapper", () => {
}; };
expect(extractTextFromMessage(message)).toBe("Completed successfully"); expect(extractTextFromMessage(message)).toBe("Completed successfully");
}); });
it("returns null for result without result field", () => {
const message: ClaudeStreamMessage = {
type: "result",
subtype: "success",
};
expect(extractTextFromMessage(message)).toBeNull();
});
it("returns null for stream_event without delta text", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "text",
text: "",
},
},
};
expect(extractTextFromMessage(message)).toBeNull();
});
it("returns null for unknown message type", () => {
const message = {
type: "unknown",
} as unknown as ClaudeStreamMessage;
expect(extractTextFromMessage(message)).toBeNull();
});
}); });
describe("extractToolInfo", () => { describe("extractToolInfo", () => {
-2
View File
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import "../app.css"; import "../app.css";
import DebugConsole from "$lib/components/DebugConsole.svelte";
let { children } = $props(); let { children } = $props();
@@ -15,5 +14,4 @@
<div id="app"> <div id="app">
{@render children()} {@render children()}
<DebugConsole />
</div> </div>
-80
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { import {
initializeTauriListeners, initializeTauriListeners,
@@ -32,18 +31,12 @@
import AchievementNotification from "$lib/components/AchievementNotification.svelte"; import AchievementNotification from "$lib/components/AchievementNotification.svelte";
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte"; import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
import UpdateNotification from "$lib/components/UpdateNotification.svelte"; import UpdateNotification from "$lib/components/UpdateNotification.svelte";
import CloseAppConfirmModal from "$lib/components/CloseAppConfirmModal.svelte";
import MemoryBrowserPanel from "$lib/components/MemoryBrowserPanel.svelte";
import { debugConsoleStore } from "$lib/stores/debugConsole";
import { initializeTodoListener, cleanupTodoListener } from "$lib/stores/todos";
let initialized = false; let initialized = false;
let updateNotification: UpdateNotification | undefined = $state(undefined); let updateNotification: UpdateNotification | undefined = $state(undefined);
let achievementPanelOpen = $state(false); let achievementPanelOpen = $state(false);
let currentCharacterState: CharacterState = $state("idle"); let currentCharacterState: CharacterState = $state("idle");
let compactModeActive = $state(false); let compactModeActive = $state(false);
let closeConfirmModalOpen = $state(false);
let hasActiveConversation = $state(false);
// Editor state // Editor state
const isEditorVisible = editorStore.isEditorVisible; const isEditorVisible = editorStore.isEditorVisible;
@@ -237,13 +230,6 @@
return; return;
} }
// Ctrl+` - Toggle debug console
if (event.ctrlKey && event.key === "`") {
event.preventDefault();
debugConsoleStore.toggle();
return;
}
// Ctrl+E - Toggle editor panel (only when connected) // Ctrl+E - Toggle editor panel (only when connected)
if (event.ctrlKey && event.key === "e") { if (event.ctrlKey && event.key === "e") {
event.preventDefault(); event.preventDefault();
@@ -356,50 +342,6 @@
} }
} }
async function handleCloseRequest() {
// Check if there's an active conversation with Claude running
const activeId = get(claudeStore.activeConversationId);
if (activeId) {
try {
const isRunning = await invoke<boolean>("is_claude_running", {
conversationId: activeId,
});
hasActiveConversation = isRunning;
} catch (error) {
console.error("Failed to check Claude status:", error);
hasActiveConversation = false;
}
} else {
hasActiveConversation = false;
}
// Always show confirmation modal
closeConfirmModalOpen = true;
}
async function handleConfirmClose() {
closeConfirmModalOpen = false;
try {
await invoke("close_application");
} catch (error) {
console.error("Failed to close application:", error);
}
}
async function handleMinimizeToTray() {
closeConfirmModalOpen = false;
try {
const window = getCurrentWindow();
await window.hide();
} catch (error) {
console.error("Failed to minimize to tray:", error);
}
}
function handleCancelClose() {
closeConfirmModalOpen = false;
}
onMount(async () => { onMount(async () => {
if (!initialized) { if (!initialized) {
initialized = true; initialized = true;
@@ -445,19 +387,6 @@
// Initialize Discord RPC // Initialize Discord RPC
await initializeDiscordRpc(); await initializeDiscordRpc();
// Initialize todo listener
await initializeTodoListener();
// Listen for window close requests
const unlisten = await listen("window-close-requested", () => {
handleCloseRequest();
});
// Store the unlisten function for cleanup
window.addEventListener("beforeunload", () => {
unlisten();
});
} }
}); });
@@ -465,7 +394,6 @@
if (initialized) { if (initialized) {
cleanupTauriListeners(); cleanupTauriListeners();
cleanupNotificationSync(); cleanupNotificationSync();
cleanupTodoListener();
stopDiscordRpc(); stopDiscordRpc();
window.removeEventListener("keydown", handleGlobalKeydown); window.removeEventListener("keydown", handleGlobalKeydown);
initialized = false; initialized = false;
@@ -519,20 +447,12 @@
<PermissionModal /> <PermissionModal />
<UserQuestionModal /> <UserQuestionModal />
<ConfigSidebar /> <ConfigSidebar />
<MemoryBrowserPanel />
<AchievementNotification /> <AchievementNotification />
<AchievementsPanel <AchievementsPanel
bind:isOpen={achievementPanelOpen} bind:isOpen={achievementPanelOpen}
onClose={() => (achievementPanelOpen = false)} onClose={() => (achievementPanelOpen = false)}
/> />
<UpdateNotification bind:this={updateNotification} /> <UpdateNotification bind:this={updateNotification} />
<CloseAppConfirmModal
isOpen={closeConfirmModalOpen}
{hasActiveConversation}
onClose={handleConfirmClose}
onMinimize={handleMinimizeToTray}
onCancel={handleCancelClose}
/>
</div> </div>
{/if} {/if}
+1
View File
@@ -39,6 +39,7 @@ vi.mock("@tauri-apps/api/core", () => ({
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,