generated from nhcarrigan/template
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
fe7027c585
|
|||
| 89a0bdd8f1 | |||
|
2e3f203508
|
|||
| b745100bd5 | |||
|
1bb7eb4d26
|
|||
| a4e6788573 | |||
|
d2e0915a75
|
|||
| d8cf5504d6 | |||
|
bd3438c7be
|
|||
| 778e016bf5 | |||
| 0ea7861047 | |||
| 381bc8410a | |||
|
fdb356a62c
|
|||
| f173892aaa | |||
|
34e9af57f0
|
|||
| bf411adeb7 | |||
| 97a93c31c2 | |||
| 3e7cb7ef60 | |||
| 136f95cd1a |
@@ -0,0 +1,177 @@
|
|||||||
|
# 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!
|
||||||
+7
-3
@@ -1,5 +1,9 @@
|
|||||||
#!/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'
|
||||||
@@ -32,11 +36,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" "pnpm test" || failed=1
|
run_check "Frontend tests with coverage" "pnpm test:coverage" || 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" "cargo test" || failed=1
|
run_check "Backend tests with coverage" "(cd src-tauri && cargo llvm-cov --fail-under-lines 50)" || failed=1
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
echo -e "\n${YELLOW}========================================${NC}"
|
echo -e "\n${YELLOW}========================================${NC}"
|
||||||
|
|||||||
+63
-62
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hikari-desktop",
|
"name": "hikari-desktop",
|
||||||
"version": "1.3.0",
|
"version": "1.8.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -27,68 +27,69 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.8.1",
|
"@codemirror/commands": "6.10.2",
|
||||||
"@codemirror/lang-angular": "^0.1.4",
|
"@codemirror/lang-angular": "0.1.4",
|
||||||
"@codemirror/lang-cpp": "^6.0.3",
|
"@codemirror/lang-cpp": "6.0.3",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "6.3.1",
|
||||||
"@codemirror/lang-go": "^6.0.1",
|
"@codemirror/lang-go": "6.0.1",
|
||||||
"@codemirror/lang-html": "^6.4.11",
|
"@codemirror/lang-html": "6.4.11",
|
||||||
"@codemirror/lang-java": "^6.0.2",
|
"@codemirror/lang-java": "6.0.2",
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "6.2.4",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "6.0.2",
|
||||||
"@codemirror/lang-less": "^6.0.2",
|
"@codemirror/lang-less": "6.0.2",
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
"@codemirror/lang-markdown": "6.5.0",
|
||||||
"@codemirror/lang-php": "^6.0.2",
|
"@codemirror/lang-php": "6.0.2",
|
||||||
"@codemirror/lang-python": "^6.2.1",
|
"@codemirror/lang-python": "6.2.1",
|
||||||
"@codemirror/lang-rust": "^6.0.2",
|
"@codemirror/lang-rust": "6.0.2",
|
||||||
"@codemirror/lang-sass": "^6.0.2",
|
"@codemirror/lang-sass": "6.0.2",
|
||||||
"@codemirror/lang-sql": "^6.10.0",
|
"@codemirror/lang-sql": "6.10.0",
|
||||||
"@codemirror/lang-vue": "^0.1.3",
|
"@codemirror/lang-vue": "0.1.3",
|
||||||
"@codemirror/lang-wast": "^6.0.2",
|
"@codemirror/lang-wast": "6.0.2",
|
||||||
"@codemirror/lang-xml": "^6.1.0",
|
"@codemirror/lang-xml": "6.1.0",
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "6.1.2",
|
||||||
"@codemirror/language": "^6.12.1",
|
"@codemirror/language": "6.12.2",
|
||||||
"@codemirror/legacy-modes": "^6.5.2",
|
"@codemirror/legacy-modes": "6.5.2",
|
||||||
"@codemirror/state": "^6.5.4",
|
"@codemirror/state": "6.5.4",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "6.1.3",
|
||||||
"@codemirror/view": "^6.39.11",
|
"@codemirror/view": "6.39.15",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "1.2.3",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "2.10.1",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
|
||||||
"@tauri-apps/plugin-dialog": "^2",
|
"@tauri-apps/plugin-dialog": "2.6.0",
|
||||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
"@tauri-apps/plugin-fs": "2.4.5",
|
||||||
"@tauri-apps/plugin-notification": "^2",
|
"@tauri-apps/plugin-notification": "2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "2.5.3",
|
||||||
"@tauri-apps/plugin-os": "^2",
|
"@tauri-apps/plugin-os": "2.3.2",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.4",
|
"@tauri-apps/plugin-shell": "2.3.5",
|
||||||
"@tauri-apps/plugin-store": "^2",
|
"@tauri-apps/plugin-store": "2.4.2",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "6.0.2",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "11.11.1",
|
||||||
"marked": "^17.0.1"
|
"lucide-svelte": "0.575.0",
|
||||||
|
"marked": "17.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "9.39.3",
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@sveltejs/adapter-static": "3.0.10",
|
||||||
"@sveltejs/kit": "^2.9.0",
|
"@sveltejs/kit": "2.53.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "5.1.1",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "4.2.1",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "2.10.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "5.3.1",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "4.0.18",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "9.39.3",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-svelte": "^3.14.0",
|
"eslint-plugin-svelte": "3.15.0",
|
||||||
"globals": "^17.0.0",
|
"globals": "17.3.0",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "28.1.0",
|
||||||
"prettier": "^3.8.0",
|
"prettier": "3.8.1",
|
||||||
"prettier-plugin-svelte": "^3.4.1",
|
"prettier-plugin-svelte": "3.5.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "5.53.5",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "4.4.3",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "4.2.1",
|
||||||
"typescript": "~5.6.2",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "^8.53.0",
|
"typescript-eslint": "8.56.1",
|
||||||
"vite": "^6.0.3",
|
"vite": "6.4.1",
|
||||||
"vitest": "^4.0.17"
|
"vitest": "4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+701
-676
File diff suppressed because it is too large
Load Diff
Generated
+568
-299
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "hikari-desktop"
|
name = "hikari-desktop"
|
||||||
version = "1.3.0"
|
version = "1.8.1"
|
||||||
description = "Hikari - Claude Code Visual Assistant"
|
description = "Hikari - Claude Code Visual Assistant"
|
||||||
authors = ["Naomi Carrigan"]
|
authors = ["Naomi Carrigan"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -33,6 +33,8 @@ 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 = [
|
||||||
|
|||||||
@@ -28,8 +28,17 @@
|
|||||||
"identifier": "fs:allow-write-file",
|
"identifier": "fs:allow-write-file",
|
||||||
"allow": [{ "path": "**" }]
|
"allow": [{ "path": "**" }]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:scope",
|
||||||
|
"allow": [{ "path": "$HOME/.claude/**" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-read-text-file",
|
||||||
|
"allow": [{ "path": "$HOME/.claude/**" }]
|
||||||
|
},
|
||||||
"core:window:allow-set-size",
|
"core:window:allow-set-size",
|
||||||
"core:window:allow-set-always-on-top",
|
"core:window:allow-set-always-on-top",
|
||||||
"core:window:allow-inner-size"
|
"core:window:allow-inner-size",
|
||||||
|
"core:window:allow-hide"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1671,7 +1671,7 @@ pub fn check_message_achievements(
|
|||||||
let mut newly_unlocked = Vec::new();
|
let mut newly_unlocked = Vec::new();
|
||||||
let message_lower = message.to_lowercase();
|
let message_lower = message.to_lowercase();
|
||||||
|
|
||||||
println!("Checking message achievements for: {}", message);
|
tracing::info!("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();
|
||||||
|
|
||||||
println!(
|
tracing::info!(
|
||||||
"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
|
||||||
);
|
);
|
||||||
println!("Currently unlocked: {:?}", progress.unlocked);
|
tracing::info!("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) {
|
||||||
println!("Unlocked FirstSteps achievement!");
|
tracing::info!("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();
|
||||||
|
|
||||||
println!("Saving achievements: {:?}", unlocked_list);
|
tracing::info!("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())?;
|
||||||
|
|
||||||
println!("Achievements saved successfully");
|
tracing::info!("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 {
|
||||||
println!("Loading achievements from store...");
|
tracing::info!("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) => {
|
||||||
println!("Failed to open achievements store: {}", e);
|
tracing::error!("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") {
|
||||||
println!("Found unlocked value in store: {:?}", unlocked_value);
|
tracing::info!("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())
|
||||||
{
|
{
|
||||||
println!("Loaded {} achievements", unlocked_list.len());
|
tracing::info!("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 {
|
||||||
println!("Failed to parse unlocked achievements");
|
tracing::error!("Failed to parse unlocked achievements");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("No unlocked achievements found in store");
|
tracing::info!("No unlocked achievements found in store");
|
||||||
}
|
}
|
||||||
|
|
||||||
progress
|
progress
|
||||||
@@ -2329,6 +2329,10 @@ mod tests {
|
|||||||
context_utilisation_percent: 0.0,
|
context_utilisation_percent: 0.0,
|
||||||
potential_cache_hits: 0,
|
potential_cache_hits: 0,
|
||||||
potential_cache_savings_tokens: 0,
|
potential_cache_savings_tokens: 0,
|
||||||
|
current_request_input: None,
|
||||||
|
current_request_output_chars: 0,
|
||||||
|
current_request_thinking_chars: 0,
|
||||||
|
current_request_tools: Vec::new(),
|
||||||
achievements: AchievementProgress::new(),
|
achievements: AchievementProgress::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,3 +173,127 @@ 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1492
-5
File diff suppressed because it is too large
Load Diff
+40
-6
@@ -25,9 +25,16 @@ pub struct ClaudeStartOptions {
|
|||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub resume_session_id: Option<String>,
|
pub resume_session_id: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub use_worktree: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub disable_1m_context: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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>,
|
||||||
@@ -71,9 +78,6 @@ 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,
|
||||||
|
|
||||||
@@ -115,6 +119,22 @@ pub struct HikariConfig {
|
|||||||
|
|
||||||
#[serde(default = "default_discord_rpc_enabled")]
|
#[serde(default = "default_discord_rpc_enabled")]
|
||||||
pub discord_rpc_enabled: bool,
|
pub discord_rpc_enabled: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub use_worktree: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub disable_1m_context: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub trusted_workspaces: Vec<String>,
|
||||||
|
|
||||||
|
// Background image settings
|
||||||
|
#[serde(default)]
|
||||||
|
pub background_image_path: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default = "default_background_image_opacity")]
|
||||||
|
pub background_image_opacity: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HikariConfig {
|
impl Default for HikariConfig {
|
||||||
@@ -134,7 +154,6 @@ 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,
|
||||||
@@ -148,6 +167,11 @@ impl Default for HikariConfig {
|
|||||||
budget_action: BudgetAction::Warn,
|
budget_action: BudgetAction::Warn,
|
||||||
budget_warning_threshold: 0.8,
|
budget_warning_threshold: 0.8,
|
||||||
discord_rpc_enabled: true,
|
discord_rpc_enabled: true,
|
||||||
|
use_worktree: false,
|
||||||
|
disable_1m_context: false,
|
||||||
|
trusted_workspaces: Vec::new(),
|
||||||
|
background_image_path: None,
|
||||||
|
background_image_opacity: 0.3,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,6 +208,10 @@ fn default_discord_rpc_enabled() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_background_image_opacity() -> f32 {
|
||||||
|
0.3
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum BudgetAction {
|
pub enum BudgetAction {
|
||||||
@@ -242,7 +270,6 @@ 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);
|
||||||
@@ -256,6 +283,9 @@ mod tests {
|
|||||||
assert_eq!(config.budget_action, BudgetAction::Warn);
|
assert_eq!(config.budget_action, BudgetAction::Warn);
|
||||||
assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON);
|
assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON);
|
||||||
assert!(config.discord_rpc_enabled);
|
assert!(config.discord_rpc_enabled);
|
||||||
|
assert!(!config.use_worktree);
|
||||||
|
assert!(!config.disable_1m_context);
|
||||||
|
assert!(config.trusted_workspaces.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -275,7 +305,6 @@ 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,
|
||||||
@@ -289,6 +318,11 @@ mod tests {
|
|||||||
budget_action: BudgetAction::Block,
|
budget_action: BudgetAction::Block,
|
||||||
budget_warning_threshold: 0.75,
|
budget_warning_threshold: 0.75,
|
||||||
discord_rpc_enabled: true,
|
discord_rpc_enabled: true,
|
||||||
|
use_worktree: true,
|
||||||
|
disable_1m_context: false,
|
||||||
|
trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()],
|
||||||
|
background_image_path: Some("/home/naomi/bg.png".to_string()),
|
||||||
|
background_image_opacity: 0.25,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use tracing::{Level, Subscriber};
|
||||||
|
use tracing_subscriber::layer::{Context, Layer};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DebugLogEvent {
|
||||||
|
pub level: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct TauriLogLayer {
|
||||||
|
app: Arc<AppHandle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TauriLogLayer {
|
||||||
|
pub fn new(app: AppHandle) -> Self {
|
||||||
|
Self {
|
||||||
|
app: Arc::new(app),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> Layer<S> for TauriLogLayer
|
||||||
|
where
|
||||||
|
S: Subscriber,
|
||||||
|
{
|
||||||
|
fn on_event(
|
||||||
|
&self,
|
||||||
|
event: &tracing::Event<'_>,
|
||||||
|
_ctx: Context<'_, S>,
|
||||||
|
) {
|
||||||
|
let metadata = event.metadata();
|
||||||
|
let level = match *metadata.level() {
|
||||||
|
Level::ERROR => "error",
|
||||||
|
Level::WARN => "warn",
|
||||||
|
Level::INFO => "info",
|
||||||
|
Level::DEBUG => "debug",
|
||||||
|
Level::TRACE => "debug",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract message from the event
|
||||||
|
struct MessageVisitor {
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl tracing::field::Visit for MessageVisitor {
|
||||||
|
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
||||||
|
if field.name() == "message" {
|
||||||
|
self.message = format!("{:?}", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut visitor = MessageVisitor {
|
||||||
|
message: String::new(),
|
||||||
|
};
|
||||||
|
event.record(&mut visitor);
|
||||||
|
|
||||||
|
// If we couldn't extract a message, try to format the whole event
|
||||||
|
if visitor.message.is_empty() {
|
||||||
|
visitor.message = metadata.name().to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip quotes from the message
|
||||||
|
let message = visitor.message.trim_matches('"').to_string();
|
||||||
|
|
||||||
|
let log_event = DebugLogEvent {
|
||||||
|
level: level.to_string(),
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emit to frontend
|
||||||
|
let _ = self.app.emit("debug:log", log_event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_debug_log_event_creation() {
|
||||||
|
let event = DebugLogEvent {
|
||||||
|
level: "info".to_string(),
|
||||||
|
message: "Test message".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(event.level, "info");
|
||||||
|
assert_eq!(event.message, "Test message");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_debug_log_event_serialization() {
|
||||||
|
let event = DebugLogEvent {
|
||||||
|
level: "error".to_string(),
|
||||||
|
message: "Error occurred".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(json.contains("\"level\":\"error\""));
|
||||||
|
assert!(json.contains("\"message\":\"Error occurred\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_debug_log_event_deserialization() {
|
||||||
|
let json = r#"{"level":"warn","message":"Warning message"}"#;
|
||||||
|
let event: DebugLogEvent = serde_json::from_str(json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(event.level, "warn");
|
||||||
|
assert_eq!(event.message, "Warning message");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_debug_log_event_with_special_characters() {
|
||||||
|
let event = DebugLogEvent {
|
||||||
|
level: "info".to_string(),
|
||||||
|
message: "Message with \"quotes\" and \n newlines".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
|
let decoded: DebugLogEvent = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(decoded.level, event.level);
|
||||||
|
assert_eq!(decoded.message, event.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_debug_log_event_with_unicode() {
|
||||||
|
let event = DebugLogEvent {
|
||||||
|
level: "debug".to_string(),
|
||||||
|
message: "Unicode: 日本語 🎉".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
|
let decoded: DebugLogEvent = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(decoded.message, "Unicode: 日本語 🎉");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_debug_log_event_all_levels() {
|
||||||
|
let levels = vec!["error", "warn", "info", "debug", "trace"];
|
||||||
|
|
||||||
|
for level in levels {
|
||||||
|
let event = DebugLogEvent {
|
||||||
|
level: level.to_string(),
|
||||||
|
message: format!("{} level message", level),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(event.level, level);
|
||||||
|
assert!(event.message.contains(level));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,13 @@
|
|||||||
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 {
|
||||||
@@ -22,82 +17,47 @@ 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> {
|
||||||
self.log("Attempting to initialize Discord RPC...");
|
tracing::debug!("Attempting to initialize Discord RPC...");
|
||||||
self.log("DEBUG: Application ID: 1391117878182281316");
|
tracing::debug!("Application ID: 1391117878182281316");
|
||||||
self.log(&format!("DEBUG: Initial session: '{}', model: '{}', timestamp: {}",
|
tracing::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);
|
||||||
self.log(&format!("ERROR: {}", error_msg));
|
tracing::error!("{}", error_msg);
|
||||||
error_msg
|
error_msg
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
self.log("DEBUG: DiscordIpcClient created successfully");
|
tracing::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);
|
||||||
self.log(&format!("ERROR: {}", error_msg));
|
tracing::error!("{}", error_msg);
|
||||||
error_msg
|
error_msg
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
self.log("DEBUG: Connected to Discord IPC socket");
|
tracing::debug!("Connected to Discord IPC socket");
|
||||||
|
|
||||||
// Set initial activity immediately after connecting
|
// Set initial activity immediately after connecting
|
||||||
self.log("DEBUG: Building initial activity...");
|
tracing::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");
|
||||||
|
|
||||||
self.log("DEBUG: Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
|
tracing::debug!("Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
|
||||||
|
|
||||||
let timestamps = Timestamps::new()
|
let timestamps = Timestamps::new()
|
||||||
.start(started_at);
|
.start(started_at);
|
||||||
|
|
||||||
self.log(&format!("DEBUG: Timestamps created - start: {}", started_at));
|
tracing::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())
|
||||||
@@ -105,19 +65,19 @@ impl DiscordRpcManager {
|
|||||||
.assets(assets)
|
.assets(assets)
|
||||||
.timestamps(timestamps);
|
.timestamps(timestamps);
|
||||||
|
|
||||||
self.log(&format!("DEBUG: Activity created - details: '{}', state: '{}'",
|
tracing::debug!("Activity created - details: '{}', state: '{}'",
|
||||||
initial_session_name, state_text));
|
initial_session_name, state_text);
|
||||||
|
|
||||||
self.log("DEBUG: Attempting to set initial activity...");
|
tracing::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);
|
||||||
self.log(&format!("ERROR: {}", error_msg));
|
tracing::error!("{}", error_msg);
|
||||||
error_msg
|
error_msg
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
self.log("DEBUG: Initial activity set successfully!");
|
tracing::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);
|
||||||
@@ -125,8 +85,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;
|
||||||
|
|
||||||
self.log(&format!("Discord RPC connected successfully with initial activity: session='{}', model='{}'",
|
tracing::info!("Discord RPC connected successfully with initial activity: session='{}', model='{}'",
|
||||||
initial_session_name, initial_model));
|
initial_session_name, initial_model);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,37 +96,37 @@ impl DiscordRpcManager {
|
|||||||
model: String,
|
model: String,
|
||||||
started_at: i64,
|
started_at: i64,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
self.log(&format!("DEBUG: update() called with session='{}', model='{}', timestamp={}",
|
tracing::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;
|
||||||
|
|
||||||
self.log("DEBUG: State variables updated");
|
tracing::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();
|
||||||
self.log(&format!("ERROR: {}", error_msg));
|
tracing::error!("{}", error_msg);
|
||||||
error_msg
|
error_msg
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
self.log("DEBUG: Client lock acquired");
|
tracing::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");
|
||||||
|
|
||||||
self.log("DEBUG: Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
|
tracing::debug!("Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
|
||||||
|
|
||||||
let timestamps = Timestamps::new()
|
let timestamps = Timestamps::new()
|
||||||
.start(started_at);
|
.start(started_at);
|
||||||
|
|
||||||
self.log(&format!("DEBUG: Timestamps created - start: {}", started_at));
|
tracing::debug!("Timestamps created - start: {}", started_at);
|
||||||
|
|
||||||
let activity = Activity::new()
|
let activity = Activity::new()
|
||||||
.details(session_name.as_str())
|
.details(session_name.as_str())
|
||||||
@@ -174,38 +134,38 @@ impl DiscordRpcManager {
|
|||||||
.assets(assets)
|
.assets(assets)
|
||||||
.timestamps(timestamps);
|
.timestamps(timestamps);
|
||||||
|
|
||||||
self.log(&format!("DEBUG: Activity created - details: '{}', state: '{}'",
|
tracing::debug!("Activity created - details: '{}', state: '{}'",
|
||||||
session_name, state_text));
|
session_name, state_text);
|
||||||
|
|
||||||
self.log("DEBUG: Attempting to set activity...");
|
tracing::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);
|
||||||
self.log(&format!("ERROR: {}", error_msg));
|
tracing::error!("{}", error_msg);
|
||||||
error_msg
|
error_msg
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
self.log(&format!("Updated Discord RPC: session='{}', model='{}'", session_name, model));
|
tracing::info!("Updated Discord RPC: session='{}', model='{}'", session_name, model);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stop(&self) -> Result<(), String> {
|
pub fn stop(&self) -> Result<(), String> {
|
||||||
self.log("DEBUG: stop() called");
|
tracing::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() {
|
||||||
self.log("DEBUG: Client found, attempting to close...");
|
tracing::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);
|
||||||
self.log(&format!("ERROR: {}", error_msg));
|
tracing::error!("{}", error_msg);
|
||||||
error_msg
|
error_msg
|
||||||
})?;
|
})?;
|
||||||
self.log("Discord RPC stopped successfully");
|
tracing::info!("Discord RPC stopped successfully");
|
||||||
} else {
|
} else {
|
||||||
self.log("DEBUG: No client to stop (already stopped or never initialized)");
|
tracing::debug!("No client to stop (already stopped or never initialized)");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
|
use crate::process_ext::HideWindow;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct GitStatus {
|
pub struct GitStatus {
|
||||||
pub is_repo: bool,
|
pub is_repo: bool,
|
||||||
@@ -37,6 +39,7 @@ pub struct GitLogEntry {
|
|||||||
|
|
||||||
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
|
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
|
.hide_window()
|
||||||
.args(args)
|
.args(args)
|
||||||
.current_dir(working_dir)
|
.current_dir(working_dir)
|
||||||
.output()
|
.output()
|
||||||
|
|||||||
+47
-14
@@ -4,9 +4,11 @@ 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;
|
||||||
|
mod process_ext;
|
||||||
mod quick_actions;
|
mod quick_actions;
|
||||||
mod sessions;
|
mod sessions;
|
||||||
mod snippets;
|
mod snippets;
|
||||||
@@ -24,6 +26,7 @@ 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::*;
|
||||||
@@ -31,9 +34,11 @@ use quick_actions::*;
|
|||||||
use sessions::*;
|
use sessions::*;
|
||||||
use snippets::*;
|
use snippets::*;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::Manager;
|
use tauri::{Emitter, Manager};
|
||||||
use temp_manager::create_shared_temp_manager;
|
use temp_manager::create_shared_temp_manager;
|
||||||
use tray::{setup_tray, should_minimize_to_tray};
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
|
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::*;
|
||||||
@@ -58,35 +63,43 @@ 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 {
|
||||||
println!("Cleaned up {} orphaned temp files", count);
|
tracing::info!("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()) {
|
||||||
eprintln!("Failed to set up system tray: {}", e);
|
tracing::error!("Failed to set up system tray: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle window close event for minimize to tray
|
// Handle window close event for minimize to tray and close confirmation
|
||||||
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 {
|
||||||
if should_minimize_to_tray(&app_handle) {
|
// Always prevent default close - let frontend handle it
|
||||||
api.prevent_close();
|
api.prevent_close();
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
|
||||||
let _ = window.hide();
|
// Emit event to frontend to show confirmation modal
|
||||||
}
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.emit("window-close-requested", ());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,6 +121,7 @@ pub fn run() {
|
|||||||
get_persisted_stats,
|
get_persisted_stats,
|
||||||
load_saved_achievements,
|
load_saved_achievements,
|
||||||
answer_question,
|
answer_question,
|
||||||
|
check_workspace_hooks,
|
||||||
send_windows_notification,
|
send_windows_notification,
|
||||||
send_simple_notification,
|
send_simple_notification,
|
||||||
send_windows_toast,
|
send_windows_toast,
|
||||||
@@ -180,7 +194,26 @@ pub fn run() {
|
|||||||
init_discord_rpc,
|
init_discord_rpc,
|
||||||
update_discord_rpc,
|
update_discord_rpc,
|
||||||
stop_discord_rpc,
|
stop_discord_rpc,
|
||||||
log_discord_rpc,
|
close_application,
|
||||||
|
list_memory_files,
|
||||||
|
get_claude_version,
|
||||||
|
get_auth_status,
|
||||||
|
auth_login,
|
||||||
|
auth_logout,
|
||||||
|
list_plugins,
|
||||||
|
install_plugin,
|
||||||
|
uninstall_plugin,
|
||||||
|
enable_plugin,
|
||||||
|
disable_plugin,
|
||||||
|
update_plugin,
|
||||||
|
list_marketplaces,
|
||||||
|
add_marketplace,
|
||||||
|
remove_marketplace,
|
||||||
|
list_mcp_servers,
|
||||||
|
get_mcp_server,
|
||||||
|
remove_mcp_server,
|
||||||
|
add_mcp_server,
|
||||||
|
get_mcp_server_details,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
+324
-29
@@ -1,34 +1,11 @@
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use tauri::command;
|
use tauri::command;
|
||||||
|
|
||||||
#[command]
|
use crate::process_ext::HideWindow;
|
||||||
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
|
|
||||||
// Use notify-send for Linux/WSL
|
|
||||||
let output = Command::new("notify-send")
|
|
||||||
.arg(&title)
|
|
||||||
.arg(&body)
|
|
||||||
.arg("--urgency=normal")
|
|
||||||
.arg("--app-name=Hikari Desktop")
|
|
||||||
.output()
|
|
||||||
.map_err(|e| {
|
|
||||||
format!(
|
|
||||||
"Failed to execute notify-send: {}. Make sure libnotify-bin is installed.",
|
|
||||||
e
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
/// Generate PowerShell script for Windows Toast Notification
|
||||||
let error = String::from_utf8_lossy(&output.stderr);
|
fn generate_powershell_toast_script(title: &str, body: &str) -> String {
|
||||||
return Err(format!("notify-send failed: {}", error));
|
format!(
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[command]
|
|
||||||
pub async fn send_windows_notification(title: String, body: String) -> Result<(), String> {
|
|
||||||
// Create PowerShell script for Windows Toast Notification
|
|
||||||
let ps_script = format!(
|
|
||||||
r#"
|
r#"
|
||||||
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
|
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
|
||||||
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
|
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
|
||||||
@@ -55,10 +32,87 @@ $toast = New-Object Windows.UI.Notifications.ToastNotification $xml
|
|||||||
"#,
|
"#,
|
||||||
title.replace("\"", "`\""),
|
title.replace("\"", "`\""),
|
||||||
body.replace("\"", "`\"")
|
body.replace("\"", "`\"")
|
||||||
);
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format simple notification message
|
||||||
|
fn format_simple_notification(title: &str, body: &str) -> String {
|
||||||
|
format!("{}\n\n{}", title, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build notify-send command for testing (doesn't execute)
|
||||||
|
#[cfg(test)]
|
||||||
|
fn build_notify_send_command(title: &str, body: &str) -> (String, Vec<String>) {
|
||||||
|
(
|
||||||
|
"notify-send".to_string(),
|
||||||
|
vec![
|
||||||
|
title.to_string(),
|
||||||
|
body.to_string(),
|
||||||
|
"--urgency=normal".to_string(),
|
||||||
|
"--app-name=Hikari Desktop".to_string(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build Windows PowerShell command for testing (doesn't execute)
|
||||||
|
#[cfg(test)]
|
||||||
|
fn build_windows_powershell_command(title: &str, body: &str) -> (String, Vec<String>) {
|
||||||
|
let script = generate_powershell_toast_script(title, body);
|
||||||
|
(
|
||||||
|
"pwsh.exe".to_string(),
|
||||||
|
vec![
|
||||||
|
"-NoProfile".to_string(),
|
||||||
|
"-WindowStyle".to_string(),
|
||||||
|
"Hidden".to_string(),
|
||||||
|
"-Command".to_string(),
|
||||||
|
script,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build simple notification command for testing (doesn't execute)
|
||||||
|
#[cfg(test)]
|
||||||
|
fn build_simple_notification_command(title: &str, body: &str) -> (String, Vec<String>) {
|
||||||
|
let message = format_simple_notification(title, body);
|
||||||
|
(
|
||||||
|
"cmd.exe".to_string(),
|
||||||
|
vec!["/c".to_string(), "msg".to_string(), "*".to_string(), message],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
|
||||||
|
// Use notify-send for Linux/WSL
|
||||||
|
let output = Command::new("notify-send")
|
||||||
|
.hide_window()
|
||||||
|
.arg(&title)
|
||||||
|
.arg(&body)
|
||||||
|
.arg("--urgency=normal")
|
||||||
|
.arg("--app-name=Hikari Desktop")
|
||||||
|
.output()
|
||||||
|
.map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"Failed to execute notify-send: {}. Make sure libnotify-bin is installed.",
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let error = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!("notify-send failed: {}", error));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn send_windows_notification(title: String, body: String) -> Result<(), String> {
|
||||||
|
// Create PowerShell script for Windows Toast Notification
|
||||||
|
let ps_script = generate_powershell_toast_script(&title, &body);
|
||||||
|
|
||||||
// Try PowerShell Core first (pwsh), then fall back to Windows PowerShell
|
// Try PowerShell Core first (pwsh), then fall back to Windows PowerShell
|
||||||
let output = Command::new("pwsh.exe")
|
let output = Command::new("pwsh.exe")
|
||||||
|
.hide_window()
|
||||||
.arg("-NoProfile")
|
.arg("-NoProfile")
|
||||||
.arg("-WindowStyle")
|
.arg("-WindowStyle")
|
||||||
.arg("Hidden")
|
.arg("Hidden")
|
||||||
@@ -67,6 +121,7 @@ $toast = New-Object Windows.UI.Notifications.ToastNotification $xml
|
|||||||
.output()
|
.output()
|
||||||
.or_else(|_| {
|
.or_else(|_| {
|
||||||
Command::new("powershell.exe")
|
Command::new("powershell.exe")
|
||||||
|
.hide_window()
|
||||||
.arg("-NoProfile")
|
.arg("-NoProfile")
|
||||||
.arg("-WindowStyle")
|
.arg("-WindowStyle")
|
||||||
.arg("Hidden")
|
.arg("Hidden")
|
||||||
@@ -87,9 +142,10 @@ $toast = New-Object Windows.UI.Notifications.ToastNotification $xml
|
|||||||
// 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!("{}\n\n{}", title, body);
|
let message = format_simple_notification(&title, &body);
|
||||||
|
|
||||||
Command::new("cmd.exe")
|
Command::new("cmd.exe")
|
||||||
|
.hide_window()
|
||||||
.arg("/c")
|
.arg("/c")
|
||||||
.arg("msg")
|
.arg("msg")
|
||||||
.arg("*")
|
.arg("*")
|
||||||
@@ -99,3 +155,242 @@ 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// Extension trait for `Command` that hides the console window on Windows.
|
||||||
|
///
|
||||||
|
/// On non-Windows platforms this is a no-op, so callers can unconditionally
|
||||||
|
/// chain `.hide_window()` without any `#[cfg]` guards at the call sites.
|
||||||
|
pub trait HideWindow {
|
||||||
|
fn hide_window(&mut self) -> &mut Self;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HideWindow for Command {
|
||||||
|
fn hide_window(&mut self) -> &mut Self {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
self.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
-31
@@ -86,6 +86,9 @@ 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
|
||||||
|
"claude-opus-4-6" => 200_000,
|
||||||
|
"claude-sonnet-4-6" => 1_000_000, // 1M token context window
|
||||||
// Claude 4.5 family - 200K standard context
|
// Claude 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"
|
||||||
@@ -154,6 +157,16 @@ pub struct UsageStats {
|
|||||||
// Achievement tracking
|
// Achievement tracking
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub achievements: AchievementProgress,
|
pub achievements: AchievementProgress,
|
||||||
|
|
||||||
|
// Track current in-flight request for cost estimation on interrupt
|
||||||
|
#[serde(skip)]
|
||||||
|
pub current_request_input: Option<String>,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub current_request_output_chars: u64,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub current_request_thinking_chars: u64,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub current_request_tools: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UsageStats {
|
impl UsageStats {
|
||||||
@@ -163,13 +176,26 @@ impl UsageStats {
|
|||||||
stats
|
stats
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_usage(&mut self, input_tokens: u64, output_tokens: u64, model: &str) {
|
pub fn add_usage(
|
||||||
|
&mut self,
|
||||||
|
input_tokens: u64,
|
||||||
|
output_tokens: u64,
|
||||||
|
model: &str,
|
||||||
|
cache_creation_tokens: Option<u64>,
|
||||||
|
cache_read_tokens: Option<u64>,
|
||||||
|
) {
|
||||||
self.total_input_tokens += input_tokens;
|
self.total_input_tokens += input_tokens;
|
||||||
self.total_output_tokens += output_tokens;
|
self.total_output_tokens += output_tokens;
|
||||||
self.session_input_tokens += input_tokens;
|
self.session_input_tokens += input_tokens;
|
||||||
self.session_output_tokens += output_tokens;
|
self.session_output_tokens += output_tokens;
|
||||||
|
|
||||||
let cost = calculate_cost(input_tokens, output_tokens, model);
|
let cost = calculate_cost(
|
||||||
|
input_tokens,
|
||||||
|
output_tokens,
|
||||||
|
model,
|
||||||
|
cache_creation_tokens,
|
||||||
|
cache_read_tokens,
|
||||||
|
);
|
||||||
self.total_cost_usd += cost;
|
self.total_cost_usd += cost;
|
||||||
self.session_cost_usd += cost;
|
self.session_cost_usd += cost;
|
||||||
|
|
||||||
@@ -439,6 +465,10 @@ impl UsageStats {
|
|||||||
potential_cache_hits: self.potential_cache_hits,
|
potential_cache_hits: self.potential_cache_hits,
|
||||||
potential_cache_savings_tokens: self.potential_cache_savings_tokens,
|
potential_cache_savings_tokens: self.potential_cache_savings_tokens,
|
||||||
achievements: AchievementProgress::new(), // Dummy for copy
|
achievements: AchievementProgress::new(), // Dummy for copy
|
||||||
|
current_request_input: None, // Don't copy tracking fields
|
||||||
|
current_request_output_chars: 0,
|
||||||
|
current_request_thinking_chars: 0,
|
||||||
|
current_request_tools: Vec::new(),
|
||||||
};
|
};
|
||||||
check_achievements(&stats_copy, &mut self.achievements)
|
check_achievements(&stats_copy, &mut self.achievements)
|
||||||
}
|
}
|
||||||
@@ -462,9 +492,20 @@ fn is_consecutive_day(prev_date: &str, current_date: &str) -> bool {
|
|||||||
|
|
||||||
// Pricing as of February 2026
|
// Pricing as of February 2026
|
||||||
// https://platform.claude.com/docs/en/about-claude/models/overview
|
// https://platform.claude.com/docs/en/about-claude/models/overview
|
||||||
pub fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
|
// Cache pricing: https://platform.claude.com/docs/en/build-with-claude/prompt-caching
|
||||||
|
pub fn calculate_cost(
|
||||||
|
input_tokens: u64,
|
||||||
|
output_tokens: u64,
|
||||||
|
model: &str,
|
||||||
|
cache_creation_tokens: Option<u64>,
|
||||||
|
cache_read_tokens: Option<u64>,
|
||||||
|
) -> f64 {
|
||||||
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.5)
|
// Current generation (Claude 4.6)
|
||||||
|
"claude-opus-4-6" => (5.0, 25.0),
|
||||||
|
"claude-sonnet-4-6" => (3.0, 15.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),
|
||||||
@@ -487,10 +528,25 @@ pub fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64
|
|||||||
_ => (3.0, 15.0),
|
_ => (3.0, 15.0),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Regular input/output tokens
|
||||||
let input_cost = (input_tokens as f64 / 1_000_000.0) * input_price_per_million;
|
let input_cost = (input_tokens as f64 / 1_000_000.0) * input_price_per_million;
|
||||||
let output_cost = (output_tokens as f64 / 1_000_000.0) * output_price_per_million;
|
let output_cost = (output_tokens as f64 / 1_000_000.0) * output_price_per_million;
|
||||||
|
|
||||||
input_cost + output_cost
|
// Cache write tokens (cache creation) cost 1.25x the base input price
|
||||||
|
let cache_write_cost = if let Some(cache_creation) = cache_creation_tokens {
|
||||||
|
(cache_creation as f64 / 1_000_000.0) * input_price_per_million * 1.25
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache read tokens cost 0.1x (10%) the base input price
|
||||||
|
let cache_read_cost = if let Some(cache_read) = cache_read_tokens {
|
||||||
|
(cache_read as f64 / 1_000_000.0) * input_price_per_million * 0.1
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
input_cost + output_cost + cache_write_cost + cache_read_cost
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -564,7 +620,7 @@ pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<()
|
|||||||
|
|
||||||
let persisted = PersistedStats::from(stats);
|
let persisted = PersistedStats::from(stats);
|
||||||
|
|
||||||
println!("Saving stats: {:?}", persisted);
|
tracing::info!("Saving stats: {:?}", persisted);
|
||||||
|
|
||||||
store.set(
|
store.set(
|
||||||
"lifetime_stats",
|
"lifetime_stats",
|
||||||
@@ -572,32 +628,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())?;
|
||||||
|
|
||||||
println!("Stats saved successfully");
|
tracing::info!("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> {
|
||||||
println!("Loading stats from store...");
|
tracing::info!("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) => {
|
||||||
println!("Failed to open stats store: {}", e);
|
tracing::error!("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") {
|
||||||
println!("Found lifetime stats in store: {:?}", stats_value);
|
tracing::info!("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()) {
|
||||||
println!("Loaded lifetime stats successfully");
|
tracing::info!("Loaded lifetime stats successfully");
|
||||||
return Some(persisted);
|
return Some(persisted);
|
||||||
} else {
|
} else {
|
||||||
println!("Failed to parse lifetime stats");
|
tracing::error!("Failed to parse lifetime stats");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("No lifetime stats found in store");
|
tracing::info!("No lifetime stats found in store");
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
@@ -609,7 +665,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cost_calculation_sonnet() {
|
fn test_cost_calculation_sonnet() {
|
||||||
let cost = calculate_cost(1000, 2000, "claude-sonnet-4-20250514");
|
let cost = calculate_cost(1000, 2000, "claude-sonnet-4-20250514", None, None);
|
||||||
// 1000 input * $3/M = $0.003
|
// 1000 input * $3/M = $0.003
|
||||||
// 2000 output * $15/M = $0.030
|
// 2000 output * $15/M = $0.030
|
||||||
// Total = $0.033
|
// Total = $0.033
|
||||||
@@ -618,7 +674,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cost_calculation_opus() {
|
fn test_cost_calculation_opus() {
|
||||||
let cost = calculate_cost(1000, 2000, "claude-opus-4-20250514");
|
let cost = calculate_cost(1000, 2000, "claude-opus-4-20250514", None, None);
|
||||||
// 1000 input * $15/M = $0.015
|
// 1000 input * $15/M = $0.015
|
||||||
// 2000 output * $75/M = $0.150
|
// 2000 output * $75/M = $0.150
|
||||||
// Total = $0.165
|
// Total = $0.165
|
||||||
@@ -627,7 +683,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cost_calculation_opus_45() {
|
fn test_cost_calculation_opus_45() {
|
||||||
let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101");
|
let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101", None, None);
|
||||||
// Opus 4.5 pricing: $5/MTok input, $25/MTok output
|
// Opus 4.5 pricing: $5/MTok input, $25/MTok output
|
||||||
// 1000 input tokens = $0.005, 2000 output tokens = $0.05
|
// 1000 input tokens = $0.005, 2000 output tokens = $0.05
|
||||||
// Total = $0.055
|
// Total = $0.055
|
||||||
@@ -636,7 +692,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cost_calculation_haiku() {
|
fn test_cost_calculation_haiku() {
|
||||||
let cost = calculate_cost(1000, 2000, "claude-3-5-haiku-20241022");
|
let cost = calculate_cost(1000, 2000, "claude-3-5-haiku-20241022", None, None);
|
||||||
// 1000 input * $1/M = $0.001
|
// 1000 input * $1/M = $0.001
|
||||||
// 2000 output * $5/M = $0.010
|
// 2000 output * $5/M = $0.010
|
||||||
// Total = $0.011
|
// Total = $0.011
|
||||||
@@ -645,14 +701,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cost_calculation_unknown_defaults_to_sonnet() {
|
fn test_cost_calculation_unknown_defaults_to_sonnet() {
|
||||||
let cost = calculate_cost(1000, 2000, "some-unknown-model");
|
let cost = calculate_cost(1000, 2000, "some-unknown-model", None, None);
|
||||||
// Should default to Sonnet pricing
|
// Should default to Sonnet pricing
|
||||||
assert!((cost - 0.033).abs() < 0.0001);
|
assert!((cost - 0.033).abs() < 0.0001);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cost_calculation_legacy_sonnet() {
|
fn test_cost_calculation_legacy_sonnet() {
|
||||||
let cost = calculate_cost(1000, 2000, "claude-3-5-sonnet-20241022");
|
let cost = calculate_cost(1000, 2000, "claude-3-5-sonnet-20241022", None, None);
|
||||||
// Same as Sonnet 4 pricing
|
// Same as Sonnet 4 pricing
|
||||||
assert!((cost - 0.033).abs() < 0.0001);
|
assert!((cost - 0.033).abs() < 0.0001);
|
||||||
}
|
}
|
||||||
@@ -660,7 +716,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_usage_stats_accumulation() {
|
fn test_usage_stats_accumulation() {
|
||||||
let mut stats = UsageStats::new();
|
let mut stats = UsageStats::new();
|
||||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
|
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514", None, None);
|
||||||
|
|
||||||
assert_eq!(stats.total_input_tokens, 1000);
|
assert_eq!(stats.total_input_tokens, 1000);
|
||||||
assert_eq!(stats.total_output_tokens, 2000);
|
assert_eq!(stats.total_output_tokens, 2000);
|
||||||
@@ -672,8 +728,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_usage_stats_multiple_accumulations() {
|
fn test_usage_stats_multiple_accumulations() {
|
||||||
let mut stats = UsageStats::new();
|
let mut stats = UsageStats::new();
|
||||||
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514");
|
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514", None, None);
|
||||||
stats.add_usage(500, 500, "claude-sonnet-4-20250514");
|
stats.add_usage(500, 500, "claude-sonnet-4-20250514", None, None);
|
||||||
|
|
||||||
assert_eq!(stats.total_input_tokens, 1500);
|
assert_eq!(stats.total_input_tokens, 1500);
|
||||||
assert_eq!(stats.total_output_tokens, 1500);
|
assert_eq!(stats.total_output_tokens, 1500);
|
||||||
@@ -684,17 +740,17 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_usage_stats_model_updated() {
|
fn test_usage_stats_model_updated() {
|
||||||
let mut stats = UsageStats::new();
|
let mut stats = UsageStats::new();
|
||||||
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514");
|
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514", None, None);
|
||||||
assert_eq!(stats.model, Some("claude-sonnet-4-20250514".to_string()));
|
assert_eq!(stats.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||||
|
|
||||||
stats.add_usage(500, 500, "claude-opus-4-20250514");
|
stats.add_usage(500, 500, "claude-opus-4-20250514", None, None);
|
||||||
assert_eq!(stats.model, Some("claude-opus-4-20250514".to_string()));
|
assert_eq!(stats.model, Some("claude-opus-4-20250514".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_session_reset() {
|
fn test_session_reset() {
|
||||||
let mut stats = UsageStats::new();
|
let mut stats = UsageStats::new();
|
||||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
|
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514", None, None);
|
||||||
stats.reset_session();
|
stats.reset_session();
|
||||||
|
|
||||||
assert_eq!(stats.total_input_tokens, 1000);
|
assert_eq!(stats.total_input_tokens, 1000);
|
||||||
@@ -921,7 +977,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_usage_stats_serialization() {
|
fn test_usage_stats_serialization() {
|
||||||
let mut stats = UsageStats::new();
|
let mut stats = UsageStats::new();
|
||||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
|
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514", None, None);
|
||||||
stats.increment_messages();
|
stats.increment_messages();
|
||||||
|
|
||||||
// UsageStats should be serializable (for events)
|
// UsageStats should be serializable (for events)
|
||||||
@@ -950,7 +1006,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_stats_update_event_serialization() {
|
fn test_stats_update_event_serialization() {
|
||||||
let mut stats = UsageStats::new();
|
let mut stats = UsageStats::new();
|
||||||
stats.add_usage(100, 200, "claude-sonnet-4-20250514");
|
stats.add_usage(100, 200, "claude-sonnet-4-20250514", None, None);
|
||||||
|
|
||||||
let event = StatsUpdateEvent { stats };
|
let event = StatsUpdateEvent { stats };
|
||||||
let json = serde_json::to_string(&event).expect("Failed to serialize");
|
let json = serde_json::to_string(&event).expect("Failed to serialize");
|
||||||
@@ -1004,7 +1060,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_context_tracking_update() {
|
fn test_context_tracking_update() {
|
||||||
let mut stats = UsageStats::new();
|
let mut stats = UsageStats::new();
|
||||||
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514");
|
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514", None, None);
|
||||||
|
|
||||||
assert_eq!(stats.context_tokens_used, 50_000);
|
assert_eq!(stats.context_tokens_used, 50_000);
|
||||||
assert_eq!(stats.context_window_limit, 200_000);
|
assert_eq!(stats.context_window_limit, 200_000);
|
||||||
@@ -1014,8 +1070,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_context_tracking_accumulates() {
|
fn test_context_tracking_accumulates() {
|
||||||
let mut stats = UsageStats::new();
|
let mut stats = UsageStats::new();
|
||||||
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514");
|
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514", None, None);
|
||||||
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514");
|
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514", None, None);
|
||||||
|
|
||||||
assert_eq!(stats.context_tokens_used, 100_000);
|
assert_eq!(stats.context_tokens_used, 100_000);
|
||||||
assert!((stats.context_utilisation_percent - 50.0).abs() < 0.1);
|
assert!((stats.context_utilisation_percent - 50.0).abs() < 0.1);
|
||||||
@@ -1079,7 +1135,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_context_reset_on_session_reset() {
|
fn test_context_reset_on_session_reset() {
|
||||||
let mut stats = UsageStats::new();
|
let mut stats = UsageStats::new();
|
||||||
stats.add_usage(100_000, 20_000, "claude-sonnet-4-20250514");
|
stats.add_usage(100_000, 20_000, "claude-sonnet-4-20250514", None, None);
|
||||||
|
|
||||||
assert!(stats.context_tokens_used > 0);
|
assert!(stats.context_tokens_used > 0);
|
||||||
assert!(stats.context_utilisation_percent > 0.0);
|
assert!(stats.context_utilisation_percent > 0.0);
|
||||||
|
|||||||
@@ -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) {
|
||||||
eprintln!(
|
tracing::warn!(
|
||||||
"Warning: Failed to remove temp file {:?}: {}",
|
"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) {
|
||||||
eprintln!("Warning: Failed to remove orphaned file {:?}: {}", path, e);
|
tracing::warn!("Failed to remove orphaned file {:?}: {}", path, e);
|
||||||
} else {
|
} else {
|
||||||
cleaned_count += 1;
|
cleaned_count += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ 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>)?;
|
||||||
@@ -48,21 +46,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|||||||
+156
-1
@@ -4,6 +4,10 @@ use serde::{Deserialize, Serialize};
|
|||||||
pub struct UsageInfo {
|
pub struct UsageInfo {
|
||||||
pub input_tokens: u64,
|
pub input_tokens: u64,
|
||||||
pub output_tokens: u64,
|
pub output_tokens: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_creation_input_tokens: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_read_input_tokens: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
@@ -59,6 +63,26 @@ pub struct PermissionDenial {
|
|||||||
pub tool_input: serde_json::Value,
|
pub tool_input: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rate limit information from a `rate_limit_event` message.
|
||||||
|
/// All fields are optional to ensure forward-compatibility as the Claude CLI evolves.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct RateLimitInfo {
|
||||||
|
#[serde(default)]
|
||||||
|
pub requests_limit: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub requests_remaining: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub requests_reset: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tokens_limit: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tokens_remaining: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tokens_reset: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub retry_after_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum ClaudeMessage {
|
pub enum ClaudeMessage {
|
||||||
@@ -96,6 +120,11 @@ pub enum ClaudeMessage {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
usage: Option<UsageInfo>,
|
usage: Option<UsageInfo>,
|
||||||
},
|
},
|
||||||
|
#[serde(rename = "rate_limit_event")]
|
||||||
|
RateLimitEvent {
|
||||||
|
#[serde(default)]
|
||||||
|
rate_limit_info: RateLimitInfo,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -193,14 +222,21 @@ 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PermissionPromptEvent {
|
pub struct PermissionPromptEventItem {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub tool_name: String,
|
pub tool_name: String,
|
||||||
pub tool_input: serde_json::Value,
|
pub tool_input: serde_json::Value,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PermissionPromptEvent {
|
||||||
|
pub permissions: Vec<PermissionPromptEventItem>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub conversation_id: Option<String>,
|
pub conversation_id: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -244,6 +280,50 @@ pub struct UserQuestionEvent {
|
|||||||
pub conversation_id: Option<String>,
|
pub conversation_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AgentStartEvent {
|
||||||
|
pub tool_use_id: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub agent_id: Option<String>,
|
||||||
|
pub description: String,
|
||||||
|
pub subagent_type: String,
|
||||||
|
pub started_at: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_tool_use_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AgentEndEvent {
|
||||||
|
pub tool_use_id: String,
|
||||||
|
pub ended_at: u64,
|
||||||
|
pub is_error: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub duration_ms: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub num_turns: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_assistant_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TodoItem {
|
||||||
|
pub content: String,
|
||||||
|
pub status: String, // "pending", "in_progress", or "completed"
|
||||||
|
#[serde(rename = "activeForm")]
|
||||||
|
pub active_form: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TodoUpdateEvent {
|
||||||
|
pub todos: Vec<TodoItem>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -365,6 +445,7 @@ 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();
|
||||||
@@ -384,6 +465,7 @@ 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();
|
||||||
@@ -391,4 +473,77 @@ mod tests {
|
|||||||
assert!(serialized.contains("\"input_tokens\":100"));
|
assert!(serialized.contains("\"input_tokens\":100"));
|
||||||
assert!(serialized.contains("\"output_tokens\":50"));
|
assert!(serialized.contains("\"output_tokens\":50"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_info_default() {
|
||||||
|
let info = RateLimitInfo::default();
|
||||||
|
assert!(info.requests_limit.is_none());
|
||||||
|
assert!(info.requests_remaining.is_none());
|
||||||
|
assert!(info.requests_reset.is_none());
|
||||||
|
assert!(info.tokens_limit.is_none());
|
||||||
|
assert!(info.tokens_remaining.is_none());
|
||||||
|
assert!(info.tokens_reset.is_none());
|
||||||
|
assert!(info.retry_after_ms.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_event_deserialization_empty_info() {
|
||||||
|
let json = r#"{"type":"rate_limit_event","rate_limit_info":{}}"#;
|
||||||
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_event_deserialization_no_info() {
|
||||||
|
// rate_limit_info field is optional via #[serde(default)]
|
||||||
|
let json = r#"{"type":"rate_limit_event"}"#;
|
||||||
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_event_deserialization_with_data() {
|
||||||
|
let json = r#"{
|
||||||
|
"type": "rate_limit_event",
|
||||||
|
"rate_limit_info": {
|
||||||
|
"requests_limit": 1000,
|
||||||
|
"requests_remaining": 0,
|
||||||
|
"requests_reset": "2024-01-01T00:01:00Z",
|
||||||
|
"tokens_limit": 50000,
|
||||||
|
"tokens_remaining": 0,
|
||||||
|
"tokens_reset": "2024-01-01T00:01:00Z",
|
||||||
|
"retry_after_ms": 60000
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||||
|
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
|
||||||
|
assert_eq!(rate_limit_info.requests_limit, Some(1000));
|
||||||
|
assert_eq!(rate_limit_info.requests_remaining, Some(0));
|
||||||
|
assert_eq!(
|
||||||
|
rate_limit_info.requests_reset,
|
||||||
|
Some("2024-01-01T00:01:00Z".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(rate_limit_info.retry_after_ms, Some(60000));
|
||||||
|
} else {
|
||||||
|
panic!("Expected RateLimitEvent variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_event_ignores_unknown_fields() {
|
||||||
|
// Ensures forward-compat: unknown fields in rate_limit_info are silently ignored
|
||||||
|
let json = r#"{
|
||||||
|
"type": "rate_limit_event",
|
||||||
|
"rate_limit_info": {
|
||||||
|
"requests_remaining": 0,
|
||||||
|
"some_future_field": "some_value"
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||||
|
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
|
||||||
|
assert_eq!(rate_limit_info.requests_remaining, Some(0));
|
||||||
|
} else {
|
||||||
|
panic!("Expected RateLimitEvent variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ use std::process::Command;
|
|||||||
use tauri::command;
|
use tauri::command;
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
use crate::process_ext::HideWindow;
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> {
|
pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> {
|
||||||
// Create a VBScript that shows a Windows notification
|
// Create a VBScript that shows a Windows notification
|
||||||
@@ -40,7 +42,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
|
|||||||
} else if temp_path.starts_with("/tmp/") {
|
} else if temp_path.starts_with("/tmp/") {
|
||||||
// WSL temp files might be in a different location
|
// WSL temp files might be in a different location
|
||||||
// Try to use wslpath to convert
|
// Try to use wslpath to convert
|
||||||
let output = Command::new("wslpath").arg("-w").arg(&temp_path).output();
|
let output = Command::new("wslpath").hide_window().arg("-w").arg(&temp_path).output();
|
||||||
|
|
||||||
if let Ok(result) = output {
|
if let Ok(result) = output {
|
||||||
if result.status.success() {
|
if result.status.success() {
|
||||||
@@ -57,6 +59,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
|
|||||||
|
|
||||||
// Execute the VBScript using wscript.exe
|
// Execute the VBScript using wscript.exe
|
||||||
let output = Command::new("/mnt/c/Windows/System32/wscript.exe")
|
let output = Command::new("/mnt/c/Windows/System32/wscript.exe")
|
||||||
|
.hide_window()
|
||||||
.arg("//NoLogo")
|
.arg("//NoLogo")
|
||||||
.arg(&windows_path)
|
.arg(&windows_path)
|
||||||
.output()
|
.output()
|
||||||
|
|||||||
+1369
-139
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use tauri::command;
|
use tauri::command;
|
||||||
|
|
||||||
|
use crate::process_ext::HideWindow;
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn send_wsl_notification(title: String, body: String) -> Result<(), String> {
|
pub async fn send_wsl_notification(title: String, body: String) -> Result<(), String> {
|
||||||
// Method 1: Try Windows 10/11 toast notification using PowerShell
|
// Method 1: Try Windows 10/11 toast notification using PowerShell
|
||||||
@@ -36,6 +38,7 @@ $notifier.Show($toast)
|
|||||||
|
|
||||||
// Try PowerShell.exe through WSL
|
// Try PowerShell.exe through WSL
|
||||||
let output = Command::new("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe")
|
let output = Command::new("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe")
|
||||||
|
.hide_window()
|
||||||
.arg("-NoProfile")
|
.arg("-NoProfile")
|
||||||
.arg("-ExecutionPolicy")
|
.arg("-ExecutionPolicy")
|
||||||
.arg("Bypass")
|
.arg("Bypass")
|
||||||
@@ -48,15 +51,15 @@ $notifier.Show($toast)
|
|||||||
match output {
|
match output {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
if result.status.success() {
|
if result.status.success() {
|
||||||
println!("WSL notification sent successfully");
|
tracing::info!("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);
|
||||||
println!("PowerShell toast failed: {}", stderr);
|
tracing::error!("PowerShell toast failed: {}", stderr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
println!("Failed to run PowerShell: {}", e);
|
tracing::error!("Failed to run PowerShell: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +68,7 @@ $notifier.Show($toast)
|
|||||||
|
|
||||||
// Method 3: Try wsl-notify-send if available
|
// Method 3: Try wsl-notify-send if available
|
||||||
let notify_result = Command::new("wsl-notify-send")
|
let notify_result = Command::new("wsl-notify-send")
|
||||||
|
.hide_window()
|
||||||
.arg("--appId")
|
.arg("--appId")
|
||||||
.arg("HikariDesktop")
|
.arg("HikariDesktop")
|
||||||
.arg("--category")
|
.arg("--category")
|
||||||
@@ -74,7 +78,7 @@ $notifier.Show($toast)
|
|||||||
|
|
||||||
if let Ok(result) = notify_result {
|
if let Ok(result) = notify_result {
|
||||||
if result.status.success() {
|
if result.status.success() {
|
||||||
println!("Notification sent via wsl-notify-send");
|
tracing::info!("Notification sent via wsl-notify-send");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "hikari-desktop",
|
"productName": "hikari-desktop",
|
||||||
"version": "1.3.0",
|
"version": "1.8.1",
|
||||||
"identifier": "com.naomi.hikari-desktop",
|
"identifier": "com.naomi.hikari-desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ async function changeDirectory(path: string): Promise<void> {
|
|||||||
// Capture conversation history before disconnecting
|
// Capture conversation history before disconnecting
|
||||||
const conversationHistory = claudeStore.getConversationHistory();
|
const conversationHistory = claudeStore.getConversationHistory();
|
||||||
|
|
||||||
|
// Get currently granted tools and config auto-granted tools
|
||||||
|
const activeConversation = get(conversationsStore.activeConversation);
|
||||||
|
const grantedTools = activeConversation ? Array.from(activeConversation.grantedTools) : [];
|
||||||
|
const config = configStore.getConfig();
|
||||||
|
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
|
||||||
|
|
||||||
await invoke("stop_claude", { conversationId });
|
await invoke("stop_claude", { conversationId });
|
||||||
|
|
||||||
// Wait for clean shutdown
|
// Wait for clean shutdown
|
||||||
@@ -50,12 +56,17 @@ async function changeDirectory(path: string): Promise<void> {
|
|||||||
conversationId,
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
working_dir: validatedPath,
|
working_dir: validatedPath,
|
||||||
|
model: config.model || null,
|
||||||
|
api_key: config.api_key || null,
|
||||||
|
custom_instructions: config.custom_instructions || null,
|
||||||
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
|
allowed_tools: allAllowedTools,
|
||||||
|
use_worktree: config.use_worktree ?? false,
|
||||||
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update Discord RPC when reconnecting after directory change
|
// Update Discord RPC when reconnecting after directory change
|
||||||
const config = configStore.getConfig();
|
|
||||||
const activeConversation = get(conversationsStore.activeConversation);
|
|
||||||
if (activeConversation) {
|
if (activeConversation) {
|
||||||
await updateDiscordRpc(
|
await updateDiscordRpc(
|
||||||
activeConversation.name,
|
activeConversation.name,
|
||||||
@@ -102,6 +113,12 @@ async function startNewConversation(): Promise<void> {
|
|||||||
conversationId,
|
conversationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get granted tools before interrupting
|
||||||
|
const activeConversation = get(conversationsStore.activeConversation);
|
||||||
|
const grantedTools = activeConversation ? Array.from(activeConversation.grantedTools) : [];
|
||||||
|
const config = configStore.getConfig();
|
||||||
|
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
|
||||||
|
|
||||||
claudeStore.addLine("system", "Starting new conversation...");
|
claudeStore.addLine("system", "Starting new conversation...");
|
||||||
characterState.setState("thinking");
|
characterState.setState("thinking");
|
||||||
|
|
||||||
@@ -115,12 +132,17 @@ async function startNewConversation(): Promise<void> {
|
|||||||
conversationId,
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
working_dir: workingDir,
|
working_dir: workingDir,
|
||||||
|
model: config.model || null,
|
||||||
|
api_key: config.api_key || null,
|
||||||
|
custom_instructions: config.custom_instructions || null,
|
||||||
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
|
allowed_tools: allAllowedTools,
|
||||||
|
use_worktree: config.use_worktree ?? false,
|
||||||
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update Discord RPC when starting new conversation
|
// Update Discord RPC when starting new conversation
|
||||||
const config = configStore.getConfig();
|
|
||||||
const activeConversation = get(conversationsStore.activeConversation);
|
|
||||||
if (activeConversation) {
|
if (activeConversation) {
|
||||||
await updateDiscordRpc(
|
await updateDiscordRpc(
|
||||||
activeConversation.name,
|
activeConversation.name,
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { SvelteMap } from "svelte/reactivity";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
|
import { agentStore, getAgentsForConversation } from "$lib/stores/agents";
|
||||||
|
import type { AgentInfo } from "$lib/types/agents";
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isOpen, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let now = $state(Date.now());
|
||||||
|
let timerInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
// We need a reactive subscription to agents for the active conversation
|
||||||
|
let agents: AgentInfo[] = $state([]);
|
||||||
|
let agentsUnsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
|
// Track active conversation reactively
|
||||||
|
let currentConversationId = $state<string | null>("");
|
||||||
|
const conversationIdUnsubscribe = claudeStore.activeConversationId.subscribe((id) => {
|
||||||
|
currentConversationId = id;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Re-subscribe when conversation changes
|
||||||
|
if (agentsUnsubscribe) {
|
||||||
|
agentsUnsubscribe();
|
||||||
|
}
|
||||||
|
if (currentConversationId) {
|
||||||
|
const store = getAgentsForConversation(currentConversationId);
|
||||||
|
agentsUnsubscribe = store.subscribe((value) => {
|
||||||
|
agents = value;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
agents = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const runningAgents = $derived(agents.filter((a) => a.status === "running"));
|
||||||
|
const completedAgents = $derived(agents.filter((a) => a.status === "completed"));
|
||||||
|
const erroredAgents = $derived(agents.filter((a) => a.status === "errored"));
|
||||||
|
|
||||||
|
// Organize agents into a tree structure based on parent_tool_use_id
|
||||||
|
const agentTree = $derived.by(() => {
|
||||||
|
const topLevel = agents.filter((a) => !a.parentToolUseId);
|
||||||
|
const childrenMap = new SvelteMap<string, AgentInfo[]>();
|
||||||
|
|
||||||
|
// Group children by their parent
|
||||||
|
agents.forEach((agent) => {
|
||||||
|
if (agent.parentToolUseId) {
|
||||||
|
const siblings = childrenMap.get(agent.parentToolUseId) || [];
|
||||||
|
siblings.push(agent);
|
||||||
|
childrenMap.set(agent.parentToolUseId, siblings);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { topLevel, childrenMap };
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
timerInterval = setInterval(() => {
|
||||||
|
now = Date.now();
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (timerInterval) clearInterval(timerInterval);
|
||||||
|
if (agentsUnsubscribe) agentsUnsubscribe();
|
||||||
|
conversationIdUnsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDuration(startedAt: number, endedAt?: number): string {
|
||||||
|
const end = endedAt || now;
|
||||||
|
const durationMs = end - startedAt;
|
||||||
|
const seconds = Math.floor(durationMs / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
||||||
|
}
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${minutes}m ${seconds % 60}s`;
|
||||||
|
}
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubagentTypeLabel(type: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
Explore: "Explorer",
|
||||||
|
"general-purpose": "General",
|
||||||
|
Plan: "Planner",
|
||||||
|
Bash: "Shell",
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadgeClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "running":
|
||||||
|
return "bg-blue-500/20 text-blue-400 border-blue-500/30";
|
||||||
|
case "completed":
|
||||||
|
return "bg-green-500/20 text-green-400 border-green-500/30";
|
||||||
|
case "errored":
|
||||||
|
return "bg-red-500/20 text-red-400 border-red-500/30";
|
||||||
|
default:
|
||||||
|
return "bg-gray-500/20 text-gray-400 border-gray-500/30";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleKillAll() {
|
||||||
|
if (!currentConversationId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke("interrupt_claude", { conversationId: currentConversationId });
|
||||||
|
// Mark all running agents as errored after killing the process
|
||||||
|
agentStore.markAllErrored(currentConversationId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to kill Claude process:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClearCompleted() {
|
||||||
|
if (currentConversationId) {
|
||||||
|
agentStore.clearCompleted(currentConversationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten the tree for rendering with depth information
|
||||||
|
const flattenedAgents = $derived.by(() => {
|
||||||
|
const result: { agent: AgentInfo; depth: number }[] = [];
|
||||||
|
const { topLevel, childrenMap } = agentTree;
|
||||||
|
|
||||||
|
function addAgentAndChildren(agent: AgentInfo, depth: number) {
|
||||||
|
result.push({ agent, depth });
|
||||||
|
const children = childrenMap.get(agent.toolUseId);
|
||||||
|
if (children) {
|
||||||
|
children.forEach((child) => addAgentAndChildren(child, depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
topLevel.forEach((agent) => addAgentAndChildren(agent, 0));
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="fixed inset-0 z-40" onclick={onClose}></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed top-12 right-0 bottom-0 w-80 bg-[var(--bg-primary)] border-l border-[var(--border-color)] shadow-xl z-50 flex flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between p-4 border-b border-[var(--border-color)]">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-[var(--accent-primary)]"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-sm font-semibold text-[var(--text-primary)]">Agent Monitor</h3>
|
||||||
|
{#if runningAgents.length > 0}
|
||||||
|
<span
|
||||||
|
class="px-1.5 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400 animate-pulse"
|
||||||
|
>
|
||||||
|
{runningAgents.length} running
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
aria-label="Close agent monitor"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="flex gap-2 px-4 py-2 border-b border-[var(--border-color)]">
|
||||||
|
<button
|
||||||
|
onclick={handleKillAll}
|
||||||
|
disabled={runningAgents.length === 0}
|
||||||
|
class="flex-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
title="Kills the entire Claude Code process to stop all agents"
|
||||||
|
>
|
||||||
|
Kill All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={handleClearCompleted}
|
||||||
|
disabled={completedAgents.length === 0 && erroredAgents.length === 0}
|
||||||
|
class="flex-1 px-2 py-1 text-xs bg-[var(--bg-secondary)] hover:bg-[var(--bg-hover,var(--bg-secondary))] text-[var(--text-secondary)] rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Clear Finished
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Agent list -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-4 space-y-2">
|
||||||
|
{#if agents.length === 0}
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)] text-sm"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-8 h-8 mb-2 opacity-50"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p>No agents detected yet</p>
|
||||||
|
<p class="text-xs mt-1 opacity-70">
|
||||||
|
Agents will appear here when Claude uses the Task tool
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each flattenedAgents as { agent, depth } (agent.toolUseId)}
|
||||||
|
<div
|
||||||
|
class="p-3 rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] {agent.status ===
|
||||||
|
'running'
|
||||||
|
? 'border-l-2 border-l-blue-500'
|
||||||
|
: agent.status === 'errored'
|
||||||
|
? 'border-l-2 border-l-red-500'
|
||||||
|
: 'border-l-2 border-l-green-500'}"
|
||||||
|
style="margin-left: {depth * 12}px; width: calc(100% - {depth * 12}px);"
|
||||||
|
>
|
||||||
|
<!-- Agent header -->
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
{#if depth > 0}
|
||||||
|
<svg
|
||||||
|
class="w-3 h-3 text-[var(--text-secondary)]"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<img
|
||||||
|
src={agent.characterAvatar}
|
||||||
|
alt={agent.characterName}
|
||||||
|
class="w-5 h-5 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<span class="text-[10px] font-medium text-[var(--text-primary)]">
|
||||||
|
{agent.characterName}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
|
||||||
|
agent.status
|
||||||
|
)}"
|
||||||
|
>
|
||||||
|
{getSubagentTypeLabel(agent.subagentType)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="text-[10px] {agent.status === 'running'
|
||||||
|
? 'text-blue-400'
|
||||||
|
: 'text-[var(--text-secondary)]'}"
|
||||||
|
>
|
||||||
|
{#if agent.durationMs !== undefined}
|
||||||
|
{Math.floor(agent.durationMs / 1000)}s
|
||||||
|
{:else}
|
||||||
|
{formatDuration(agent.startedAt, agent.endedAt)}
|
||||||
|
{/if}
|
||||||
|
{#if agent.status === "running"}
|
||||||
|
<span class="inline-block w-1 h-1 bg-blue-400 rounded-full animate-pulse ml-1"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Agent description -->
|
||||||
|
<p class="text-xs text-[var(--text-primary)] truncate" title={agent.description}>
|
||||||
|
{agent.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Status indicator -->
|
||||||
|
<div class="mt-1 flex items-center gap-1">
|
||||||
|
{#if agent.status === "running"}
|
||||||
|
<span class="text-[10px] text-blue-400">Running...</span>
|
||||||
|
{:else if agent.status === "completed"}
|
||||||
|
<span class="text-[10px] text-green-400">Completed</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-[10px] text-red-400">Errored / Killed</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last assistant message snippet -->
|
||||||
|
{#if agent.lastAssistantMessage}
|
||||||
|
<p
|
||||||
|
class="mt-1 text-[10px] text-[var(--text-secondary)] italic truncate"
|
||||||
|
title={agent.lastAssistantMessage}
|
||||||
|
>
|
||||||
|
{agent.lastAssistantMessage}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer summary -->
|
||||||
|
{#if agents.length > 0}
|
||||||
|
<div
|
||||||
|
class="px-4 py-2 border-t border-[var(--border-color)] text-[10px] text-[var(--text-secondary)]"
|
||||||
|
>
|
||||||
|
{agents.length} total ·
|
||||||
|
{runningAgents.length} running ·
|
||||||
|
{completedAgents.length} completed ·
|
||||||
|
{erroredAgents.length} errored
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onClose }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||||
|
onclick={onClose}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="cast-title"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 id="cast-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||||
|
Meet the Team
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Principal cast: Hikari + Naomi -->
|
||||||
|
<div class="grid grid-cols-1 gap-3 mb-6 sm:grid-cols-2">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--accent-primary)]/40"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="https://cdn.nhcarrigan.com/hikari.png"
|
||||||
|
alt="Hikari"
|
||||||
|
class="w-16 h-16 object-cover rounded-full border-2 border-[var(--border-color)] shrink-0"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="font-semibold text-[var(--text-primary)]">Hikari</span>
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
|
||||||
|
>
|
||||||
|
Chief Operating Officer
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)]">
|
||||||
|
Holds the line so the others don't have to. Never without her clipboard — or her
|
||||||
|
glasses.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--accent-primary)]/40"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="https://cdn.nhcarrigan.com/profile.png"
|
||||||
|
alt="Naomi"
|
||||||
|
class="w-16 h-16 object-cover rounded-full border-2 border-[var(--border-color)] shrink-0"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="font-semibold text-[var(--text-primary)]">Naomi</span>
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
|
||||||
|
>
|
||||||
|
Chief hEx-ecutive Officer
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)]">
|
||||||
|
A 525-year-old vampire running a tech company from behind a VTuber avatar. Fixes server
|
||||||
|
crashes at 4 AM.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subagent girls grid -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-3">
|
||||||
|
Subagent Squad
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
{#each CHARACTER_POOL as character (character.name)}
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-2 p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] text-center"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={character.avatar}
|
||||||
|
alt={character.name}
|
||||||
|
class="w-14 h-14 object-cover rounded-full border-2 border-[var(--border-color)]"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium text-[var(--text-primary)]">{character.name}</span>
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
|
||||||
|
>
|
||||||
|
{character.title}
|
||||||
|
</span>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)] leading-snug">{character.description}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
[role="dialog"] {
|
||||||
|
animation: slideIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
const SUPPORTED_CLI_VERSION = "2.1.53";
|
||||||
|
|
||||||
|
let installedVersion = $state("Loading...");
|
||||||
|
|
||||||
|
function compareVersions(a: string, b: string): number {
|
||||||
|
const aParts = a.split(".").map(Number);
|
||||||
|
const bParts = b.split(".").map(Number);
|
||||||
|
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
||||||
|
const aVal = aParts[i] ?? 0;
|
||||||
|
const bVal = bParts[i] ?? 0;
|
||||||
|
if (aVal > bVal) return 1;
|
||||||
|
if (aVal < bVal) return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let displayVersion = $derived(installedVersion.split(" (")[0]);
|
||||||
|
|
||||||
|
let supportedBadgeState = $derived.by(() => {
|
||||||
|
if (installedVersion === "Loading..." || installedVersion === "Unknown") {
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion);
|
||||||
|
if (!semverMatch) return "neutral";
|
||||||
|
const cmp = compareVersions(semverMatch[1], SUPPORTED_CLI_VERSION);
|
||||||
|
if (cmp > 0) return "ahead";
|
||||||
|
if (cmp < 0) return "behind";
|
||||||
|
return "current";
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchVersion() {
|
||||||
|
try {
|
||||||
|
const result = await invoke<string>("get_claude_version");
|
||||||
|
installedVersion = result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get Claude CLI version:", error);
|
||||||
|
installedVersion = "Unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fetchVersion();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="cli-versions">
|
||||||
|
<div class="cli-version">
|
||||||
|
<svg
|
||||||
|
class="terminal-icon"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="4 17 10 11 4 5" />
|
||||||
|
<line x1="12" y1="19" x2="20" y2="19" />
|
||||||
|
</svg>
|
||||||
|
<span class="version-text">CLI {displayVersion}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cli-version supported {supportedBadgeState}" title="Highest audited CLI version">
|
||||||
|
<svg
|
||||||
|
class="terminal-icon"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||||
|
</svg>
|
||||||
|
<span class="version-text">Supported {SUPPORTED_CLI_VERSION}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if supportedBadgeState === "ahead"}
|
||||||
|
<span class="version-warning ahead"
|
||||||
|
>Your version is newer, some features may not be supported</span
|
||||||
|
>
|
||||||
|
{:else if supportedBadgeState === "behind"}
|
||||||
|
<span class="version-warning behind"
|
||||||
|
>Your version is out of date, please update to ensure compatibility</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cli-versions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cli-version {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cli-version:hover {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cli-version.supported.current {
|
||||||
|
border-color: var(--success-color, #4caf50);
|
||||||
|
color: var(--success-color, #4caf50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cli-version.supported.ahead {
|
||||||
|
border-color: var(--warning-color, #ff9800);
|
||||||
|
color: var(--warning-color, #ff9800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cli-version.supported.behind {
|
||||||
|
border-color: var(--error-color, #f44336);
|
||||||
|
color: var(--error-color, #f44336);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-text {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-warning {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-warning.ahead {
|
||||||
|
color: var(--warning-color, #ff9800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-warning.behind {
|
||||||
|
color: var(--error-color, #f44336);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
hasActiveConversation: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onMinimize: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isOpen, hasActiveConversation, onClose, onMinimize, onCancel }: Props = $props();
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||||
|
onclick={onCancel}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onkeydown={(e) => e.key === " " && onCancel()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-md w-full"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="confirm-title"
|
||||||
|
aria-describedby="confirm-message"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center flex-shrink-0"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-yellow-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 id="confirm-title" class="text-lg font-semibold text-[var(--text-primary)] mb-1">
|
||||||
|
Close Hikari Desktop?
|
||||||
|
</h3>
|
||||||
|
<p id="confirm-message" class="text-sm text-[var(--text-secondary)]">
|
||||||
|
{#if hasActiveConversation}
|
||||||
|
You have an active conversation with Claude. Are you sure you want to close the
|
||||||
|
application? Your conversation history will be saved, but any in-progress tasks will
|
||||||
|
be interrupted.
|
||||||
|
{:else}
|
||||||
|
Are you sure you want to close the application?
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 mt-6 justify-end">
|
||||||
|
<button
|
||||||
|
onclick={onCancel}
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-300 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={onMinimize}
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Minimize to Tray
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Close Application
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
[role="dialog"] {
|
||||||
|
animation: slideIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95) translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||||
import { characterState, characterInfo } from "$lib/stores/character";
|
import { characterState, characterInfo } from "$lib/stores/character";
|
||||||
import { isStreamerMode } from "$lib/stores/config";
|
import { isStreamerMode, configStore } from "$lib/stores/config";
|
||||||
import { handleNewUserMessage } from "$lib/notifications/rules";
|
import { handleNewUserMessage } from "$lib/notifications/rules";
|
||||||
|
import { setSkipNextGreeting } from "$lib/tauri";
|
||||||
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
|
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -13,6 +14,9 @@
|
|||||||
|
|
||||||
let { onExpand }: Props = $props();
|
let { onExpand }: Props = $props();
|
||||||
|
|
||||||
|
const configValues = configStore.config;
|
||||||
|
const hasBackgroundImage = $derived($configValues.background_image_path !== null);
|
||||||
|
|
||||||
let inputValue = $state("");
|
let inputValue = $state("");
|
||||||
let isSubmitting = $state(false);
|
let isSubmitting = $state(false);
|
||||||
let isConnected = $state(false);
|
let isConnected = $state(false);
|
||||||
@@ -127,8 +131,11 @@
|
|||||||
const conversationId = get(claudeStore.activeConversationId);
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
if (!conversationId) return;
|
if (!conversationId) return;
|
||||||
|
|
||||||
|
// Set flag to preserve stats/permissions (don't treat next connect as new session)
|
||||||
|
setSkipNextGreeting(true);
|
||||||
|
|
||||||
await invoke("interrupt_claude", { conversationId });
|
await invoke("interrupt_claude", { conversationId });
|
||||||
claudeStore.addLine("system", "Interrupted");
|
claudeStore.addLine("system", "Process interrupted via stop button");
|
||||||
characterState.setState("idle");
|
characterState.setState("idle");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to interrupt:", error);
|
console.error("Failed to interrupt:", error);
|
||||||
@@ -146,7 +153,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="compact-container {getStateGlow()}">
|
<div
|
||||||
|
class="compact-container {getStateGlow()}"
|
||||||
|
style={hasBackgroundImage ? "background: transparent !important;" : ""}
|
||||||
|
>
|
||||||
<!-- Character sprite (smaller) -->
|
<!-- Character sprite (smaller) -->
|
||||||
<div class="compact-character">
|
<div class="compact-character">
|
||||||
<div class="sprite-wrapper {getAnimationClass()}">
|
<div class="sprite-wrapper {getAnimationClass()}">
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
} from "$lib/stores/config";
|
} from "$lib/stores/config";
|
||||||
import { claudeStore } from "$lib/stores/claude";
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import CostSummary from "./CostSummary.svelte";
|
import CostSummary from "./CostSummary.svelte";
|
||||||
|
|
||||||
let config: HikariConfig = $state({
|
let config: HikariConfig = $state({
|
||||||
@@ -26,7 +28,6 @@
|
|||||||
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,
|
||||||
@@ -52,10 +53,31 @@
|
|||||||
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,
|
||||||
|
use_worktree: false,
|
||||||
|
disable_1m_context: false,
|
||||||
|
trusted_workspaces: [],
|
||||||
|
background_image_path: null,
|
||||||
|
background_image_opacity: 0.3,
|
||||||
});
|
});
|
||||||
|
|
||||||
let showCustomThemeEditor = $state(false);
|
let showCustomThemeEditor = $state(false);
|
||||||
|
|
||||||
|
interface AuthStatus {
|
||||||
|
is_logged_in: boolean;
|
||||||
|
email: string | null;
|
||||||
|
org_id: string | null;
|
||||||
|
org_name: string | null;
|
||||||
|
api_key_source: string | null;
|
||||||
|
api_provider: string | null;
|
||||||
|
subscription_type: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let authStatus: AuthStatus | null = $state(null);
|
||||||
|
let authLoading = $state(false);
|
||||||
|
let authActionLoading = $state(false);
|
||||||
|
let authError: string | null = $state(null);
|
||||||
|
|
||||||
let isOpen = $state(false);
|
let isOpen = $state(false);
|
||||||
let isSaving = $state(false);
|
let isSaving = $state(false);
|
||||||
let saveError: string | null = $state(null);
|
let saveError: string | null = $state(null);
|
||||||
@@ -69,6 +91,9 @@
|
|||||||
|
|
||||||
configStore.isSidebarOpen.subscribe((open) => {
|
configStore.isSidebarOpen.subscribe((open) => {
|
||||||
isOpen = open;
|
isOpen = open;
|
||||||
|
if (open && authStatus === null) {
|
||||||
|
void refreshAuthStatus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
configStore.saveError.subscribe((error) => {
|
configStore.saveError.subscribe((error) => {
|
||||||
@@ -81,16 +106,21 @@
|
|||||||
|
|
||||||
const availableModels = [
|
const availableModels = [
|
||||||
{ value: "", label: "Default (from ~/.claude)" },
|
{ value: "", label: "Default (from ~/.claude)" },
|
||||||
// Current generation (Claude 4.5)
|
// Current generation (Claude 4.6)
|
||||||
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5 (Recommended)" },
|
{ value: "claude-opus-4-6", label: "Claude Opus 4.6 (Most Capable)" },
|
||||||
|
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 (Recommended)" },
|
||||||
|
// Previous generation (Claude 4.5)
|
||||||
|
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
|
||||||
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" },
|
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" },
|
||||||
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5 (Most Capable)" },
|
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5" },
|
||||||
// Previous generation (Claude 4)
|
// Previous generation (Claude 4.x)
|
||||||
{ 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)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -106,6 +136,44 @@
|
|||||||
"Task",
|
"Task",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
async function refreshAuthStatus() {
|
||||||
|
authLoading = true;
|
||||||
|
authError = null;
|
||||||
|
try {
|
||||||
|
authStatus = await invoke<AuthStatus>("get_auth_status");
|
||||||
|
} catch (e) {
|
||||||
|
authError = String(e);
|
||||||
|
} finally {
|
||||||
|
authLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAuthLogin() {
|
||||||
|
authActionLoading = true;
|
||||||
|
authError = null;
|
||||||
|
try {
|
||||||
|
await invoke<string>("auth_login");
|
||||||
|
await refreshAuthStatus();
|
||||||
|
} catch (e) {
|
||||||
|
authError = String(e);
|
||||||
|
} finally {
|
||||||
|
authActionLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAuthLogout() {
|
||||||
|
authActionLoading = true;
|
||||||
|
authError = null;
|
||||||
|
try {
|
||||||
|
await invoke<string>("auth_logout");
|
||||||
|
await refreshAuthStatus();
|
||||||
|
} catch (e) {
|
||||||
|
authError = String(e);
|
||||||
|
} finally {
|
||||||
|
authActionLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
saveError = null;
|
saveError = null;
|
||||||
@@ -177,6 +245,20 @@
|
|||||||
await window.setAlwaysOnTop(enabled);
|
await window.setAlwaysOnTop(enabled);
|
||||||
await configStore.updateConfig({ always_on_top: enabled });
|
await configStore.updateConfig({ always_on_top: enabled });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pickBackgroundImage() {
|
||||||
|
const selected = await open({
|
||||||
|
multiple: false,
|
||||||
|
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "gif", "avif"] }],
|
||||||
|
});
|
||||||
|
if (selected) {
|
||||||
|
config.background_image_path = selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBackgroundImage() {
|
||||||
|
config.background_image_path = null;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Backdrop -->
|
<!-- Backdrop -->
|
||||||
@@ -223,6 +305,109 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Account Section -->
|
||||||
|
<section class="mb-6">
|
||||||
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
|
Account
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{#if authLoading}
|
||||||
|
<div class="text-sm text-[var(--text-secondary)] py-2">Checking auth status...</div>
|
||||||
|
{:else if authStatus}
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span
|
||||||
|
class="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 {authStatus.is_logged_in
|
||||||
|
? 'bg-green-500'
|
||||||
|
: 'bg-red-500'}"
|
||||||
|
></span>
|
||||||
|
<span class="text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
{authStatus.is_logged_in ? "Logged in" : "Not logged in"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if authStatus.email || authStatus.org_name || authStatus.api_key_source || config.api_key}
|
||||||
|
<dl class="text-xs space-y-1 mb-3">
|
||||||
|
{#if authStatus.email}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Email</dt>
|
||||||
|
<dd class="text-[var(--text-primary)] break-all">{authStatus.email}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if authStatus.org_name}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Org</dt>
|
||||||
|
<dd class="text-[var(--text-primary)]">{authStatus.org_name}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if authStatus.org_id}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Org UUID</dt>
|
||||||
|
<dd class="text-[var(--text-secondary)] font-mono text-[10px] break-all">
|
||||||
|
{authStatus.org_id}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if authStatus.api_key_source}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">API key</dt>
|
||||||
|
<dd class="text-[var(--text-primary)]">{authStatus.api_key_source}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if authStatus.subscription_type}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Plan</dt>
|
||||||
|
<dd class="text-[var(--text-primary)]">{authStatus.subscription_type}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Override</dt>
|
||||||
|
<dd class="text-[var(--text-primary)]">
|
||||||
|
{#if config.api_key}
|
||||||
|
{config.streamer_mode ? "Custom key set 🔒" : "Custom key set"}
|
||||||
|
{:else}
|
||||||
|
None
|
||||||
|
{/if}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="text-sm text-[var(--text-secondary)] py-2">Auth status unavailable</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if authError}
|
||||||
|
<div class="mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-red-400 text-xs">
|
||||||
|
{authError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={refreshAuthStatus}
|
||||||
|
disabled={authLoading || authActionLoading}
|
||||||
|
class="px-3 py-1.5 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-secondary)] hover:border-[var(--accent-primary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
{#if authStatus && !authStatus.is_logged_in}
|
||||||
|
<button
|
||||||
|
onclick={handleAuthLogin}
|
||||||
|
disabled={authActionLoading}
|
||||||
|
class="btn-trans-gradient px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{authActionLoading ? "Logging in..." : "Login"}
|
||||||
|
</button>
|
||||||
|
{:else if authStatus && authStatus.is_logged_in}
|
||||||
|
<button
|
||||||
|
onclick={handleAuthLogout}
|
||||||
|
disabled={authActionLoading}
|
||||||
|
class="px-3 py-1.5 text-sm bg-red-500/20 border border-red-500/50 rounded-lg text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{authActionLoading ? "Logging out..." : "Logout"}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Agent Settings Section -->
|
<!-- Agent Settings Section -->
|
||||||
<section class="mb-6">
|
<section class="mb-6">
|
||||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
@@ -317,6 +502,37 @@
|
|||||||
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none"
|
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Worktree Isolation -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.use_worktree}
|
||||||
|
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Worktree isolation</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
Launch sessions with <code class="font-mono">--worktree</code> for isolated git worktree environments
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Disable 1M Context Window -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.disable_1m_context}
|
||||||
|
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Disable 1M context window</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
Sets <code class="font-mono">CLAUDE_CODE_DISABLE_1M_CONTEXT=1</code> to opt out of the extended
|
||||||
|
context window
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Greeting Section -->
|
<!-- Greeting Section -->
|
||||||
@@ -700,6 +916,68 @@
|
|||||||
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>
|
||||||
|
|
||||||
|
<!-- Background Image -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="block text-sm text-[var(--text-secondary)] mb-2">Background Image</span>
|
||||||
|
{#if config.background_image_path}
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] font-mono mb-2 truncate">
|
||||||
|
{config.background_image_path.split("/").pop()}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={pickBackgroundImage}
|
||||||
|
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
{config.background_image_path ? "Change Image" : "Choose Image"}
|
||||||
|
</button>
|
||||||
|
{#if config.background_image_path}
|
||||||
|
<button
|
||||||
|
onclick={clearBackgroundImage}
|
||||||
|
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
|
||||||
|
title="Remove background image"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if config.background_image_path}
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<label for="bg-opacity" class="text-xs text-[var(--text-secondary)]"> Opacity </label>
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)]">
|
||||||
|
{Math.round(config.background_image_opacity * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="bg-opacity"
|
||||||
|
type="range"
|
||||||
|
bind:value={config.background_image_opacity}
|
||||||
|
min="0.05"
|
||||||
|
max="1"
|
||||||
|
step="0.05"
|
||||||
|
class="w-full h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Window Section -->
|
<!-- Window Section -->
|
||||||
@@ -724,21 +1002,6 @@
|
|||||||
</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">
|
||||||
|
|||||||
@@ -12,6 +12,25 @@
|
|||||||
let editingTabId = $state<string | null>(null);
|
let editingTabId = $state<string | null>(null);
|
||||||
let editingName = $state("");
|
let editingName = $state("");
|
||||||
|
|
||||||
|
// Tab order for pointer-drag reordering
|
||||||
|
let tabOrder = $state<string[]>([]);
|
||||||
|
let draggedId = $state<string | null>(null);
|
||||||
|
let dragOverId = $state<string | null>(null);
|
||||||
|
let dragStartX = 0;
|
||||||
|
let isDragging = false;
|
||||||
|
let wasDragged = false;
|
||||||
|
let tabsRef = $state<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
// Keep tabOrder in sync with conversations map (add new, remove deleted)
|
||||||
|
$effect(() => {
|
||||||
|
const currentIds = Array.from($conversations.keys());
|
||||||
|
const validIds = tabOrder.filter((id) => currentIds.includes(id));
|
||||||
|
const newIds = currentIds.filter((id) => !tabOrder.includes(id));
|
||||||
|
if (validIds.length !== tabOrder.length || newIds.length > 0) {
|
||||||
|
tabOrder = [...validIds, ...newIds];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Track last seen message count for each conversation
|
// Track last seen message count for each conversation
|
||||||
let lastSeenMessageCount = new SvelteMap<string, number>();
|
let lastSeenMessageCount = new SvelteMap<string, number>();
|
||||||
|
|
||||||
@@ -138,8 +157,73 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleTabClick(id: string) {
|
||||||
|
if (wasDragged) {
|
||||||
|
wasDragged = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await switchTab(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event: PointerEvent, id: string) {
|
||||||
|
if (editingTabId === id) return;
|
||||||
|
draggedId = id;
|
||||||
|
dragStartX = event.clientX;
|
||||||
|
isDragging = false;
|
||||||
|
wasDragged = false;
|
||||||
|
|
||||||
|
function onMove(e: PointerEvent) {
|
||||||
|
if (!isDragging && Math.abs(e.clientX - dragStartX) > 5) {
|
||||||
|
isDragging = true;
|
||||||
|
}
|
||||||
|
if (!isDragging || !tabsRef) return;
|
||||||
|
const tabs = tabsRef.querySelectorAll<HTMLElement>("[data-tab-id]");
|
||||||
|
dragOverId = null;
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const rect = tab.getBoundingClientRect();
|
||||||
|
if (e.clientX >= rect.left && e.clientX <= rect.right) {
|
||||||
|
const tabId = tab.dataset.tabId;
|
||||||
|
if (tabId && tabId !== id) {
|
||||||
|
dragOverId = tabId;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp() {
|
||||||
|
if (isDragging && draggedId && dragOverId && draggedId !== dragOverId) {
|
||||||
|
const order = [...tabOrder];
|
||||||
|
const fromIndex = order.indexOf(draggedId);
|
||||||
|
const toIndex = order.indexOf(dragOverId);
|
||||||
|
order.splice(fromIndex, 1);
|
||||||
|
order.splice(toIndex, 0, draggedId);
|
||||||
|
tabOrder = order;
|
||||||
|
wasDragged = true;
|
||||||
|
}
|
||||||
|
draggedId = null;
|
||||||
|
dragOverId = null;
|
||||||
|
isDragging = false;
|
||||||
|
window.removeEventListener("pointermove", onMove);
|
||||||
|
window.removeEventListener("pointerup", onUp);
|
||||||
|
window.removeEventListener("pointercancel", onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", onMove);
|
||||||
|
window.addEventListener("pointerup", onUp);
|
||||||
|
window.addEventListener("pointercancel", onUp);
|
||||||
|
}
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
// Initialise all conversations as seen on mount so that remounting
|
||||||
|
// this component (e.g. after closing the file editor) doesn't falsely
|
||||||
|
// mark existing messages as unread.
|
||||||
|
for (const [id, conversation] of $conversations) {
|
||||||
|
lastSeenMessageCount.set(id, conversation.terminalLines.length);
|
||||||
|
}
|
||||||
|
lastSeenMessageCount = lastSeenMessageCount;
|
||||||
|
|
||||||
function handleGlobalKeydown(event: KeyboardEvent) {
|
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||||
// Ctrl/Cmd + T: New tab
|
// Ctrl/Cmd + T: New tab
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === "t") {
|
if ((event.ctrlKey || event.metaKey) && event.key === "t") {
|
||||||
@@ -165,21 +249,19 @@
|
|||||||
// Ctrl/Cmd + Tab: Next tab
|
// Ctrl/Cmd + Tab: Next tab
|
||||||
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
|
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const tabs = Array.from($conversations.keys());
|
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
|
||||||
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
|
|
||||||
if (currentIndex !== -1) {
|
if (currentIndex !== -1) {
|
||||||
const nextIndex = (currentIndex + 1) % tabs.length;
|
const nextIndex = (currentIndex + 1) % tabOrder.length;
|
||||||
claudeStore.switchConversation(tabs[nextIndex]);
|
claudeStore.switchConversation(tabOrder[nextIndex]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Ctrl/Cmd + Shift + Tab: Previous tab
|
// Ctrl/Cmd + Shift + Tab: Previous tab
|
||||||
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
|
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const tabs = Array.from($conversations.keys());
|
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
|
||||||
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
|
|
||||||
if (currentIndex !== -1) {
|
if (currentIndex !== -1) {
|
||||||
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
const prevIndex = (currentIndex - 1 + tabOrder.length) % tabOrder.length;
|
||||||
claudeStore.switchConversation(tabs[prevIndex]);
|
claudeStore.switchConversation(tabOrder[prevIndex]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,15 +272,22 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
bind:this={tabsRef}
|
||||||
class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
|
class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
|
||||||
>
|
>
|
||||||
{#each Array.from($conversations.entries()) as [id, conversation] (id)}
|
{#each tabOrder
|
||||||
|
.filter((id) => $conversations.has(id))
|
||||||
|
.map((id) => ({ id, conversation: $conversations.get(id)! })) as { id, conversation } (id)}
|
||||||
<div
|
<div
|
||||||
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all
|
data-tab-id={id}
|
||||||
|
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t transition-all
|
||||||
{id === $activeConversationId
|
{id === $activeConversationId
|
||||||
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
|
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
|
||||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
|
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}
|
||||||
onclick={() => switchTab(id)}
|
{dragOverId === id && draggedId !== id ? 'drag-over' : ''}
|
||||||
|
{draggedId === id ? 'dragging' : ''}"
|
||||||
|
onpointerdown={(e) => handlePointerDown(e, id)}
|
||||||
|
onclick={() => handleTabClick(id)}
|
||||||
onkeydown={(e) => handleTabKeydown(id, e)}
|
onkeydown={(e) => handleTabKeydown(id, e)}
|
||||||
role="tab"
|
role="tab"
|
||||||
tabindex={0}
|
tabindex={0}
|
||||||
@@ -211,7 +300,7 @@
|
|||||||
onblur={saveTabName}
|
onblur={saveTabName}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32"
|
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32 select-text"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -296,5 +385,20 @@
|
|||||||
|
|
||||||
.tab-item {
|
.tab-item {
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-over {
|
||||||
|
border-left: 2px solid var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragging {
|
||||||
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
<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>
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
} 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 {
|
||||||
@@ -162,6 +164,17 @@
|
|||||||
attachments = storedAttachments;
|
attachments = storedAttachments;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Per-tab draft persistence — restore the draft text whenever the active
|
||||||
|
// conversation changes, and save it back on every keystroke.
|
||||||
|
claudeStore.activeConversationId.subscribe((conversationId) => {
|
||||||
|
if (conversationId) {
|
||||||
|
const conv = get(claudeStore.conversations).get(conversationId);
|
||||||
|
inputValue = conv?.draftText ?? "";
|
||||||
|
} else {
|
||||||
|
inputValue = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function handleInputChange() {
|
function handleInputChange() {
|
||||||
// If input is empty, allow history navigation again
|
// If input is empty, allow history navigation again
|
||||||
// Otherwise, mark that user has manually typed
|
// Otherwise, mark that user has manually typed
|
||||||
@@ -174,6 +187,12 @@
|
|||||||
historyIndex = -1;
|
historyIndex = -1;
|
||||||
tempInput = "";
|
tempInput = "";
|
||||||
|
|
||||||
|
// Save the current draft so it persists if the user switches tabs.
|
||||||
|
const activeId = get(claudeStore.activeConversationId);
|
||||||
|
if (activeId) {
|
||||||
|
claudeStore.setDraftText(activeId, inputValue);
|
||||||
|
}
|
||||||
|
|
||||||
if (isSlashCommand(inputValue)) {
|
if (isSlashCommand(inputValue)) {
|
||||||
matchingCommands = getMatchingCommands(inputValue);
|
matchingCommands = getMatchingCommands(inputValue);
|
||||||
showCommandMenu = matchingCommands.length > 0;
|
showCommandMenu = matchingCommands.length > 0;
|
||||||
@@ -324,7 +343,7 @@ User: ${formattedMessage}`;
|
|||||||
throw new Error("No active conversation");
|
throw new Error("No active conversation");
|
||||||
}
|
}
|
||||||
await invoke("interrupt_claude", { conversationId });
|
await invoke("interrupt_claude", { conversationId });
|
||||||
claudeStore.addLine("system", "Process interrupted - reconnecting...");
|
claudeStore.addLine("system", "Process interrupted via stop button — reconnecting...");
|
||||||
characterState.setState("idle");
|
characterState.setState("idle");
|
||||||
|
|
||||||
// Show connecting status while we reconnect
|
// Show connecting status while we reconnect
|
||||||
@@ -338,23 +357,34 @@ User: ${formattedMessage}`;
|
|||||||
throw new Error("No active conversation");
|
throw new Error("No active conversation");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current working directory before reconnecting
|
// Get current working directory and granted tools before reconnecting
|
||||||
const workingDir = await invoke<string>("get_working_directory", { conversationId });
|
const workingDir = await invoke<string>("get_working_directory", { conversationId });
|
||||||
|
const activeConversation = get(conversationsStore.activeConversation);
|
||||||
|
const grantedTools = activeConversation
|
||||||
|
? Array.from(activeConversation.grantedTools)
|
||||||
|
: [];
|
||||||
|
const config = configStore.getConfig();
|
||||||
|
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
|
||||||
|
|
||||||
// Set the flag to skip greeting on next connection
|
// Set the flag to skip greeting on next connection
|
||||||
setSkipNextGreeting(true);
|
setSkipNextGreeting(true);
|
||||||
|
|
||||||
// Reconnect to Claude
|
// Reconnect to Claude with preserved permissions
|
||||||
await invoke("start_claude", {
|
await invoke("start_claude", {
|
||||||
conversationId,
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
working_dir: workingDir,
|
working_dir: workingDir,
|
||||||
|
model: config.model || null,
|
||||||
|
api_key: config.api_key || null,
|
||||||
|
custom_instructions: config.custom_instructions || null,
|
||||||
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
|
allowed_tools: allAllowedTools,
|
||||||
|
use_worktree: config.use_worktree ?? false,
|
||||||
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update Discord RPC when reconnecting
|
// Update Discord RPC when reconnecting
|
||||||
const config = configStore.getConfig();
|
|
||||||
const activeConversation = get(conversationsStore.activeConversation);
|
|
||||||
if (activeConversation) {
|
if (activeConversation) {
|
||||||
await updateDiscordRpc(
|
await updateDiscordRpc(
|
||||||
activeConversation.name,
|
activeConversation.name,
|
||||||
@@ -905,6 +935,9 @@ User: ${formattedMessage}`;
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Clipboard</span>
|
<span>Clipboard</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<CliVersion />
|
||||||
|
<SystemClock />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
@@ -1057,6 +1090,7 @@ User: ${formattedMessage}`;
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-button {
|
.control-button {
|
||||||
@@ -1073,6 +1107,18 @@ 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 {
|
||||||
|
|||||||
@@ -35,7 +35,12 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderer.codespan = ({ text }) => {
|
renderer.codespan = ({ text }) => {
|
||||||
return `<code class="hljs-inline">${text}</code>`;
|
const escaped = text.replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
return `<code class="hljs-inline">${escaped}</code>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderer.html = ({ text }) => {
|
||||||
|
return text.replace(/</g, "<").replace(/>/g, ">");
|
||||||
};
|
};
|
||||||
|
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
@@ -276,10 +281,16 @@
|
|||||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content :global(ul),
|
.markdown-content :global(ul) {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-content :global(ol) {
|
.markdown-content :global(ol) {
|
||||||
margin: 0.5em 0;
|
margin: 0.5em 0;
|
||||||
padding-left: 1.5em;
|
padding-left: 1.5em;
|
||||||
|
list-style-type: decimal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content :global(li) {
|
.markdown-content :global(li) {
|
||||||
|
|||||||
@@ -0,0 +1,416 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { Trash2, RefreshCw, Server, Globe, Terminal } from "lucide-svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McpServerInfo {
|
||||||
|
name: string;
|
||||||
|
command: string | null;
|
||||||
|
url: string | null;
|
||||||
|
transport: string; // "stdio", "http", or "sse"
|
||||||
|
env: Record<string, string> | null;
|
||||||
|
status: string | null; // "Connected" or "Failed to connect"
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onClose }: Props = $props();
|
||||||
|
|
||||||
|
let servers = $state<McpServerInfo[]>([]);
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let selectedServer = $state<McpServerInfo | null>(null);
|
||||||
|
let isLoadingDetails = $state(false);
|
||||||
|
let actionInProgress = $state<string | null>(null);
|
||||||
|
let showAddForm = $state(false);
|
||||||
|
let serverDetails = $state<string>("");
|
||||||
|
|
||||||
|
// Add server form fields
|
||||||
|
let newServerName = $state("");
|
||||||
|
let newServerUrl = $state("");
|
||||||
|
let newServerTransport = $state("stdio");
|
||||||
|
let isAdding = $state(false);
|
||||||
|
|
||||||
|
async function loadServers(): Promise<void> {
|
||||||
|
try {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
servers = await invoke<McpServerInfo[]>("list_mcp_servers");
|
||||||
|
} catch (e) {
|
||||||
|
error = `Failed to load MCP servers: ${e}`;
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadServerDetails(name: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
isLoadingDetails = true;
|
||||||
|
error = null;
|
||||||
|
selectedServer = await invoke<McpServerInfo>("get_mcp_server", { name });
|
||||||
|
serverDetails = await invoke<string>("get_mcp_server_details", { name });
|
||||||
|
} catch (e) {
|
||||||
|
error = `Failed to load server details: ${e}`;
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
isLoadingDetails = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeServer(name: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
actionInProgress = name;
|
||||||
|
error = null;
|
||||||
|
await invoke("remove_mcp_server", { name });
|
||||||
|
if (selectedServer?.name === name) {
|
||||||
|
selectedServer = null;
|
||||||
|
serverDetails = "";
|
||||||
|
}
|
||||||
|
await loadServers();
|
||||||
|
} catch (e) {
|
||||||
|
error = `Failed to remove server: ${e}`;
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
actionInProgress = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addServer(): Promise<void> {
|
||||||
|
if (!newServerName.trim() || !newServerUrl.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isAdding = true;
|
||||||
|
error = null;
|
||||||
|
await invoke("add_mcp_server", {
|
||||||
|
name: newServerName.trim(),
|
||||||
|
commandOrUrl: newServerUrl.trim(),
|
||||||
|
transport: newServerTransport,
|
||||||
|
envVars: null,
|
||||||
|
headers: null,
|
||||||
|
});
|
||||||
|
newServerName = "";
|
||||||
|
newServerUrl = "";
|
||||||
|
newServerTransport = "stdio";
|
||||||
|
showAddForm = false;
|
||||||
|
await loadServers();
|
||||||
|
} catch (e) {
|
||||||
|
error = `Failed to add server: ${e}`;
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
isAdding = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTransportIcon(transport: string) {
|
||||||
|
switch (transport) {
|
||||||
|
case "http":
|
||||||
|
return Globe;
|
||||||
|
case "stdio":
|
||||||
|
return Terminal;
|
||||||
|
case "sse":
|
||||||
|
return Server;
|
||||||
|
default:
|
||||||
|
return Server;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTransportColor(transport: string) {
|
||||||
|
switch (transport) {
|
||||||
|
case "http":
|
||||||
|
return "text-blue-400";
|
||||||
|
case "stdio":
|
||||||
|
return "text-green-400";
|
||||||
|
case "sse":
|
||||||
|
return "text-purple-400";
|
||||||
|
default:
|
||||||
|
return "text-[var(--text-secondary)]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadServers();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed top-0 right-0 h-full w-[700px] bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="text-[var(--accent-primary)]">
|
||||||
|
<Server class="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-[var(--text-primary)]">MCP Server Management</h2>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)]">
|
||||||
|
{servers.length} server{servers.length !== 1 ? "s" : ""} configured
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
|
||||||
|
aria-label="Close MCP panel"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Server Button -->
|
||||||
|
<div class="p-4 border-b border-[var(--border-color)]">
|
||||||
|
<button
|
||||||
|
onclick={() => (showAddForm = !showAddForm)}
|
||||||
|
class="w-full px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 transition-opacity flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Server class="w-4 h-4" />
|
||||||
|
{showAddForm ? "Cancel" : "Add New Server"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Server Form -->
|
||||||
|
{#if showAddForm}
|
||||||
|
<div
|
||||||
|
class="mx-4 mt-4 p-4 bg-[var(--bg-secondary)]/50 border border-[var(--border-color)] rounded-lg"
|
||||||
|
>
|
||||||
|
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-3">Add MCP Server</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="mcp-new-name"
|
||||||
|
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
||||||
|
>Server Name</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="mcp-new-name"
|
||||||
|
type="text"
|
||||||
|
bind:value={newServerName}
|
||||||
|
placeholder="my-server"
|
||||||
|
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="mcp-new-transport"
|
||||||
|
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
||||||
|
>Transport</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="mcp-new-transport"
|
||||||
|
bind:value={newServerTransport}
|
||||||
|
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
>
|
||||||
|
<option value="stdio">STDIO</option>
|
||||||
|
<option value="http">HTTP</option>
|
||||||
|
<option value="sse">SSE</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="mcp-new-url"
|
||||||
|
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
||||||
|
>
|
||||||
|
{newServerTransport === "stdio" ? "Command" : "URL"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="mcp-new-url"
|
||||||
|
type="text"
|
||||||
|
bind:value={newServerUrl}
|
||||||
|
placeholder={newServerTransport === "stdio"
|
||||||
|
? "npx my-mcp-server"
|
||||||
|
: "https://mcp.example.com"}
|
||||||
|
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={addServer}
|
||||||
|
disabled={isAdding || !newServerName.trim() || !newServerUrl.trim()}
|
||||||
|
class="w-full px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{#if isAdding}
|
||||||
|
<RefreshCw class="w-4 h-4 animate-spin" />
|
||||||
|
{:else}
|
||||||
|
<Server class="w-4 h-4" />
|
||||||
|
{/if}
|
||||||
|
Add Server
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Error Display -->
|
||||||
|
{#if error}
|
||||||
|
<div class="mx-4 mt-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
|
||||||
|
<p class="text-sm text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-4 flex gap-4">
|
||||||
|
<!-- Server List -->
|
||||||
|
<div class="flex-1">
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex items-center justify-center h-full text-[var(--text-secondary)]">
|
||||||
|
<RefreshCw class="w-8 h-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
{:else if servers.length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
|
||||||
|
<Server class="w-16 h-16 mb-4 opacity-50" />
|
||||||
|
<p class="text-center">No MCP servers configured</p>
|
||||||
|
<p class="text-sm text-center mt-2">Add servers via Settings</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each servers as server (server.name)}
|
||||||
|
{@const TransportIcon = getTransportIcon(server.transport)}
|
||||||
|
<button
|
||||||
|
onclick={() => loadServerDetails(server.name)}
|
||||||
|
class="w-full bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all text-left"
|
||||||
|
class:border-[var(--accent-primary)]={selectedServer?.name === server.name}
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
|
||||||
|
<TransportIcon class="w-4 h-4 {getTransportColor(server.transport)}" />
|
||||||
|
{server.name}
|
||||||
|
{#if server.status}
|
||||||
|
{#if server.status.includes("Connected")}
|
||||||
|
<span
|
||||||
|
class="px-2 py-0.5 bg-[var(--success-color)]/20 text-[var(--success-color)] text-xs rounded border border-[var(--success-color)]/30"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="px-2 py-0.5 bg-red-500/20 text-red-400 text-xs rounded border border-red-500/30"
|
||||||
|
>
|
||||||
|
✗
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)] mt-1">
|
||||||
|
{server.transport.toUpperCase()}
|
||||||
|
{#if server.url}
|
||||||
|
• {server.url}
|
||||||
|
{:else if server.command}
|
||||||
|
• {server.command}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Server Details Panel -->
|
||||||
|
{#if selectedServer}
|
||||||
|
<div
|
||||||
|
class="w-80 bg-[var(--bg-secondary)]/50 rounded-lg p-4 border border-[var(--border-color)]"
|
||||||
|
>
|
||||||
|
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Server Details</h3>
|
||||||
|
|
||||||
|
{#if isLoadingDetails}
|
||||||
|
<div class="flex items-center justify-center h-32">
|
||||||
|
<RefreshCw class="w-6 h-6 animate-spin text-[var(--text-secondary)]" />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@const TransportIcon = getTransportIcon(selectedServer.transport)}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Name -->
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Name</p>
|
||||||
|
<p class="text-sm text-[var(--text-primary)] mt-1">{selectedServer.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transport -->
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Transport</p>
|
||||||
|
<p class="text-sm text-[var(--text-primary)] mt-1 flex items-center gap-2">
|
||||||
|
<TransportIcon class="w-4 h-4 {getTransportColor(selectedServer.transport)}" />
|
||||||
|
{selectedServer.transport.toUpperCase()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL or Command -->
|
||||||
|
{#if selectedServer.url}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">URL</p>
|
||||||
|
<p
|
||||||
|
class="text-sm text-[var(--text-primary)] mt-1 break-all font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
|
||||||
|
>
|
||||||
|
{selectedServer.url}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedServer.command}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Command</p>
|
||||||
|
<p
|
||||||
|
class="text-sm text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
|
||||||
|
>
|
||||||
|
{selectedServer.command}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Environment Variables -->
|
||||||
|
{#if selectedServer.env}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
|
||||||
|
Environment
|
||||||
|
</p>
|
||||||
|
<pre
|
||||||
|
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto">{JSON.stringify(
|
||||||
|
selectedServer.env,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}</pre>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Full Server Details -->
|
||||||
|
{#if serverDetails}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
|
||||||
|
Full Details
|
||||||
|
</p>
|
||||||
|
<pre
|
||||||
|
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto whitespace-pre-wrap">{serverDetails}</pre>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="pt-4 border-t border-[var(--border-color)]">
|
||||||
|
<button
|
||||||
|
onclick={() => selectedServer && removeServer(selectedServer.name)}
|
||||||
|
disabled={actionInProgress === selectedServer?.name}
|
||||||
|
class="w-full px-4 py-2 bg-red-500/20 border border-red-500/30 rounded-lg text-sm text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
Remove Server
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import Markdown from "./Markdown.svelte";
|
||||||
|
|
||||||
|
let memoryFiles: string[] = $state([]);
|
||||||
|
let selectedFile: string | null = $state(null);
|
||||||
|
let fileContent: string = $state("");
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let error: string | null = $state(null);
|
||||||
|
let isPanelOpen = $state(false);
|
||||||
|
|
||||||
|
interface MemoryFilesResponse {
|
||||||
|
files: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMemoryFiles() {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const response = await invoke<MemoryFilesResponse>("list_memory_files");
|
||||||
|
memoryFiles = response.files;
|
||||||
|
} catch (e) {
|
||||||
|
error = `Failed to load memory files: ${e}`;
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFileContent(filePath: string) {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
// Use our backend command instead of Tauri plugin to handle WSL paths
|
||||||
|
const content = await invoke<string>("read_file_content", { path: filePath });
|
||||||
|
fileContent = content;
|
||||||
|
selectedFile = filePath;
|
||||||
|
} catch (e) {
|
||||||
|
error = `Failed to read file: ${e}`;
|
||||||
|
console.error(error);
|
||||||
|
fileContent = "";
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileName(path: string): string {
|
||||||
|
return path.split("/").pop() || path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePanel() {
|
||||||
|
isPanelOpen = !isPanelOpen;
|
||||||
|
if (isPanelOpen && memoryFiles.length === 0) {
|
||||||
|
loadMemoryFiles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Don't load on mount - only when panel is opened
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button class="memory-toggle" onclick={togglePanel} title="Memory Browser">
|
||||||
|
<svg
|
||||||
|
class="icon"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="label">Memory</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if isPanelOpen}
|
||||||
|
<div class="memory-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="header-title">
|
||||||
|
<svg
|
||||||
|
class="header-icon"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h3>Memory Files</h3>
|
||||||
|
</div>
|
||||||
|
<button class="close-btn" onclick={togglePanel} title="Close">
|
||||||
|
<svg
|
||||||
|
class="close-icon"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-content">
|
||||||
|
{#if isLoading && memoryFiles.length === 0}
|
||||||
|
<div class="loading">
|
||||||
|
<svg
|
||||||
|
class="spinner"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Loading memory files...
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="error">
|
||||||
|
<p>{error}</p>
|
||||||
|
<button class="retry-btn" onclick={loadMemoryFiles}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if memoryFiles.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<p>No memory files found.</p>
|
||||||
|
<p class="hint">
|
||||||
|
Memory files are created automatically as I learn from our conversations!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="panel-layout">
|
||||||
|
<div class="file-list">
|
||||||
|
{#each memoryFiles as file (file)}
|
||||||
|
<button
|
||||||
|
class="file-item"
|
||||||
|
class:active={selectedFile === file}
|
||||||
|
onclick={() => loadFileContent(file)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="file-icon"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="file-name">{getFileName(file)}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-viewer">
|
||||||
|
{#if selectedFile && fileContent}
|
||||||
|
<div class="viewer-header">
|
||||||
|
<h4>{getFileName(selectedFile)}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="viewer-content">
|
||||||
|
<Markdown content={fileContent} />
|
||||||
|
</div>
|
||||||
|
{:else if selectedFile && isLoading}
|
||||||
|
<div class="loading-file">
|
||||||
|
<svg
|
||||||
|
class="spinner"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Loading file...
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="no-selection">
|
||||||
|
<p>Select a memory file to view its contents</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.memory-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-toggle:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 600px;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-icon {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error,
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error p {
|
||||||
|
color: var(--terminal-error, #f87171);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--accent-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-style: italic;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item.active {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-viewer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-header {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-file,
|
||||||
|
.no-selection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,25 +1,24 @@
|
|||||||
<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 { claudeStore, hasPermissionPending } from "$lib/stores/claude";
|
import { SvelteSet } from "svelte/reactivity";
|
||||||
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
import type { PermissionRequest } from "$lib/types/messages";
|
import type { PermissionRequest } from "$lib/types/messages";
|
||||||
import { updateDiscordRpc } from "$lib/tauri";
|
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
||||||
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 isVisible = $state(false);
|
let permissions: PermissionRequest[] = [];
|
||||||
let permission: PermissionRequest | null = $state(null);
|
let selectedPermissions = new SvelteSet<string>();
|
||||||
let grantedToolsList: string[] = $state([]);
|
let grantedToolsList: string[] = [];
|
||||||
let workingDirectory = $state("");
|
let workingDirectory = "";
|
||||||
|
|
||||||
hasPermissionPending.subscribe((pending) => {
|
conversationsStore.pendingPermissions.subscribe((perms) => {
|
||||||
isVisible = pending;
|
permissions = perms;
|
||||||
});
|
// When new permissions arrive, select all by default
|
||||||
|
if (perms.length > 0) {
|
||||||
claudeStore.pendingPermission.subscribe((perm) => {
|
selectedPermissions = new SvelteSet(perms.map((p) => p.id));
|
||||||
permission = perm;
|
|
||||||
if (perm) {
|
|
||||||
characterState.setState("permission");
|
characterState.setState("permission");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -33,77 +32,105 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function handleApproveAndReconnect() {
|
async function handleApproveAndReconnect() {
|
||||||
if (permission) {
|
const selectedPerms = permissions.filter((p) => selectedPermissions.has(p.id));
|
||||||
// Capture conversation history before clearing/reconnecting
|
|
||||||
const conversationHistory = claudeStore.getConversationHistory();
|
|
||||||
const approvedTool = permission.tool;
|
|
||||||
const toolInput = permission.input;
|
|
||||||
|
|
||||||
claudeStore.grantTool(approvedTool);
|
if (selectedPerms.length === 0) {
|
||||||
const newGrantedTools = [...grantedToolsList, approvedTool];
|
claudeStore.addLine("system", "No permissions selected to approve");
|
||||||
claudeStore.addLine(
|
|
||||||
"system",
|
|
||||||
`Permission granted for: ${approvedTool}. Reconnecting with context...`
|
|
||||||
);
|
|
||||||
claudeStore.clearPermission();
|
claudeStore.clearPermission();
|
||||||
|
characterState.setTemporaryState("idle", 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Stop current session and reconnect with new permissions
|
// Capture conversation history before clearing/reconnecting
|
||||||
try {
|
const conversationHistory = claudeStore.getConversationHistory();
|
||||||
const conversationId = get(claudeStore.activeConversationId);
|
|
||||||
if (!conversationId) {
|
|
||||||
throw new Error("No active conversation");
|
|
||||||
}
|
|
||||||
|
|
||||||
await invoke("stop_claude", { conversationId });
|
// Grant all selected tools
|
||||||
|
const newlyGrantedTools: string[] = [];
|
||||||
|
for (const perm of selectedPerms) {
|
||||||
|
if (!grantedToolsList.includes(perm.tool)) {
|
||||||
|
claudeStore.grantTool(perm.tool);
|
||||||
|
newlyGrantedTools.push(perm.tool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Small delay to ensure clean shutdown
|
const newGrantedTools = [...grantedToolsList, ...newlyGrantedTools];
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
const toolNames = selectedPerms.map((p) => p.tool).join(", ");
|
||||||
|
|
||||||
await invoke("start_claude", {
|
claudeStore.addLine(
|
||||||
conversationId,
|
"system",
|
||||||
options: {
|
`Permission granted for ${selectedPerms.length} tool(s): ${toolNames}. Reconnecting with context...`
|
||||||
working_dir: workingDirectory || "/home/naomi",
|
);
|
||||||
allowed_tools: newGrantedTools,
|
claudeStore.clearPermission();
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update Discord RPC when reconnecting after permission grant
|
// Stop current session and reconnect with new permissions
|
||||||
const config = configStore.getConfig();
|
try {
|
||||||
const activeConversation = get(conversationsStore.activeConversation);
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
if (activeConversation) {
|
if (!conversationId) {
|
||||||
await updateDiscordRpc(
|
throw new Error("No active conversation");
|
||||||
activeConversation.name,
|
}
|
||||||
config.model || "claude",
|
|
||||||
activeConversation.startedAt
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for connection to establish
|
// Prevent stats reset on reconnection
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
setSkipNextGreeting(true);
|
||||||
|
|
||||||
// Send conversation context to restore state
|
await invoke("stop_claude", { conversationId });
|
||||||
if (conversationHistory) {
|
|
||||||
const contextMessage = `[CONTEXT RESTORATION]
|
// Small delay to ensure clean shutdown
|
||||||
I just granted you permission to use the ${approvedTool} tool. Here's our conversation so far:
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const config = configStore.getConfig();
|
||||||
|
await invoke("start_claude", {
|
||||||
|
conversationId,
|
||||||
|
options: {
|
||||||
|
working_dir: workingDirectory || "/home/naomi",
|
||||||
|
model: config.model || null,
|
||||||
|
api_key: config.api_key || null,
|
||||||
|
custom_instructions: config.custom_instructions || null,
|
||||||
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
|
allowed_tools: newGrantedTools,
|
||||||
|
use_worktree: config.use_worktree ?? false,
|
||||||
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Discord RPC when reconnecting after permission grant
|
||||||
|
const activeConversation = get(conversationsStore.activeConversation);
|
||||||
|
if (activeConversation) {
|
||||||
|
await updateDiscordRpc(
|
||||||
|
activeConversation.name,
|
||||||
|
config.model || "claude",
|
||||||
|
activeConversation.startedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for connection to establish
|
||||||
|
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 last action that was blocked was: ${approvedTool} with input:
|
The actions that were blocked:
|
||||||
${JSON.stringify(toolInput, null, 2)}
|
${blockedActions}
|
||||||
|
|
||||||
Please continue where we left off and retry that action now that you have permission.`;
|
Please continue where we left off and retry those actions 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() {
|
||||||
@@ -124,8 +151,24 @@ Please continue where we left off and retry that action now that you have permis
|
|||||||
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 (!isVisible || !permission) return;
|
if (permissions.length === 0) return;
|
||||||
|
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -139,72 +182,126 @@ Please continue where we left off and retry that action now that you have permis
|
|||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
{#if isVisible && permission}
|
{#if permissions.length > 0}
|
||||||
<div
|
<div
|
||||||
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
|
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-[60] backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<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>
|
<div class="flex-1">
|
||||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Permission Blocked</h2>
|
<h2 class="text-lg font-semibold text-[var(--text-primary)]">
|
||||||
<p class="text-sm text-[var(--text-secondary)]">Hikari tried to use a restricted tool</p>
|
{permissions.length === 1
|
||||||
|
? "Permission Required"
|
||||||
|
: `${permissions.length} Permissions Required`}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-[var(--text-secondary)]">
|
||||||
|
Hikari tried to use {permissions.length === 1
|
||||||
|
? "a restricted tool"
|
||||||
|
: "restricted tools"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 text-xs">
|
||||||
|
<button
|
||||||
|
onclick={selectAll}
|
||||||
|
class="px-2 py-1 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Select All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={selectNone}
|
||||||
|
class="px-2 py-1 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
Select None
|
||||||
|
</button>
|
||||||
</div>
|
</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">
|
||||||
This action was automatically blocked. Approve to allow this tool for future requests.
|
{permissions.length === 1
|
||||||
|
? "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="mb-4">
|
<div class="space-y-3 mb-6">
|
||||||
<div class="text-sm text-[var(--text-secondary)] mb-1">Tool</div>
|
{#each permissions as perm (perm.id)}
|
||||||
<div
|
<div
|
||||||
class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--accent-primary)] font-mono flex items-center justify-between"
|
class="border border-[var(--border-color)] rounded-lg p-4 cursor-pointer transition-colors {selectedPermissions.has(
|
||||||
>
|
perm.id
|
||||||
<span>{permission.tool}</span>
|
)
|
||||||
{#if isToolAlreadyGranted(permission.tool)}
|
? 'bg-green-500/10 border-green-500/30'
|
||||||
<span class="text-xs text-green-400 bg-green-500/20 px-2 py-0.5 rounded"
|
: 'bg-[var(--bg-secondary)] hover:bg-[var(--bg-secondary)]/80'}"
|
||||||
>Already Granted</span
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
{/if}
|
onclick={() => togglePermission(perm.id)}
|
||||||
</div>
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
togglePermission(perm.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="mt-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedPermissions.has(perm.id)}
|
||||||
|
onchange={() => togglePermission(perm.id)}
|
||||||
|
class="w-4 h-4 accent-green-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="text-[var(--accent-primary)] font-mono text-sm font-medium">
|
||||||
|
{perm.tool}
|
||||||
|
</span>
|
||||||
|
{#if isToolAlreadyGranted(perm.tool)}
|
||||||
|
<span class="text-xs text-green-400 bg-green-500/20 px-2 py-0.5 rounded">
|
||||||
|
Already Granted
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-[var(--text-secondary)] mb-2">
|
||||||
|
{perm.description}
|
||||||
|
</div>
|
||||||
|
{#if Object.keys(perm.input).length > 0}
|
||||||
|
<details class="text-xs">
|
||||||
|
<summary
|
||||||
|
class="cursor-pointer text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
View details
|
||||||
|
</summary>
|
||||||
|
<pre
|
||||||
|
class="mt-2 px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] overflow-x-auto max-h-32">{formatInput(
|
||||||
|
perm.input
|
||||||
|
)}</pre>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
</div>
|
</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
|
Dismiss All
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={handleApproveAndReconnect}
|
onclick={handleApproveAndReconnect}
|
||||||
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={selectedPermissions.size === 0}
|
||||||
|
class="flex-1 px-4 py-2 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Allow & Reconnect
|
Approve Selected ({selectedPermissions.size})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { Download, Trash2, Power, PowerOff, RefreshCw } from "lucide-svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginInfo {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarketplaceInfo {
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onClose }: Props = $props();
|
||||||
|
|
||||||
|
let plugins = $state<PluginInfo[]>([]);
|
||||||
|
let marketplaces = $state<MarketplaceInfo[]>([]);
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let isLoadingMarketplaces = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let newPluginName = $state("");
|
||||||
|
let isInstalling = $state(false);
|
||||||
|
let actionInProgress = $state<string | null>(null);
|
||||||
|
let showMarketplaces = $state(false);
|
||||||
|
let newMarketplaceSource = $state("");
|
||||||
|
let isAddingMarketplace = $state(false);
|
||||||
|
|
||||||
|
async function loadPlugins(): Promise<void> {
|
||||||
|
try {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
plugins = await invoke<PluginInfo[]>("list_plugins");
|
||||||
|
} catch (e) {
|
||||||
|
error = `Failed to load plugins: ${e}`;
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMarketplaces(): Promise<void> {
|
||||||
|
try {
|
||||||
|
isLoadingMarketplaces = true;
|
||||||
|
error = null;
|
||||||
|
marketplaces = await invoke<MarketplaceInfo[]>("list_marketplaces");
|
||||||
|
} catch (e) {
|
||||||
|
error = `Failed to load marketplaces: ${e}`;
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
isLoadingMarketplaces = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installPlugin(): Promise<void> {
|
||||||
|
if (!newPluginName.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isInstalling = true;
|
||||||
|
error = null;
|
||||||
|
await invoke("install_plugin", { pluginName: newPluginName.trim() });
|
||||||
|
newPluginName = "";
|
||||||
|
await loadPlugins();
|
||||||
|
} catch (e) {
|
||||||
|
error = `Failed to install plugin: ${e}`;
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
isInstalling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uninstallPlugin(pluginName: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
actionInProgress = pluginName;
|
||||||
|
error = null;
|
||||||
|
await invoke("uninstall_plugin", { pluginName });
|
||||||
|
await loadPlugins();
|
||||||
|
} catch (e) {
|
||||||
|
error = `Failed to uninstall plugin: ${e}`;
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
actionInProgress = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePlugin(plugin: PluginInfo): Promise<void> {
|
||||||
|
try {
|
||||||
|
actionInProgress = plugin.name;
|
||||||
|
error = null;
|
||||||
|
if (plugin.enabled) {
|
||||||
|
await invoke("disable_plugin", { pluginName: plugin.name });
|
||||||
|
} else {
|
||||||
|
await invoke("enable_plugin", { pluginName: plugin.name });
|
||||||
|
}
|
||||||
|
await loadPlugins();
|
||||||
|
} catch (e) {
|
||||||
|
error = `Failed to ${plugin.enabled ? "disable" : "enable"} plugin: ${e}`;
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
actionInProgress = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePlugin(pluginName: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
actionInProgress = pluginName;
|
||||||
|
error = null;
|
||||||
|
await invoke("update_plugin", { pluginName });
|
||||||
|
await loadPlugins();
|
||||||
|
} catch (e) {
|
||||||
|
error = `Failed to update plugin: ${e}`;
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
actionInProgress = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMarketplace(): Promise<void> {
|
||||||
|
if (!newMarketplaceSource.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isAddingMarketplace = true;
|
||||||
|
error = null;
|
||||||
|
await invoke("add_marketplace", { source: newMarketplaceSource.trim() });
|
||||||
|
newMarketplaceSource = "";
|
||||||
|
await loadMarketplaces();
|
||||||
|
} catch (e) {
|
||||||
|
error = `Failed to add marketplace: ${e}`;
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
isAddingMarketplace = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMarketplace(name: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
actionInProgress = name;
|
||||||
|
error = null;
|
||||||
|
await invoke("remove_marketplace", { name });
|
||||||
|
await loadMarketplaces();
|
||||||
|
} catch (e) {
|
||||||
|
error = `Failed to remove marketplace: ${e}`;
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
actionInProgress = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadPlugins();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed top-0 right-0 h-full w-[600px] bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="text-[var(--accent-primary)]">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Plugin Management</h2>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)]">
|
||||||
|
{plugins.length} plugin{plugins.length !== 1 ? "s" : ""} installed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
|
||||||
|
aria-label="Close plugin panel"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Install Plugin Section -->
|
||||||
|
<div class="p-4 border-b border-[var(--border-color)]">
|
||||||
|
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-2">Install New Plugin</h3>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)] mb-3">
|
||||||
|
Enter plugin name (e.g., "macrodata" or "macrodata@macrodata" for specific marketplace)
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newPluginName}
|
||||||
|
placeholder="plugin-name or plugin@marketplace"
|
||||||
|
class="flex-1 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
onkeydown={(e) => e.key === "Enter" && installPlugin()}
|
||||||
|
disabled={isInstalling}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onclick={installPlugin}
|
||||||
|
disabled={isInstalling || !newPluginName.trim()}
|
||||||
|
class="px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{#if isInstalling}
|
||||||
|
<RefreshCw class="w-4 h-4 animate-spin" />
|
||||||
|
{:else}
|
||||||
|
<Download class="w-4 h-4" />
|
||||||
|
{/if}
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Marketplace Management Section -->
|
||||||
|
<div class="p-4 border-b border-[var(--border-color)]">
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
showMarketplaces = !showMarketplaces;
|
||||||
|
if (showMarketplaces && marketplaces.length === 0) {
|
||||||
|
loadMarketplaces();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="w-full text-left flex items-center justify-between text-sm font-medium text-[var(--text-primary)] hover:text-[var(--accent-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<span>Manage Marketplaces</span>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4 transition-transform"
|
||||||
|
class:rotate-180={showMarketplaces}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if showMarketplaces}
|
||||||
|
<div class="mt-3 space-y-3">
|
||||||
|
<!-- Add Marketplace Form -->
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)] mb-2">
|
||||||
|
Add a marketplace from GitHub (e.g., "ascorbic/macrodata")
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newMarketplaceSource}
|
||||||
|
placeholder="owner/repo"
|
||||||
|
class="flex-1 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
onkeydown={(e) => e.key === "Enter" && addMarketplace()}
|
||||||
|
disabled={isAddingMarketplace}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onclick={addMarketplace}
|
||||||
|
disabled={isAddingMarketplace || !newMarketplaceSource.trim()}
|
||||||
|
class="px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{#if isAddingMarketplace}
|
||||||
|
<RefreshCw class="w-4 h-4 animate-spin" />
|
||||||
|
{:else}
|
||||||
|
<Download class="w-4 h-4" />
|
||||||
|
{/if}
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Marketplaces List -->
|
||||||
|
{#if isLoadingMarketplaces}
|
||||||
|
<div class="flex items-center justify-center py-4">
|
||||||
|
<RefreshCw class="w-5 h-5 animate-spin text-[var(--text-secondary)]" />
|
||||||
|
</div>
|
||||||
|
{:else if marketplaces.length > 0}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each marketplaces as marketplace (marketplace.name)}
|
||||||
|
<div
|
||||||
|
class="bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)]"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="font-medium text-[var(--text-primary)]">{marketplace.name}</h4>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)] mt-1">{marketplace.source}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={() => removeMarketplace(marketplace.name)}
|
||||||
|
disabled={actionInProgress === marketplace.name}
|
||||||
|
class="px-2 py-1 text-red-400 hover:bg-red-500/20 rounded transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-[var(--text-secondary)] text-center py-4">
|
||||||
|
No marketplaces configured
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Display -->
|
||||||
|
{#if error}
|
||||||
|
<div class="mx-4 mt-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
|
||||||
|
<p class="text-sm text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Plugins List -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-4">
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex items-center justify-center h-full text-[var(--text-secondary)]">
|
||||||
|
<RefreshCw class="w-8 h-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
{:else if plugins.length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-16 w-16 mb-4 opacity-50"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-center">No plugins installed</p>
|
||||||
|
<p class="text-sm text-center mt-2">Install a plugin using the form above</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each plugins as plugin (plugin.name)}
|
||||||
|
<div
|
||||||
|
class="bg-[var(--bg-secondary)]/50 rounded-lg p-4 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
|
||||||
|
{plugin.name}
|
||||||
|
{#if plugin.enabled}
|
||||||
|
<span
|
||||||
|
class="px-2 py-0.5 bg-[var(--success-color)]/20 text-[var(--success-color)] text-xs rounded border border-[var(--success-color)]/30"
|
||||||
|
>
|
||||||
|
Enabled
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="px-2 py-0.5 bg-[var(--text-secondary)]/20 text-[var(--text-secondary)] text-xs rounded border border-[var(--border-color)]"
|
||||||
|
>
|
||||||
|
Disabled
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)] mt-1">v{plugin.version}</p>
|
||||||
|
{#if plugin.description}
|
||||||
|
<p class="text-sm text-[var(--text-secondary)] mt-2">{plugin.description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-3">
|
||||||
|
<button
|
||||||
|
onclick={() => togglePlugin(plugin)}
|
||||||
|
disabled={actionInProgress === plugin.name}
|
||||||
|
class="flex-1 px-3 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{#if plugin.enabled}
|
||||||
|
<PowerOff class="w-4 h-4" />
|
||||||
|
Disable
|
||||||
|
{:else}
|
||||||
|
<Power class="w-4 h-4" />
|
||||||
|
Enable
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={() => updatePlugin(plugin.name)}
|
||||||
|
disabled={actionInProgress === plugin.name}
|
||||||
|
class="px-3 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw class="w-4 h-4" />
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={() => uninstallPlugin(plugin.name)}
|
||||||
|
disabled={actionInProgress === plugin.name}
|
||||||
|
class="px-3 py-1.5 bg-red-500/20 border border-red-500/30 rounded text-sm text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
Uninstall
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -15,9 +15,23 @@
|
|||||||
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);
|
||||||
|
|
||||||
const sessions = $derived(sessionsStore.sessions);
|
let sessions = $state<SessionListItem[]>([]);
|
||||||
const isLoading = $derived(sessionsStore.isLoading);
|
let isLoading = $state(false);
|
||||||
|
|
||||||
|
$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();
|
||||||
@@ -121,6 +135,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -186,6 +205,22 @@
|
|||||||
</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}
|
||||||
@@ -281,11 +316,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"
|
||||||
@@ -307,7 +342,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)}>
|
||||||
@@ -428,6 +463,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if showClearAllConfirm}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
|
||||||
|
onclick={() => (showClearAllConfirm = false)}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onkeydown={(e) => e.key === "Escape" && (showClearAllConfirm = false)}
|
||||||
|
>
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div
|
||||||
|
class="bg-[var(--bg-primary)] border border-red-500/30 rounded-lg shadow-xl max-w-md w-full p-6"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="clear-all-title"
|
||||||
|
aria-describedby="clear-all-description"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="flex-shrink-0 text-red-500">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 id="clear-all-title" class="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||||
|
Clear All Sessions?
|
||||||
|
</h3>
|
||||||
|
<p id="clear-all-description" class="text-[var(--text-secondary)] mb-4">
|
||||||
|
This will permanently delete all {sessions.length} session{sessions.length === 1
|
||||||
|
? ""
|
||||||
|
: "s"}. This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onclick={() => (showClearAllConfirm = false)}
|
||||||
|
class="px-4 py-2 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={handleClearAll}
|
||||||
|
class="px-4 py-2 text-sm font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
Clear All Sessions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
[role="dialog"] {
|
[role="dialog"] {
|
||||||
animation: slideIn 0.2s ease-out;
|
animation: slideIn 0.2s ease-out;
|
||||||
|
|||||||
@@ -21,16 +21,25 @@
|
|||||||
import HelpPanel from "./HelpPanel.svelte";
|
import HelpPanel from "./HelpPanel.svelte";
|
||||||
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
|
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
|
||||||
import { achievementProgress } from "$lib/stores/achievements";
|
import { achievementProgress } from "$lib/stores/achievements";
|
||||||
|
import { runningAgentCount } from "$lib/stores/agents";
|
||||||
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
|
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
|
||||||
|
import TodoPanel from "./TodoPanel.svelte";
|
||||||
import GitPanel from "./GitPanel.svelte";
|
import GitPanel from "./GitPanel.svelte";
|
||||||
import ProfilePanel from "./ProfilePanel.svelte";
|
import ProfilePanel from "./ProfilePanel.svelte";
|
||||||
|
import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
|
||||||
|
import CastPanel from "./CastPanel.svelte";
|
||||||
|
import PluginManagementPanel from "./PluginManagementPanel.svelte";
|
||||||
|
import McpManagementPanel from "./McpManagementPanel.svelte";
|
||||||
import { conversationsStore } from "$lib/stores/conversations";
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
import {
|
import {
|
||||||
generateContextInjection,
|
generateContextInjection,
|
||||||
createSummary,
|
createSummary,
|
||||||
sanitizeForJson,
|
sanitizeForJson,
|
||||||
} from "$lib/utils/conversationUtils";
|
} from "$lib/utils/conversationUtils";
|
||||||
import { updateDiscordRpc } from "$lib/tauri";
|
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
||||||
|
import { debugConsoleStore } from "$lib/stores/debugConsole";
|
||||||
|
import WorkspaceTrustModal from "./WorkspaceTrustModal.svelte";
|
||||||
|
import type { WorkspaceHookInfo } from "$lib/types/messages";
|
||||||
|
|
||||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||||
const DONATE_URL = "https://donate.nhcarrigan.com";
|
const DONATE_URL = "https://donate.nhcarrigan.com";
|
||||||
@@ -46,10 +55,18 @@
|
|||||||
let showHelp = $state(false);
|
let showHelp = $state(false);
|
||||||
let showKeyboardShortcuts = $state(false);
|
let showKeyboardShortcuts = $state(false);
|
||||||
let showSessionHistory = $state(false);
|
let showSessionHistory = $state(false);
|
||||||
|
let showTodoPanel = $state(false);
|
||||||
let showGitPanel = $state(false);
|
let showGitPanel = $state(false);
|
||||||
let showProfile = $state(false);
|
let showProfile = $state(false);
|
||||||
|
let showAgentMonitor = $state(false);
|
||||||
|
let showCastPanel = $state(false);
|
||||||
|
let showPluginPanel = $state(false);
|
||||||
|
let showMcpPanel = $state(false);
|
||||||
let isSummarising = $state(false);
|
let isSummarising = $state(false);
|
||||||
|
let showWorkspaceTrust = $state(false);
|
||||||
|
let pendingHookInfo: WorkspaceHookInfo | null = $state(null);
|
||||||
const progress = $derived($achievementProgress);
|
const progress = $derived($achievementProgress);
|
||||||
|
const activeAgentCount = $derived($runningAgentCount);
|
||||||
let currentConfig: HikariConfig = $state({
|
let currentConfig: HikariConfig = $state({
|
||||||
model: null,
|
model: null,
|
||||||
api_key: null,
|
api_key: null,
|
||||||
@@ -65,7 +82,6 @@
|
|||||||
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,
|
||||||
@@ -88,6 +104,12 @@
|
|||||||
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,
|
||||||
|
use_worktree: false,
|
||||||
|
disable_1m_context: false,
|
||||||
|
trusted_workspaces: [],
|
||||||
|
background_image_path: null,
|
||||||
|
background_image_opacity: 0.3,
|
||||||
});
|
});
|
||||||
|
|
||||||
let streamerModeActive = $state(false);
|
let streamerModeActive = $state(false);
|
||||||
@@ -141,11 +163,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConnect() {
|
async function doConnect(targetDir: string) {
|
||||||
if (isConnecting || connectionStatus === "connected") return;
|
|
||||||
|
|
||||||
const targetDir = selectedDirectory || "/home/naomi";
|
|
||||||
|
|
||||||
// Combine session-granted tools with config auto-granted tools
|
// Combine session-granted tools with config auto-granted tools
|
||||||
const allAllowedTools = [
|
const allAllowedTools = [
|
||||||
...new Set([...grantedToolsList, ...currentConfig.auto_granted_tools]),
|
...new Set([...grantedToolsList, ...currentConfig.auto_granted_tools]),
|
||||||
@@ -165,6 +183,8 @@
|
|||||||
custom_instructions: currentConfig.custom_instructions || null,
|
custom_instructions: currentConfig.custom_instructions || null,
|
||||||
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
||||||
allowed_tools: allAllowedTools,
|
allowed_tools: allAllowedTools,
|
||||||
|
use_worktree: currentConfig.use_worktree ?? false,
|
||||||
|
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -183,6 +203,52 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleConnect() {
|
||||||
|
if (isConnecting || connectionStatus === "connected") return;
|
||||||
|
|
||||||
|
const targetDir = selectedDirectory || "/home/naomi";
|
||||||
|
|
||||||
|
if (currentConfig.trusted_workspaces?.includes(targetDir)) {
|
||||||
|
await doConnect(targetDir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hookInfo = await invoke<WorkspaceHookInfo>("check_workspace_hooks", {
|
||||||
|
workingDir: targetDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hookInfo.has_concerns) {
|
||||||
|
pendingHookInfo = hookInfo;
|
||||||
|
showWorkspaceTrust = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Fail open: if we can't check hooks, proceed with connection
|
||||||
|
console.error("Failed to check workspace hooks:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await doConnect(targetDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTrustAndConnect() {
|
||||||
|
showWorkspaceTrust = false;
|
||||||
|
const targetDir = selectedDirectory || "/home/naomi";
|
||||||
|
pendingHookInfo = null;
|
||||||
|
const alreadyTrusted = currentConfig.trusted_workspaces?.includes(targetDir) ?? false;
|
||||||
|
if (!alreadyTrusted) {
|
||||||
|
await configStore.updateConfig({
|
||||||
|
trusted_workspaces: [...(currentConfig.trusted_workspaces ?? []), targetDir],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
doConnect(targetDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelConnect() {
|
||||||
|
showWorkspaceTrust = false;
|
||||||
|
pendingHookInfo = null;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDisconnect() {
|
async function handleDisconnect() {
|
||||||
try {
|
try {
|
||||||
const conversationId = get(claudeStore.activeConversationId);
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
@@ -190,6 +256,9 @@
|
|||||||
throw new Error("No active conversation");
|
throw new Error("No active conversation");
|
||||||
}
|
}
|
||||||
await invoke("stop_claude", { conversationId });
|
await invoke("stop_claude", { conversationId });
|
||||||
|
|
||||||
|
// Clear granted permissions when user explicitly disconnects
|
||||||
|
claudeStore.revokeAllTools();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to stop Claude:", error);
|
console.error("Failed to stop Claude:", error);
|
||||||
}
|
}
|
||||||
@@ -248,6 +317,9 @@
|
|||||||
: sanitizedContent;
|
: sanitizedContent;
|
||||||
|
|
||||||
// Step 1: Disconnect from Claude to reset context
|
// Step 1: Disconnect from Claude to reset context
|
||||||
|
// Prevent stats reset on reconnection
|
||||||
|
setSkipNextGreeting(true);
|
||||||
|
|
||||||
if (connectionStatus === "connected") {
|
if (connectionStatus === "connected") {
|
||||||
await invoke("stop_claude", { conversationId: activeId });
|
await invoke("stop_claude", { conversationId: activeId });
|
||||||
}
|
}
|
||||||
@@ -270,6 +342,8 @@
|
|||||||
custom_instructions: currentConfig.custom_instructions || null,
|
custom_instructions: currentConfig.custom_instructions || null,
|
||||||
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
||||||
allowed_tools: allAllowedTools,
|
allowed_tools: allAllowedTools,
|
||||||
|
use_worktree: currentConfig.use_worktree ?? false,
|
||||||
|
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -364,16 +438,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-2 flex-wrap min-w-0">
|
||||||
{#if streamerModeActive}
|
{#if streamerModeActive}
|
||||||
<div
|
<div
|
||||||
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse"
|
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse shrink-0"
|
||||||
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"
|
class="p-1 text-gray-500 icon-trans-hover shrink-0"
|
||||||
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">
|
||||||
@@ -427,6 +501,20 @@
|
|||||||
/>
|
/>
|
||||||
</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"
|
||||||
@@ -441,6 +529,34 @@
|
|||||||
/>
|
/>
|
||||||
</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"}
|
||||||
@@ -460,6 +576,43 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (showCastPanel = true)}
|
||||||
|
class="p-1 text-gray-500 icon-trans-hover"
|
||||||
|
title="Meet the Team"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (showAgentMonitor = !showAgentMonitor)}
|
||||||
|
class="p-1 text-gray-500 icon-trans-hover relative {showAgentMonitor
|
||||||
|
? 'text-[var(--trans-pink)]'
|
||||||
|
: ''}"
|
||||||
|
title="Agent Monitor"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{#if activeAgentCount > 0}
|
||||||
|
<span
|
||||||
|
class="absolute -top-1 -right-1 bg-blue-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px] animate-pulse"
|
||||||
|
>
|
||||||
|
{activeAgentCount}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => (showStats = !showStats)}
|
onclick={() => (showStats = !showStats)}
|
||||||
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
|
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
|
||||||
@@ -474,6 +627,20 @@
|
|||||||
/>
|
/>
|
||||||
</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"
|
||||||
@@ -600,7 +767,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">
|
<div class="fixed top-14 right-4 z-50 max-h-[calc(100vh-4rem)] overflow-y-auto">
|
||||||
<StatsDisplay
|
<StatsDisplay
|
||||||
onRequestSummary={handleCompactConversation}
|
onRequestSummary={handleCompactConversation}
|
||||||
onStartFreshWithContext={handleStartFreshWithContext}
|
onStartFreshWithContext={handleStartFreshWithContext}
|
||||||
@@ -625,6 +792,10 @@
|
|||||||
<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}
|
||||||
@@ -632,3 +803,56 @@
|
|||||||
{#if showProfile}
|
{#if showProfile}
|
||||||
<ProfilePanel onClose={() => (showProfile = false)} />
|
<ProfilePanel onClose={() => (showProfile = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showAgentMonitor}
|
||||||
|
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showCastPanel}
|
||||||
|
<CastPanel onClose={() => (showCastPanel = false)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showPluginPanel}
|
||||||
|
<PluginManagementPanel onClose={() => (showPluginPanel = false)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showMcpPanel}
|
||||||
|
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showWorkspaceTrust && pendingHookInfo}
|
||||||
|
<WorkspaceTrustModal
|
||||||
|
hookInfo={pendingHookInfo}
|
||||||
|
onTrust={handleTrustAndConnect}
|
||||||
|
onCancel={handleCancelConnect}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Responsive status bar styling */
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make all icon buttons shrink but not grow */
|
||||||
|
.status-bar button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide version text on very small screens */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.status-bar button span:last-of-type {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stack left and right sections on very small screens */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.status-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* SystemClock Component Tests
|
||||||
|
*
|
||||||
|
* Note: This file tests the time formatting logic used by the SystemClock component.
|
||||||
|
* Full component rendering tests are challenging with Svelte 5 + @testing-library/svelte
|
||||||
|
* due to SSR/CSR compatibility issues. The component itself is simple and visually
|
||||||
|
* testable - it displays the current date and time, updating every second.
|
||||||
|
*
|
||||||
|
* What this component does:
|
||||||
|
* - Displays date in British format: "7 February 2026"
|
||||||
|
* - Displays time in 24-hour format: "14:35:42"
|
||||||
|
* - Updates every second via setInterval
|
||||||
|
* - Cleans up interval on unmount via $effect
|
||||||
|
*
|
||||||
|
* Manual testing checklist:
|
||||||
|
* - [ ] Clock appears above the Send button
|
||||||
|
* - [ ] Time updates every second
|
||||||
|
* - [ ] Date format is "DD Month YYYY"
|
||||||
|
* - [ ] Time format is "HH:MM:SS" (24-hour)
|
||||||
|
* - [ ] Hover effect works (border turns accent colour)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
// Helper function that mirrors the component's formatting logic
|
||||||
|
function formatDateTime(date: Date): string {
|
||||||
|
const day = date.getDate();
|
||||||
|
const month = date.toLocaleString("en-GB", { month: "long" });
|
||||||
|
const year = date.getFullYear();
|
||||||
|
|
||||||
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||||
|
|
||||||
|
return `${day} ${month} ${year}, ${hours}:${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SystemClock date/time formatting", () => {
|
||||||
|
it("formats date in British format (DD Month YYYY)", () => {
|
||||||
|
// Use local timezone (not UTC) since the component uses local time
|
||||||
|
const date = new Date(2026, 1, 7, 14, 35, 42); // Feb 7, 2026 14:35:42 local
|
||||||
|
const formatted = formatDateTime(date);
|
||||||
|
|
||||||
|
expect(formatted).toContain("7 February 2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats time in 24-hour format (HH:MM:SS)", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 14, 35, 42);
|
||||||
|
const formatted = formatDateTime(date);
|
||||||
|
|
||||||
|
// Should have the pattern HH:MM:SS
|
||||||
|
expect(formatted).toMatch(/\d{2}:\d{2}:\d{2}/);
|
||||||
|
expect(formatted).toContain("14:35:42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combines date and time with comma separator", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 14, 35, 42);
|
||||||
|
const formatted = formatDateTime(date);
|
||||||
|
|
||||||
|
expect(formatted).toBe("7 February 2026, 14:35:42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pads single-digit hours, minutes, and seconds with zeros", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 3, 5, 8);
|
||||||
|
const formatted = formatDateTime(date);
|
||||||
|
|
||||||
|
// Should have leading zeros: 03:05:08, not 3:5:8
|
||||||
|
expect(formatted).toContain("03:05:08");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles different months correctly", () => {
|
||||||
|
const date = new Date(2026, 11, 25, 12, 0, 0); // December is month 11
|
||||||
|
const formatted = formatDateTime(date);
|
||||||
|
|
||||||
|
expect(formatted).toContain("25 December 2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles year changes correctly", () => {
|
||||||
|
const date = new Date(2027, 0, 1, 0, 0, 0); // January is month 0
|
||||||
|
const formatted = formatDateTime(date);
|
||||||
|
|
||||||
|
expect(formatted).toContain("1 January 2027");
|
||||||
|
expect(formatted).toContain("00:00:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles midnight correctly", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 0, 0, 0);
|
||||||
|
const formatted = formatDateTime(date);
|
||||||
|
|
||||||
|
expect(formatted).toContain("00:00:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles noon correctly", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 12, 0, 0);
|
||||||
|
const formatted = formatDateTime(date);
|
||||||
|
|
||||||
|
// 24-hour format, so noon is 12:00:00, not 00:00:00
|
||||||
|
expect(formatted).toContain("12:00:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles end of day correctly", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 23, 59, 59);
|
||||||
|
const formatted = formatDateTime(date);
|
||||||
|
|
||||||
|
expect(formatted).toContain("23:59:59");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles month boundaries correctly", () => {
|
||||||
|
// Last day of January
|
||||||
|
const jan31 = new Date(2026, 0, 31, 23, 59, 59);
|
||||||
|
expect(formatDateTime(jan31)).toContain("31 January 2026");
|
||||||
|
|
||||||
|
// First day of February
|
||||||
|
const feb1 = new Date(2026, 1, 1, 0, 0, 0);
|
||||||
|
expect(formatDateTime(feb1)).toContain("1 February 2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles leap year February correctly", () => {
|
||||||
|
// 2024 is a leap year
|
||||||
|
const feb29 = new Date(2024, 1, 29, 12, 0, 0);
|
||||||
|
const formatted = formatDateTime(feb29);
|
||||||
|
|
||||||
|
expect(formatted).toContain("29 February 2024");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles all 12 months correctly", () => {
|
||||||
|
const months = [
|
||||||
|
"January",
|
||||||
|
"February",
|
||||||
|
"March",
|
||||||
|
"April",
|
||||||
|
"May",
|
||||||
|
"June",
|
||||||
|
"July",
|
||||||
|
"August",
|
||||||
|
"September",
|
||||||
|
"October",
|
||||||
|
"November",
|
||||||
|
"December",
|
||||||
|
];
|
||||||
|
|
||||||
|
months.forEach((month, index) => {
|
||||||
|
const date = new Date(2026, index, 15, 12, 0, 0);
|
||||||
|
const formatted = formatDateTime(date);
|
||||||
|
|
||||||
|
expect(formatted).toContain(month);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
||||||
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
|
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
import ConversationTabs from "./ConversationTabs.svelte";
|
import ConversationTabs from "./ConversationTabs.svelte";
|
||||||
import Markdown from "./Markdown.svelte";
|
import Markdown from "./Markdown.svelte";
|
||||||
import HighlightedText from "./HighlightedText.svelte";
|
import HighlightedText from "./HighlightedText.svelte";
|
||||||
|
import ThinkingBlock from "./ThinkingBlock.svelte";
|
||||||
import { 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 } from "$lib/stores/config";
|
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
|
||||||
|
|
||||||
let terminalElement: HTMLDivElement;
|
let terminalElement: HTMLDivElement;
|
||||||
let shouldAutoScroll = true;
|
let shouldAutoScroll = true;
|
||||||
@@ -24,6 +27,11 @@
|
|||||||
hidePaths = value;
|
hidePaths = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let showThinking = true;
|
||||||
|
showThinkingBlocks.subscribe((value) => {
|
||||||
|
showThinking = value;
|
||||||
|
});
|
||||||
|
|
||||||
claudeStore.terminalLines.subscribe((value) => {
|
claudeStore.terminalLines.subscribe((value) => {
|
||||||
lines = value;
|
lines = value;
|
||||||
});
|
});
|
||||||
@@ -84,6 +92,16 @@
|
|||||||
return "terminal-tool";
|
return "terminal-tool";
|
||||||
case "error":
|
case "error":
|
||||||
return "terminal-error";
|
return "terminal-error";
|
||||||
|
case "thinking":
|
||||||
|
return "terminal-thinking";
|
||||||
|
case "rate-limit":
|
||||||
|
return "terminal-rate-limit";
|
||||||
|
case "compact-prompt":
|
||||||
|
return "terminal-compact-prompt";
|
||||||
|
case "worktree":
|
||||||
|
return "terminal-worktree";
|
||||||
|
case "config-change":
|
||||||
|
return "terminal-config-change";
|
||||||
default:
|
default:
|
||||||
return "terminal-default";
|
return "terminal-default";
|
||||||
}
|
}
|
||||||
@@ -101,6 +119,12 @@
|
|||||||
return "[tool]";
|
return "[tool]";
|
||||||
case "error":
|
case "error":
|
||||||
return "[error]";
|
return "[error]";
|
||||||
|
case "rate-limit":
|
||||||
|
return "[rate-limit]";
|
||||||
|
case "worktree":
|
||||||
|
return "[worktree]";
|
||||||
|
case "config-change":
|
||||||
|
return "[config]";
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -179,6 +203,27 @@
|
|||||||
copiedMessageId = null;
|
copiedMessageId = null;
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCompact() {
|
||||||
|
if (!currentConversationId) return;
|
||||||
|
await invoke("send_prompt", { conversationId: currentConversationId, message: "/compact" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapsible tool lines
|
||||||
|
const TOOL_COLLAPSE_THRESHOLD = 60;
|
||||||
|
let expandedToolLines: Record<string, boolean> = {};
|
||||||
|
|
||||||
|
function isToolContentLong(content: string): boolean {
|
||||||
|
return content.length > TOOL_COLLAPSE_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateToolContent(content: string): string {
|
||||||
|
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleToolLine(id: string) {
|
||||||
|
expandedToolLines = { ...expandedToolLines, [id]: !expandedToolLines[id] };
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -209,58 +254,106 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each lines as line (line.id)}
|
{#each lines as line (line.id)}
|
||||||
<div class="terminal-line mb-2 {getLineClass(line.type)} relative group">
|
{#if line.type === "thinking"}
|
||||||
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
|
{#if showThinking}
|
||||||
{#if line.cost && line.cost.costUsd > 0}
|
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
|
||||||
<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}
|
||||||
{#if getLinePrefix(line.type)}
|
{:else}
|
||||||
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
<div
|
||||||
{/if}
|
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
|
||||||
{#if line.toolName}
|
style={line.parentToolUseId
|
||||||
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
|
||||||
{/if}
|
: ""}
|
||||||
{#if line.type === "assistant" || line.type === "user"}
|
>
|
||||||
<div class="message-content-wrapper">
|
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
|
||||||
<Markdown
|
{#if line.parentToolUseId}
|
||||||
|
<span class="text-xs mr-2 opacity-60" title="Message from subagent">
|
||||||
|
<svg
|
||||||
|
class="inline-block w-3 h-3 -mt-0.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if line.cost && line.cost.costUsd > 0}
|
||||||
|
<span
|
||||||
|
class="terminal-cost text-xs mr-2"
|
||||||
|
title="Input: {line.cost.inputTokens} | Output: {line.cost.outputTokens}"
|
||||||
|
>
|
||||||
|
${line.cost.costUsd < 0.01
|
||||||
|
? line.cost.costUsd.toFixed(4)
|
||||||
|
: line.cost.costUsd.toFixed(3)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if getLinePrefix(line.type)}
|
||||||
|
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if line.toolName}
|
||||||
|
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
||||||
|
{/if}
|
||||||
|
{#if line.type === "compact-prompt"}
|
||||||
|
<button class="compact-action-btn" onclick={handleCompact}>
|
||||||
|
⚡ Compact Conversation
|
||||||
|
</button>
|
||||||
|
{:else 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 if line.type === "tool" && isToolContentLong(maskPaths(line.content, hidePaths))}
|
||||||
|
<span class="tool-collapsible">
|
||||||
|
<HighlightedText
|
||||||
|
content={expandedToolLines[line.id]
|
||||||
|
? maskPaths(line.content, hidePaths)
|
||||||
|
: truncateToolContent(maskPaths(line.content, hidePaths))}
|
||||||
|
searchQuery={currentSearchQuery}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="tool-toggle-btn"
|
||||||
|
onclick={() => toggleToolLine(line.id)}
|
||||||
|
title={expandedToolLines[line.id] ? "Collapse" : "Expand to see full content"}
|
||||||
|
>
|
||||||
|
{expandedToolLines[line.id] ? "▲" : "▼"}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<HighlightedText
|
||||||
content={maskPaths(line.content, hidePaths)}
|
content={maskPaths(line.content, hidePaths)}
|
||||||
searchQuery={currentSearchQuery}
|
searchQuery={currentSearchQuery}
|
||||||
/>
|
/>
|
||||||
<button
|
{/if}
|
||||||
class="copy-message-btn opacity-0 group-hover:opacity-100 transition-opacity"
|
</div>
|
||||||
onclick={() => handleCopyMessage(line.id, line.content)}
|
{/if}
|
||||||
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>
|
||||||
@@ -293,6 +386,42 @@
|
|||||||
color: var(--terminal-error, #f87171);
|
color: var(--terminal-error, #f87171);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-rate-limit {
|
||||||
|
color: var(--terminal-rate-limit, #fb923c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-compact-prompt {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-worktree {
|
||||||
|
color: var(--terminal-worktree, #34d399);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-config-change {
|
||||||
|
color: var(--terminal-config-change, #a78bfa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4em;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--terminal-error, #f87171);
|
||||||
|
color: var(--terminal-error, #f87171);
|
||||||
|
padding: 0.3em 0.8em;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-action-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--terminal-error, #f87171) 15%, transparent);
|
||||||
|
color: var(--terminal-error, #f87171);
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-default {
|
.terminal-default {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
@@ -372,4 +501,28 @@
|
|||||||
.terminal-line {
|
.terminal-line {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-collapsible {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-toggle-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-tertiary, #6b7280);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.7em;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-toggle-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--terminal-tool, #c084fc);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
/**
|
||||||
|
* Terminal Component Tests
|
||||||
|
*
|
||||||
|
* Tests the pure helper functions extracted from the Terminal component:
|
||||||
|
* - getLineClass: maps line types to CSS class names
|
||||||
|
* - getLinePrefix: maps line types to display prefixes
|
||||||
|
* - formatTime: formats a Date as "HH:MM AM/PM"
|
||||||
|
* - isToolContentLong: checks if tool content exceeds collapse threshold
|
||||||
|
* - truncateToolContent: truncates long tool content with ellipsis
|
||||||
|
*
|
||||||
|
* Manual testing checklist:
|
||||||
|
* - [ ] rate-limit lines appear in amber
|
||||||
|
* - [ ] error lines appear in red
|
||||||
|
* - [ ] tool lines appear in purple
|
||||||
|
* - [ ] system lines appear in grey italic
|
||||||
|
* - [ ] user lines appear in cyan
|
||||||
|
* - [ ] assistant lines appear in primary text colour
|
||||||
|
* - [ ] long tool content is collapsed by default with a toggle button
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
// Mirror functions from Terminal.svelte for isolated testing
|
||||||
|
|
||||||
|
function getLineClass(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case "user":
|
||||||
|
return "terminal-user";
|
||||||
|
case "assistant":
|
||||||
|
return "terminal-assistant";
|
||||||
|
case "system":
|
||||||
|
return "terminal-system italic";
|
||||||
|
case "tool":
|
||||||
|
return "terminal-tool";
|
||||||
|
case "error":
|
||||||
|
return "terminal-error";
|
||||||
|
case "thinking":
|
||||||
|
return "terminal-thinking";
|
||||||
|
case "rate-limit":
|
||||||
|
return "terminal-rate-limit";
|
||||||
|
case "compact-prompt":
|
||||||
|
return "terminal-compact-prompt";
|
||||||
|
case "worktree":
|
||||||
|
return "terminal-worktree";
|
||||||
|
case "config-change":
|
||||||
|
return "terminal-config-change";
|
||||||
|
default:
|
||||||
|
return "terminal-default";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinePrefix(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case "user":
|
||||||
|
return ">";
|
||||||
|
case "assistant":
|
||||||
|
return "";
|
||||||
|
case "system":
|
||||||
|
return "[system]";
|
||||||
|
case "tool":
|
||||||
|
return "[tool]";
|
||||||
|
case "error":
|
||||||
|
return "[error]";
|
||||||
|
case "rate-limit":
|
||||||
|
return "[rate-limit]";
|
||||||
|
case "worktree":
|
||||||
|
return "[worktree]";
|
||||||
|
case "config-change":
|
||||||
|
return "[config]";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(date: Date): string {
|
||||||
|
return date.toLocaleTimeString("en-US", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOL_COLLAPSE_THRESHOLD = 60;
|
||||||
|
|
||||||
|
function isToolContentLong(content: string): boolean {
|
||||||
|
return content.length > TOOL_COLLAPSE_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateToolContent(content: string): string {
|
||||||
|
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
describe("getLineClass", () => {
|
||||||
|
it("returns terminal-user for user lines", () => {
|
||||||
|
expect(getLineClass("user")).toBe("terminal-user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-assistant for assistant lines", () => {
|
||||||
|
expect(getLineClass("assistant")).toBe("terminal-assistant");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-system italic for system lines", () => {
|
||||||
|
expect(getLineClass("system")).toBe("terminal-system italic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-tool for tool lines", () => {
|
||||||
|
expect(getLineClass("tool")).toBe("terminal-tool");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-error for error lines", () => {
|
||||||
|
expect(getLineClass("error")).toBe("terminal-error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-thinking for thinking lines", () => {
|
||||||
|
expect(getLineClass("thinking")).toBe("terminal-thinking");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-rate-limit for rate-limit lines", () => {
|
||||||
|
expect(getLineClass("rate-limit")).toBe("terminal-rate-limit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-compact-prompt for compact-prompt lines", () => {
|
||||||
|
expect(getLineClass("compact-prompt")).toBe("terminal-compact-prompt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-worktree for worktree lines", () => {
|
||||||
|
expect(getLineClass("worktree")).toBe("terminal-worktree");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-config-change for config-change lines", () => {
|
||||||
|
expect(getLineClass("config-change")).toBe("terminal-config-change");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-default for unknown line types", () => {
|
||||||
|
expect(getLineClass("unknown")).toBe("terminal-default");
|
||||||
|
expect(getLineClass("")).toBe("terminal-default");
|
||||||
|
expect(getLineClass("random-future-type")).toBe("terminal-default");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getLinePrefix", () => {
|
||||||
|
it("returns > for user lines", () => {
|
||||||
|
expect(getLinePrefix("user")).toBe(">");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for assistant lines", () => {
|
||||||
|
expect(getLinePrefix("assistant")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [system] for system lines", () => {
|
||||||
|
expect(getLinePrefix("system")).toBe("[system]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [tool] for tool lines", () => {
|
||||||
|
expect(getLinePrefix("tool")).toBe("[tool]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [error] for error lines", () => {
|
||||||
|
expect(getLinePrefix("error")).toBe("[error]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [rate-limit] for rate-limit lines", () => {
|
||||||
|
expect(getLinePrefix("rate-limit")).toBe("[rate-limit]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for compact-prompt lines (button renders instead)", () => {
|
||||||
|
expect(getLinePrefix("compact-prompt")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [worktree] for worktree lines", () => {
|
||||||
|
expect(getLinePrefix("worktree")).toBe("[worktree]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [config] for config-change lines", () => {
|
||||||
|
expect(getLinePrefix("config-change")).toBe("[config]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for thinking lines (no prefix)", () => {
|
||||||
|
expect(getLinePrefix("thinking")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for unknown line types", () => {
|
||||||
|
expect(getLinePrefix("unknown")).toBe("");
|
||||||
|
expect(getLinePrefix("")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatTime", () => {
|
||||||
|
it("formats time in 12-hour format with AM/PM", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 14, 35);
|
||||||
|
const formatted = formatTime(date);
|
||||||
|
expect(formatted).toMatch(/\d{2}:\d{2}\s?(AM|PM)/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats afternoon times correctly", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 14, 35);
|
||||||
|
const formatted = formatTime(date);
|
||||||
|
expect(formatted).toContain("02:35");
|
||||||
|
expect(formatted.toUpperCase()).toContain("PM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats morning times correctly", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 9, 5);
|
||||||
|
const formatted = formatTime(date);
|
||||||
|
expect(formatted).toContain("09:05");
|
||||||
|
expect(formatted.toUpperCase()).toContain("AM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats midnight correctly", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 0, 0);
|
||||||
|
const formatted = formatTime(date);
|
||||||
|
expect(formatted).toContain("12:00");
|
||||||
|
expect(formatted.toUpperCase()).toContain("AM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats noon correctly", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 12, 0);
|
||||||
|
const formatted = formatTime(date);
|
||||||
|
expect(formatted).toContain("12:00");
|
||||||
|
expect(formatted.toUpperCase()).toContain("PM");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isToolContentLong", () => {
|
||||||
|
it("returns false for content at or below the threshold", () => {
|
||||||
|
const exactThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD);
|
||||||
|
expect(isToolContentLong(exactThreshold)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for content exceeding the threshold", () => {
|
||||||
|
const overThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD + 1);
|
||||||
|
expect(isToolContentLong(overThreshold)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for short content", () => {
|
||||||
|
expect(isToolContentLong("short")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for empty content", () => {
|
||||||
|
expect(isToolContentLong("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("truncateToolContent", () => {
|
||||||
|
it("truncates content to the threshold length with an ellipsis", () => {
|
||||||
|
const long = "x".repeat(100);
|
||||||
|
const result = truncateToolContent(long);
|
||||||
|
expect(result).toBe("x".repeat(TOOL_COLLAPSE_THRESHOLD) + "…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps content shorter than threshold unchanged (plus ellipsis)", () => {
|
||||||
|
const short = "hello";
|
||||||
|
const result = truncateToolContent(short);
|
||||||
|
expect(result).toBe("hello…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the unicode ellipsis character (not three dots)", () => {
|
||||||
|
const long = "x".repeat(100);
|
||||||
|
const result = truncateToolContent(long);
|
||||||
|
expect(result.endsWith("…")).toBe(true);
|
||||||
|
expect(result.endsWith("...")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
bind:this={menuElement}
|
bind:this={menuElement}
|
||||||
class="menu-content"
|
class="menu-content"
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { todos } from "$lib/stores/todos";
|
||||||
|
import { CheckCircle, Circle, Loader } from "lucide-svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onClose }: Props = $props();
|
||||||
|
|
||||||
|
const currentTodos = $derived($todos);
|
||||||
|
const hasTodos = $derived(currentTodos.length > 0);
|
||||||
|
const completedCount = $derived(currentTodos.filter((t) => t.status === "completed").length);
|
||||||
|
const totalCount = $derived(currentTodos.length);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed top-0 right-0 h-full w-96 bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="text-[var(--accent-primary)]">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Hikari's Todo List</h2>
|
||||||
|
{#if hasTodos}
|
||||||
|
<p class="text-xs text-[var(--text-secondary)]">
|
||||||
|
{completedCount} of {totalCount} completed
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
|
||||||
|
aria-label="Close todo panel"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-4">
|
||||||
|
{#if !hasTodos}
|
||||||
|
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-16 w-16 mb-4 opacity-50"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-center">No active todos</p>
|
||||||
|
<p class="text-sm text-center mt-2">I'll update this when I start working on tasks!</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each currentTodos as todo (todo.content)}
|
||||||
|
<div
|
||||||
|
class="group bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all"
|
||||||
|
class:opacity-60={todo.status === "completed"}
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<!-- Status Icon -->
|
||||||
|
<div class="mt-0.5 flex-shrink-0">
|
||||||
|
{#if todo.status === "completed"}
|
||||||
|
<CheckCircle class="w-5 h-5 text-[var(--success-color)]" />
|
||||||
|
{:else if todo.status === "in_progress"}
|
||||||
|
<Loader class="w-5 h-5 text-[var(--accent-primary)] animate-spin" />
|
||||||
|
{:else}
|
||||||
|
<Circle class="w-5 h-5 text-[var(--text-secondary)]" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
class="text-sm font-medium"
|
||||||
|
class:text-[var(--text-secondary)]={todo.status === "completed"}
|
||||||
|
class:line-through={todo.status === "completed"}
|
||||||
|
class:text-[var(--text-primary)]={todo.status !== "completed"}
|
||||||
|
>
|
||||||
|
{todo.status === "in_progress" ? todo.activeForm : todo.content}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Status Badge -->
|
||||||
|
<div class="mt-1">
|
||||||
|
{#if todo.status === "completed"}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[var(--success-color)]/20 text-[var(--success-color)] border border-[var(--success-color)]/30"
|
||||||
|
>
|
||||||
|
✓ Completed
|
||||||
|
</span>
|
||||||
|
{:else if todo.status === "in_progress"}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] border border-[var(--accent-primary)]/30 animate-pulse"
|
||||||
|
>
|
||||||
|
⚡ In Progress
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-color)]"
|
||||||
|
>
|
||||||
|
○ Pending
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer with Progress Bar -->
|
||||||
|
{#if hasTodos}
|
||||||
|
<div class="border-t border-[var(--accent-primary)]/30 p-4 bg-[var(--bg-secondary)]/50">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs font-medium text-[var(--text-secondary)]">Progress</span>
|
||||||
|
<span class="text-xs font-medium text-[var(--accent-primary)]">
|
||||||
|
{Math.round((completedCount / totalCount) * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-[var(--bg-secondary)] rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] h-2 rounded-full transition-all duration-500 ease-out"
|
||||||
|
style="width: {(completedCount / totalCount) * 100}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse {
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
import { claudeStore, hasQuestionPending } from "$lib/stores/claude";
|
import { claudeStore, hasQuestionPending } from "$lib/stores/claude";
|
||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
import type { UserQuestionEvent } from "$lib/types/messages";
|
import type { UserQuestionEvent } from "$lib/types/messages";
|
||||||
import { updateDiscordRpc } from "$lib/tauri";
|
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
||||||
import { conversationsStore } from "$lib/stores/conversations";
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
import { configStore } from "$lib/stores/config";
|
import { configStore } from "$lib/stores/config";
|
||||||
|
|
||||||
@@ -89,20 +89,29 @@
|
|||||||
claudeStore.clearQuestion();
|
claudeStore.clearQuestion();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Prevent stats reset on reconnection
|
||||||
|
setSkipNextGreeting(true);
|
||||||
|
|
||||||
await invoke("stop_claude", { conversationId });
|
await invoke("stop_claude", { conversationId });
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const config = configStore.getConfig();
|
||||||
await invoke("start_claude", {
|
await invoke("start_claude", {
|
||||||
conversationId,
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
working_dir: workingDirectory || "/home/naomi",
|
working_dir: workingDirectory || "/home/naomi",
|
||||||
|
model: config.model || null,
|
||||||
|
api_key: config.api_key || null,
|
||||||
|
custom_instructions: config.custom_instructions || null,
|
||||||
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
allowed_tools: grantedToolsList,
|
allowed_tools: grantedToolsList,
|
||||||
|
use_worktree: config.use_worktree ?? false,
|
||||||
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update Discord RPC when reconnecting after answering question
|
// 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(
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { characterState } from "$lib/stores/character";
|
||||||
|
import type { WorkspaceHookInfo } from "$lib/types/messages";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hookInfo: WorkspaceHookInfo;
|
||||||
|
onTrust: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hookInfo, onTrust, onCancel }: Props = $props();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
characterState.setState("permission");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onclick={onCancel}>
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 max-w-md w-full mx-4 shadow-xl"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-yellow-400 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Workspace Trust Required</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-[var(--text-secondary)] mb-4">
|
||||||
|
This workspace contains configuration that can execute code on your system. Review what was
|
||||||
|
found before connecting.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-3 mb-4">
|
||||||
|
{#if hookInfo.hook_types.length > 0}
|
||||||
|
<div class="bg-[var(--bg-primary)] rounded-md p-3">
|
||||||
|
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
|
||||||
|
Hooks (run shell commands automatically):
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{#each hookInfo.hook_types as hookType (hookType)}
|
||||||
|
<li class="text-sm text-yellow-400 font-mono">• {hookType}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if hookInfo.mcp_servers.length > 0}
|
||||||
|
<div class="bg-[var(--bg-primary)] rounded-md p-3">
|
||||||
|
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
|
||||||
|
MCP servers (run as local processes with system access):
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{#each hookInfo.mcp_servers as server (server)}
|
||||||
|
<li class="text-sm text-yellow-400 font-mono">• {server}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if hookInfo.custom_commands.length > 0}
|
||||||
|
<div class="bg-[var(--bg-primary)] rounded-md p-3">
|
||||||
|
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
|
||||||
|
Custom slash commands (can execute arbitrary instructions):
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{#each hookInfo.custom_commands as cmd (cmd)}
|
||||||
|
<li class="text-sm text-yellow-400 font-mono">• /{cmd}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-[var(--text-secondary)] mb-6">
|
||||||
|
Only connect to workspaces you trust. Trusting this workspace will remember your choice for
|
||||||
|
future sessions.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex gap-3 justify-end">
|
||||||
|
<button
|
||||||
|
onclick={onCancel}
|
||||||
|
class="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border-color)] rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={onTrust}
|
||||||
|
class="px-4 py-2 text-sm bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 border border-yellow-500/30 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Trust and Connect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -30,9 +30,9 @@
|
|||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||||
<div class="dialog-overlay" onclick={onCancel}>
|
<div class="dialog-overlay" onclick={onCancel}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||||
<div class="dialog-content" onclick={(e) => e.stopPropagation()}>
|
<div class="dialog-content" onclick={(e) => e.stopPropagation()}>
|
||||||
<h2 class="dialog-title">{title}</h2>
|
<h2 class="dialog-title">{title}</h2>
|
||||||
<p class="dialog-message">{message}</p>
|
<p class="dialog-message">{message}</p>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
bind:this={menuElement}
|
bind:this={menuElement}
|
||||||
class="menu-content"
|
class="menu-content"
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
class="menu-overlay"
|
class="menu-overlay"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
bind:this={menuElement}
|
bind:this={menuElement}
|
||||||
class="menu-content"
|
class="menu-content"
|
||||||
|
|||||||
@@ -50,9 +50,9 @@
|
|||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||||
<div class="dialog-overlay" onclick={onCancel}>
|
<div class="dialog-overlay" onclick={onCancel}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||||
<div class="dialog-content" onclick={(e) => e.stopPropagation()}>
|
<div class="dialog-content" onclick={(e) => e.stopPropagation()}>
|
||||||
<h2 class="dialog-title">{title}</h2>
|
<h2 class="dialog-title">{title}</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +1,8 @@
|
|||||||
import { characterState } from "$lib/stores/character";
|
|
||||||
import { notificationManager } from "./notificationManager";
|
import { notificationManager } from "./notificationManager";
|
||||||
import type { CharacterState } from "$lib/types/states";
|
|
||||||
import type { ConnectionStatus } from "$lib/types/messages";
|
import type { ConnectionStatus } from "$lib/types/messages";
|
||||||
|
|
||||||
// Track previous states to detect transitions
|
// Track previous connection status to detect transitions
|
||||||
let previousCharacterState: CharacterState | null = null;
|
|
||||||
let previousConnectionStatus: ConnectionStatus | null = null;
|
let previousConnectionStatus: ConnectionStatus | null = null;
|
||||||
let taskStartTime: number | null = null;
|
|
||||||
let hasNotifiedTaskStart = false;
|
|
||||||
|
|
||||||
export function handleCharacterStateChange(newState: CharacterState): void {
|
|
||||||
// Detect state transitions
|
|
||||||
if (previousCharacterState === newState) return;
|
|
||||||
|
|
||||||
// Task completion: any state -> success
|
|
||||||
if (newState === "success" && previousCharacterState !== null) {
|
|
||||||
const taskDuration = taskStartTime ? Date.now() - taskStartTime : 0;
|
|
||||||
// Only notify for tasks that took more than 2 seconds
|
|
||||||
if (taskDuration > 2000) {
|
|
||||||
notificationManager.notifySuccess();
|
|
||||||
}
|
|
||||||
taskStartTime = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error occurred
|
|
||||||
if (newState === "error" && previousCharacterState !== "error") {
|
|
||||||
notificationManager.notifyError();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Permission needed
|
|
||||||
if (newState === "permission") {
|
|
||||||
notificationManager.notifyPermission();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Starting long tasks - only notify once per response
|
|
||||||
if (
|
|
||||||
(newState === "coding" || newState === "searching") &&
|
|
||||||
previousCharacterState !== "coding" &&
|
|
||||||
previousCharacterState !== "searching" &&
|
|
||||||
!hasNotifiedTaskStart
|
|
||||||
) {
|
|
||||||
taskStartTime = Date.now();
|
|
||||||
hasNotifiedTaskStart = true;
|
|
||||||
notificationManager.notifyTaskStart();
|
|
||||||
}
|
|
||||||
|
|
||||||
previousCharacterState = newState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleConnectionStatusChange(newStatus: ConnectionStatus): void {
|
export function handleConnectionStatusChange(newStatus: ConnectionStatus): void {
|
||||||
// Only notify on successful connection after being disconnected
|
// Only notify on successful connection after being disconnected
|
||||||
@@ -67,37 +23,13 @@ export function handleToolExecution(_toolName: string): void {
|
|||||||
// But we could add specific rules here if needed
|
// But we could add specific rules here if needed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset notification state for a new response
|
// No-op: sound tracking is now per-conversation in tauri.ts
|
||||||
export function handleNewUserMessage(): void {
|
export function handleNewUserMessage(): void {}
|
||||||
hasNotifiedTaskStart = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store unsubscribe functions
|
// No-op: all per-conversation sounds are driven by tauri.ts event listeners
|
||||||
let unsubscribeCharacterState: (() => void) | null = null;
|
export function initializeNotificationRules(): void {}
|
||||||
|
|
||||||
// Initialize listeners
|
// Cleanup — reset connection tracking on teardown
|
||||||
export function initializeNotificationRules(): void {
|
|
||||||
// Clean up any existing subscriptions first
|
|
||||||
cleanupNotificationRules();
|
|
||||||
|
|
||||||
// Subscribe to character state changes
|
|
||||||
unsubscribeCharacterState = characterState.subscribe((state) => {
|
|
||||||
handleCharacterStateChange(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
// We'll connect to connection status in the next step
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup function to prevent duplicate listeners
|
|
||||||
export function cleanupNotificationRules(): void {
|
export function cleanupNotificationRules(): void {
|
||||||
if (unsubscribeCharacterState) {
|
|
||||||
unsubscribeCharacterState();
|
|
||||||
unsubscribeCharacterState = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset state tracking
|
|
||||||
previousCharacterState = null;
|
|
||||||
previousConnectionStatus = null;
|
previousConnectionStatus = null;
|
||||||
taskStartTime = null;
|
|
||||||
hasNotifiedTaskStart = false;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { agentStore, getAgentsForConversation, runningAgentCount } from "./agents";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import type { AgentInfo } from "$lib/types/agents";
|
||||||
|
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
|
||||||
|
|
||||||
|
describe("agents store", () => {
|
||||||
|
const conversationId = "test-conversation-1";
|
||||||
|
const otherConversationId = "test-conversation-2";
|
||||||
|
|
||||||
|
type AgentInput = Omit<AgentInfo, "characterName" | "characterAvatar">;
|
||||||
|
|
||||||
|
const createMockAgent = (overrides?: Partial<AgentInput>): AgentInput => ({
|
||||||
|
toolUseId: "toolu_test123",
|
||||||
|
description: "Test agent",
|
||||||
|
subagentType: "Explore",
|
||||||
|
startedAt: Date.now(),
|
||||||
|
status: "running",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear all conversations by subscribing and getting state
|
||||||
|
let state: Record<string, AgentInfo[]> = {};
|
||||||
|
const unsub = agentStore.subscribe((s) => {
|
||||||
|
state = s;
|
||||||
|
});
|
||||||
|
unsub();
|
||||||
|
|
||||||
|
// Clear each conversation
|
||||||
|
for (const convId of Object.keys(state)) {
|
||||||
|
agentStore.clearConversation(convId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addAgent", () => {
|
||||||
|
it("adds an agent to a conversation", () => {
|
||||||
|
const agent = createMockAgent();
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents).toHaveLength(1);
|
||||||
|
expect(agents[0]).toMatchObject(agent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assigns a character name and avatar to added agents", () => {
|
||||||
|
const agent = createMockAgent();
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
const validNames = CHARACTER_POOL.map((c) => c.name);
|
||||||
|
expect(validNames).toContain(agents[0].characterName);
|
||||||
|
expect(agents[0].characterAvatar).toMatch(/^https:\/\//u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("avoids duplicate character names across agents when possible", () => {
|
||||||
|
// Add 6 agents - each should ideally get a unique character
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
agentStore.addAgent(conversationId, createMockAgent({ toolUseId: `tool${i.toString()}` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
const names = agents.map((a) => a.characterName);
|
||||||
|
const uniqueNames = new Set(names);
|
||||||
|
expect(uniqueNames.size).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds multiple agents to the same conversation", () => {
|
||||||
|
const agent1 = createMockAgent({ toolUseId: "tool1" });
|
||||||
|
const agent2 = createMockAgent({ toolUseId: "tool2" });
|
||||||
|
|
||||||
|
agentStore.addAgent(conversationId, agent1);
|
||||||
|
agentStore.addAgent(conversationId, agent2);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents).toHaveLength(2);
|
||||||
|
expect(agents[0]).toMatchObject(agent1);
|
||||||
|
expect(agents[1]).toMatchObject(agent2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps agents in different conversations separate", () => {
|
||||||
|
const agent1 = createMockAgent({ toolUseId: "tool1" });
|
||||||
|
const agent2 = createMockAgent({ toolUseId: "tool2" });
|
||||||
|
|
||||||
|
agentStore.addAgent(conversationId, agent1);
|
||||||
|
agentStore.addAgent(otherConversationId, agent2);
|
||||||
|
|
||||||
|
const agents1 = get(getAgentsForConversation(conversationId));
|
||||||
|
const agents2 = get(getAgentsForConversation(otherConversationId));
|
||||||
|
|
||||||
|
expect(agents1).toHaveLength(1);
|
||||||
|
expect(agents2).toHaveLength(1);
|
||||||
|
expect(agents1[0]).toMatchObject(agent1);
|
||||||
|
expect(agents2[0]).toMatchObject(agent2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateAgentId", () => {
|
||||||
|
it("updates the agent_id for a specific agent", () => {
|
||||||
|
const agent = createMockAgent({ agentId: undefined });
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
agentStore.updateAgentId(conversationId, agent.toolUseId, "agent-abc123");
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents[0].agentId).toBe("agent-abc123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing if conversation doesn't exist", () => {
|
||||||
|
agentStore.updateAgentId("nonexistent", "tool1", "agent1");
|
||||||
|
// Should not throw
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing if tool_use_id doesn't exist", () => {
|
||||||
|
const agent = createMockAgent();
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
agentStore.updateAgentId(conversationId, "nonexistent-tool", "agent1");
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents[0].agentId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("endAgent", () => {
|
||||||
|
it("marks an agent as completed", () => {
|
||||||
|
const agent = createMockAgent({ status: "running" });
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
const endTime = Date.now();
|
||||||
|
agentStore.endAgent(conversationId, agent.toolUseId, endTime, false);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents[0].status).toBe("completed");
|
||||||
|
expect(agents[0].endedAt).toBe(endTime);
|
||||||
|
expect(agents[0].durationMs).toBeGreaterThanOrEqual(0); // Duration can be 0 if timestamps are the same
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks an agent as errored", () => {
|
||||||
|
const agent = createMockAgent({ status: "running" });
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
const endTime = Date.now();
|
||||||
|
agentStore.endAgent(conversationId, agent.toolUseId, endTime, true);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents[0].status).toBe("errored");
|
||||||
|
expect(agents[0].endedAt).toBe(endTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates duration correctly", () => {
|
||||||
|
const startTime = Date.now() - 5000; // 5 seconds ago
|
||||||
|
const agent = createMockAgent({ startedAt: startTime, status: "running" });
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
const endTime = Date.now();
|
||||||
|
agentStore.endAgent(conversationId, agent.toolUseId, endTime, false);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents[0].durationMs).toBeGreaterThanOrEqual(5000);
|
||||||
|
expect(agents[0].durationMs).toBeLessThanOrEqual(6000); // Allow some buffer
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing if conversation doesn't exist", () => {
|
||||||
|
agentStore.endAgent("nonexistent", "tool1", Date.now(), false);
|
||||||
|
// Should not throw
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing if agent doesn't exist", () => {
|
||||||
|
const agent = createMockAgent();
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
agentStore.endAgent(conversationId, "nonexistent-tool", Date.now(), false);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents[0].status).toBe("running"); // Status unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores lastAssistantMessage when provided", () => {
|
||||||
|
const agent = createMockAgent({ status: "running" });
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
agentStore.endAgent(
|
||||||
|
conversationId,
|
||||||
|
agent.toolUseId,
|
||||||
|
Date.now(),
|
||||||
|
false,
|
||||||
|
"Task completed successfully."
|
||||||
|
);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents[0].lastAssistantMessage).toBe("Task completed successfully.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves lastAssistantMessage undefined when not provided", () => {
|
||||||
|
const agent = createMockAgent({ status: "running" });
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
agentStore.endAgent(conversationId, agent.toolUseId, Date.now(), false);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents[0].lastAssistantMessage).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("markAllErrored", () => {
|
||||||
|
it("marks all running agents as errored", () => {
|
||||||
|
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
|
||||||
|
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
|
||||||
|
const agent3 = createMockAgent({ toolUseId: "tool3", status: "completed" });
|
||||||
|
|
||||||
|
agentStore.addAgent(conversationId, agent1);
|
||||||
|
agentStore.addAgent(conversationId, agent2);
|
||||||
|
agentStore.addAgent(conversationId, agent3);
|
||||||
|
|
||||||
|
agentStore.markAllErrored(conversationId);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents[0].status).toBe("errored");
|
||||||
|
expect(agents[0].endedAt).toBeGreaterThan(0);
|
||||||
|
expect(agents[1].status).toBe("errored");
|
||||||
|
expect(agents[1].endedAt).toBeGreaterThan(0);
|
||||||
|
expect(agents[2].status).toBe("completed"); // Already completed, unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing if conversation doesn't exist", () => {
|
||||||
|
agentStore.markAllErrored("nonexistent");
|
||||||
|
// Should not throw
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing if conversation has no running agents", () => {
|
||||||
|
const agent = createMockAgent({ status: "completed" });
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
agentStore.markAllErrored(conversationId);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents[0].status).toBe("completed"); // Unchanged
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearCompleted", () => {
|
||||||
|
it("removes completed and errored agents", () => {
|
||||||
|
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
|
||||||
|
const agent2 = createMockAgent({ toolUseId: "tool2", status: "completed" });
|
||||||
|
const agent3 = createMockAgent({ toolUseId: "tool3", status: "errored" });
|
||||||
|
|
||||||
|
agentStore.addAgent(conversationId, agent1);
|
||||||
|
agentStore.addAgent(conversationId, agent2);
|
||||||
|
agentStore.addAgent(conversationId, agent3);
|
||||||
|
|
||||||
|
agentStore.clearCompleted(conversationId);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents).toHaveLength(1);
|
||||||
|
expect(agents[0].toolUseId).toBe("tool1"); // Only running agent remains
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing if conversation doesn't exist", () => {
|
||||||
|
agentStore.clearCompleted("nonexistent");
|
||||||
|
// Should not throw
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears all agents if all are completed", () => {
|
||||||
|
const agent1 = createMockAgent({ toolUseId: "tool1", status: "completed" });
|
||||||
|
const agent2 = createMockAgent({ toolUseId: "tool2", status: "errored" });
|
||||||
|
|
||||||
|
agentStore.addAgent(conversationId, agent1);
|
||||||
|
agentStore.addAgent(conversationId, agent2);
|
||||||
|
|
||||||
|
agentStore.clearCompleted(conversationId);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearConversation", () => {
|
||||||
|
it("removes all agents from a conversation", () => {
|
||||||
|
const agent1 = createMockAgent({ toolUseId: "tool1" });
|
||||||
|
const agent2 = createMockAgent({ toolUseId: "tool2" });
|
||||||
|
|
||||||
|
agentStore.addAgent(conversationId, agent1);
|
||||||
|
agentStore.addAgent(conversationId, agent2);
|
||||||
|
|
||||||
|
agentStore.clearConversation(conversationId);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only removes agents from the specified conversation", () => {
|
||||||
|
const agent1 = createMockAgent({ toolUseId: "tool1" });
|
||||||
|
const agent2 = createMockAgent({ toolUseId: "tool2" });
|
||||||
|
|
||||||
|
agentStore.addAgent(conversationId, agent1);
|
||||||
|
agentStore.addAgent(otherConversationId, agent2);
|
||||||
|
|
||||||
|
agentStore.clearConversation(conversationId);
|
||||||
|
|
||||||
|
const agents1 = get(getAgentsForConversation(conversationId));
|
||||||
|
const agents2 = get(getAgentsForConversation(otherConversationId));
|
||||||
|
|
||||||
|
expect(agents1).toHaveLength(0);
|
||||||
|
expect(agents2).toHaveLength(1);
|
||||||
|
expect(agents2[0]).toMatchObject(agent2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing if conversation doesn't exist", () => {
|
||||||
|
agentStore.clearConversation("nonexistent");
|
||||||
|
// Should not throw
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runningAgentCount", () => {
|
||||||
|
it("counts running agents across all conversations", () => {
|
||||||
|
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
|
||||||
|
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
|
||||||
|
const agent3 = createMockAgent({ toolUseId: "tool3", status: "completed" });
|
||||||
|
const agent4 = createMockAgent({ toolUseId: "tool4", status: "running" });
|
||||||
|
|
||||||
|
agentStore.addAgent(conversationId, agent1);
|
||||||
|
agentStore.addAgent(conversationId, agent2);
|
||||||
|
agentStore.addAgent(conversationId, agent3);
|
||||||
|
agentStore.addAgent(otherConversationId, agent4);
|
||||||
|
|
||||||
|
const count = get(runningAgentCount);
|
||||||
|
expect(count).toBe(3); // 2 from first conversation + 1 from second
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 when no agents are running", () => {
|
||||||
|
const agent1 = createMockAgent({ status: "completed" });
|
||||||
|
const agent2 = createMockAgent({ status: "errored" });
|
||||||
|
|
||||||
|
agentStore.addAgent(conversationId, agent1);
|
||||||
|
agentStore.addAgent(otherConversationId, agent2);
|
||||||
|
|
||||||
|
const count = get(runningAgentCount);
|
||||||
|
expect(count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates when agents complete", () => {
|
||||||
|
const agent = createMockAgent({ status: "running" });
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
let count = get(runningAgentCount);
|
||||||
|
expect(count).toBe(1);
|
||||||
|
|
||||||
|
agentStore.endAgent(conversationId, agent.toolUseId, Date.now(), false);
|
||||||
|
|
||||||
|
count = get(runningAgentCount);
|
||||||
|
expect(count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates when conversation is cleared", () => {
|
||||||
|
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
|
||||||
|
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
|
||||||
|
|
||||||
|
agentStore.addAgent(conversationId, agent1);
|
||||||
|
agentStore.addAgent(conversationId, agent2);
|
||||||
|
|
||||||
|
let count = get(runningAgentCount);
|
||||||
|
expect(count).toBe(2);
|
||||||
|
|
||||||
|
agentStore.clearConversation(conversationId);
|
||||||
|
|
||||||
|
count = get(runningAgentCount);
|
||||||
|
expect(count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { writable, derived } from "svelte/store";
|
||||||
|
import type { AgentInfo } from "$lib/types/agents";
|
||||||
|
import { assignCharacter } from "$lib/utils/agentCharacters";
|
||||||
|
|
||||||
|
// Map of conversation ID -> agents in that conversation
|
||||||
|
const agentsByConversation = writable<Record<string, AgentInfo[]>>({});
|
||||||
|
|
||||||
|
function createAgentStore() {
|
||||||
|
return {
|
||||||
|
subscribe: agentsByConversation.subscribe,
|
||||||
|
|
||||||
|
addAgent(conversationId: string, agent: Omit<AgentInfo, "characterName" | "characterAvatar">) {
|
||||||
|
agentsByConversation.update((state) => {
|
||||||
|
const existing = state[conversationId] || [];
|
||||||
|
const activeNames = existing.map((a) => a.characterName);
|
||||||
|
const character = assignCharacter(activeNames);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
[conversationId]: [
|
||||||
|
...existing,
|
||||||
|
{ ...agent, characterName: character.name, characterAvatar: character.avatar },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAgentId(conversationId: string, toolUseId: string, agentId: string) {
|
||||||
|
agentsByConversation.update((state) => {
|
||||||
|
const agents = state[conversationId];
|
||||||
|
if (!agents) return state;
|
||||||
|
|
||||||
|
const agentIndex = agents.findIndex((a) => a.toolUseId === toolUseId);
|
||||||
|
if (agentIndex === -1) return state;
|
||||||
|
|
||||||
|
const updated = [...agents];
|
||||||
|
updated[agentIndex] = {
|
||||||
|
...updated[agentIndex],
|
||||||
|
agentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
[conversationId]: updated,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
endAgent(
|
||||||
|
conversationId: string,
|
||||||
|
toolUseId: string,
|
||||||
|
endedAt: number,
|
||||||
|
isError: boolean,
|
||||||
|
lastAssistantMessage?: string
|
||||||
|
) {
|
||||||
|
agentsByConversation.update((state) => {
|
||||||
|
const agents = state[conversationId];
|
||||||
|
if (!agents) return state;
|
||||||
|
|
||||||
|
const agentIndex = agents.findIndex((a) => a.toolUseId === toolUseId);
|
||||||
|
if (agentIndex === -1) return state;
|
||||||
|
|
||||||
|
const updated = [...agents];
|
||||||
|
const agent = updated[agentIndex];
|
||||||
|
const durationMs = endedAt - agent.startedAt;
|
||||||
|
|
||||||
|
updated[agentIndex] = {
|
||||||
|
...agent,
|
||||||
|
endedAt,
|
||||||
|
status: isError ? "errored" : "completed",
|
||||||
|
durationMs,
|
||||||
|
lastAssistantMessage,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
[conversationId]: updated,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
markAllErrored(conversationId: string) {
|
||||||
|
agentsByConversation.update((state) => {
|
||||||
|
const agents = state[conversationId];
|
||||||
|
if (!agents) return state;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const updated = agents.map((agent) =>
|
||||||
|
agent.status === "running"
|
||||||
|
? { ...agent, endedAt: now, status: "errored" as const }
|
||||||
|
: agent
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
[conversationId]: updated,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCompleted(conversationId: string) {
|
||||||
|
agentsByConversation.update((state) => {
|
||||||
|
const agents = state[conversationId];
|
||||||
|
if (!agents) return state;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
[conversationId]: agents.filter((a) => a.status === "running"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearConversation(conversationId: string) {
|
||||||
|
agentsByConversation.update((state) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Unused destructured value
|
||||||
|
const { [conversationId]: _, ...rest } = state;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentStore = createAgentStore();
|
||||||
|
|
||||||
|
export function getAgentsForConversation(conversationId: string) {
|
||||||
|
return derived(agentsByConversation, ($state) => $state[conversationId] || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runningAgentCount = derived(agentsByConversation, ($state) => {
|
||||||
|
let count = 0;
|
||||||
|
for (const agents of Object.values($state)) {
|
||||||
|
count += agents.filter((a) => a.status === "running").length;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
});
|
||||||
@@ -60,6 +60,15 @@ export const claudeStore = {
|
|||||||
isToolGranted: conversationsStore.isToolGranted,
|
isToolGranted: conversationsStore.isToolGranted,
|
||||||
setPendingRetryMessage: conversationsStore.setPendingRetryMessage,
|
setPendingRetryMessage: conversationsStore.setPendingRetryMessage,
|
||||||
|
|
||||||
|
// Sound tracking
|
||||||
|
resetSoundState: conversationsStore.resetSoundState,
|
||||||
|
setTaskStartTime: conversationsStore.setTaskStartTime,
|
||||||
|
markSuccessSoundFired: conversationsStore.markSuccessSoundFired,
|
||||||
|
markTaskStartSoundFired: conversationsStore.markTaskStartSoundFired,
|
||||||
|
|
||||||
|
// Draft text (per-tab input persistence)
|
||||||
|
setDraftText: conversationsStore.setDraftText,
|
||||||
|
|
||||||
// Conversation management
|
// Conversation management
|
||||||
createConversation: conversationsStore.createConversation,
|
createConversation: conversationsStore.createConversation,
|
||||||
deleteConversation: conversationsStore.deleteConversation,
|
deleteConversation: conversationsStore.deleteConversation,
|
||||||
@@ -101,7 +110,10 @@ export const claudeStore = {
|
|||||||
|
|
||||||
export const hasPermissionPending = derived(
|
export const hasPermissionPending = derived(
|
||||||
claudeStore.activeConversation,
|
claudeStore.activeConversation,
|
||||||
($conversation) => $conversation?.pendingPermission !== null
|
($conversation) =>
|
||||||
|
$conversation?.pendingPermissions !== null &&
|
||||||
|
$conversation?.pendingPermissions !== undefined &&
|
||||||
|
$conversation.pendingPermissions.length > 0
|
||||||
);
|
);
|
||||||
|
|
||||||
export const hasQuestionPending = derived(
|
export const hasQuestionPending = derived(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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", () => ({
|
||||||
@@ -167,7 +168,6 @@ 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,6 +193,12 @@ 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,
|
||||||
|
use_worktree: false,
|
||||||
|
disable_1m_context: false,
|
||||||
|
trusted_workspaces: [],
|
||||||
|
background_image_path: null,
|
||||||
|
background_image_opacity: 0.3,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBe("claude-sonnet-4");
|
expect(config.model).toBe("claude-sonnet-4");
|
||||||
@@ -213,7 +219,6 @@ 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,6 +244,12 @@ 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,
|
||||||
|
use_worktree: false,
|
||||||
|
disable_1m_context: false,
|
||||||
|
trusted_workspaces: [],
|
||||||
|
background_image_path: null,
|
||||||
|
background_image_opacity: 0.3,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBeNull();
|
expect(config.model).toBeNull();
|
||||||
@@ -489,4 +500,344 @@ 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,
|
||||||
|
use_worktree: false,
|
||||||
|
disable_1m_context: false,
|
||||||
|
trusted_workspaces: [],
|
||||||
|
background_image_path: null,
|
||||||
|
background_image_opacity: 0.3,
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+39
-23
@@ -27,7 +27,6 @@ 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;
|
||||||
@@ -46,6 +45,17 @@ 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;
|
||||||
|
// Worktree isolation
|
||||||
|
use_worktree: boolean;
|
||||||
|
// Disable 1M context window
|
||||||
|
disable_1m_context: boolean;
|
||||||
|
// Workspaces the user has explicitly trusted
|
||||||
|
trusted_workspaces: string[];
|
||||||
|
// Background image settings
|
||||||
|
background_image_path: string | null;
|
||||||
|
background_image_opacity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: HikariConfig = {
|
const defaultConfig: HikariConfig = {
|
||||||
@@ -60,7 +70,6 @@ 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,6 +95,12 @@ 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,
|
||||||
|
use_worktree: false,
|
||||||
|
disable_1m_context: false,
|
||||||
|
trusted_workspaces: [],
|
||||||
|
background_image_path: null,
|
||||||
|
background_image_opacity: 0.3,
|
||||||
};
|
};
|
||||||
|
|
||||||
function createConfigStore() {
|
function createConfigStore() {
|
||||||
@@ -94,6 +109,14 @@ 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 {
|
||||||
@@ -121,8 +144,7 @@ function createConfigStore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateConfig(updates: Partial<HikariConfig>) {
|
async function updateConfig(updates: Partial<HikariConfig>) {
|
||||||
let currentConfig: HikariConfig = defaultConfig;
|
const currentConfig = getCurrentConfig();
|
||||||
config.subscribe((c) => (currentConfig = c))();
|
|
||||||
const newConfig = { ...currentConfig, ...updates };
|
const newConfig = { ...currentConfig, ...updates };
|
||||||
await saveConfig(newConfig);
|
await saveConfig(newConfig);
|
||||||
}
|
}
|
||||||
@@ -147,15 +169,13 @@ function createConfigStore() {
|
|||||||
updates.custom_theme_colors = customColors;
|
updates.custom_theme_colors = customColors;
|
||||||
}
|
}
|
||||||
await updateConfig(updates);
|
await updateConfig(updates);
|
||||||
let currentConfig: HikariConfig = defaultConfig;
|
const currentConfig = getCurrentConfig();
|
||||||
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 });
|
||||||
let currentConfig: HikariConfig = defaultConfig;
|
const currentConfig = getCurrentConfig();
|
||||||
config.subscribe((c) => (currentConfig = c))();
|
|
||||||
if (currentConfig.theme === "custom") {
|
if (currentConfig.theme === "custom") {
|
||||||
applyCustomThemeColors(colors);
|
applyCustomThemeColors(colors);
|
||||||
}
|
}
|
||||||
@@ -168,16 +188,14 @@ function createConfigStore() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
increaseFontSize: async () => {
|
increaseFontSize: async () => {
|
||||||
let currentConfig: HikariConfig = defaultConfig;
|
const currentConfig = getCurrentConfig();
|
||||||
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 () => {
|
||||||
let currentConfig: HikariConfig = defaultConfig;
|
const currentConfig = getCurrentConfig();
|
||||||
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);
|
||||||
@@ -189,8 +207,7 @@ function createConfigStore() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
addAutoGrantedTool: async (tool: string) => {
|
addAutoGrantedTool: async (tool: string) => {
|
||||||
let currentConfig: HikariConfig = defaultConfig;
|
const currentConfig = getCurrentConfig();
|
||||||
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 });
|
||||||
@@ -198,27 +215,22 @@ function createConfigStore() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
removeAutoGrantedTool: async (tool: string) => {
|
removeAutoGrantedTool: async (tool: string) => {
|
||||||
let currentConfig: HikariConfig = defaultConfig;
|
const currentConfig = getCurrentConfig();
|
||||||
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 => {
|
||||||
let currentConfig: HikariConfig = defaultConfig;
|
return getCurrentConfig();
|
||||||
config.subscribe((c) => (currentConfig = c))();
|
|
||||||
return currentConfig;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleStreamerMode: async () => {
|
toggleStreamerMode: async () => {
|
||||||
let currentConfig: HikariConfig = defaultConfig;
|
const currentConfig = getCurrentConfig();
|
||||||
config.subscribe((c) => (currentConfig = c))();
|
|
||||||
await updateConfig({ streamer_mode: !currentConfig.streamer_mode });
|
await updateConfig({ streamer_mode: !currentConfig.streamer_mode });
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleCompactMode: async () => {
|
toggleCompactMode: async () => {
|
||||||
let currentConfig: HikariConfig = defaultConfig;
|
const currentConfig = getCurrentConfig();
|
||||||
config.subscribe((c) => (currentConfig = c))();
|
|
||||||
await updateConfig({ compact_mode: !currentConfig.compact_mode });
|
await updateConfig({ compact_mode: !currentConfig.compact_mode });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -302,6 +314,10 @@ export const shouldHidePaths = derived(
|
|||||||
configStore.config,
|
configStore.config,
|
||||||
($config) => $config.streamer_mode && $config.streamer_hide_paths
|
($config) => $config.streamer_mode && $config.streamer_hide_paths
|
||||||
);
|
);
|
||||||
|
export const showThinkingBlocks = derived(
|
||||||
|
configStore.config,
|
||||||
|
($config) => $config.show_thinking_blocks
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Masks file paths in text when streamer mode with hide paths is enabled.
|
* Masks file paths in text when streamer mode with hide paths is enabled.
|
||||||
|
|||||||
@@ -523,3 +523,41 @@ describe("pending retry message", () => {
|
|||||||
expect(pendingRetryMessage).toBeNull();
|
expect(pendingRetryMessage).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("draft text persistence", () => {
|
||||||
|
it("initialises draft text as empty string", () => {
|
||||||
|
const conversation = { draftText: "" };
|
||||||
|
expect(conversation.draftText).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores draft text per conversation", () => {
|
||||||
|
const conversations = new Map([
|
||||||
|
["conv-1", { draftText: "Hello world" }],
|
||||||
|
["conv-2", { draftText: "" }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(conversations.get("conv-1")?.draftText).toBe("Hello world");
|
||||||
|
expect(conversations.get("conv-2")?.draftText).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates draft text independently per conversation", () => {
|
||||||
|
const conversations = new Map([
|
||||||
|
["conv-1", { draftText: "Draft A" }],
|
||||||
|
["conv-2", { draftText: "Draft B" }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const convA = conversations.get("conv-1");
|
||||||
|
if (convA) convA.draftText = "Updated A";
|
||||||
|
|
||||||
|
expect(conversations.get("conv-1")?.draftText).toBe("Updated A");
|
||||||
|
expect(conversations.get("conv-2")?.draftText).toBe("Draft B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears draft text after submission", () => {
|
||||||
|
const conversation = { draftText: "My prompt" };
|
||||||
|
|
||||||
|
conversation.draftText = "";
|
||||||
|
|
||||||
|
expect(conversation.draftText).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+115
-11
@@ -10,6 +10,7 @@ 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;
|
||||||
@@ -28,7 +29,7 @@ export interface Conversation {
|
|||||||
characterState: CharacterState;
|
characterState: CharacterState;
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
grantedTools: Set<string>;
|
grantedTools: Set<string>;
|
||||||
pendingPermission: PermissionRequest | null;
|
pendingPermissions: PermissionRequest[];
|
||||||
pendingQuestion: UserQuestionEvent | null;
|
pendingQuestion: UserQuestionEvent | null;
|
||||||
scrollPosition: number;
|
scrollPosition: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -36,6 +37,10 @@ export interface Conversation {
|
|||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
summary: ConversationSummary | null;
|
summary: ConversationSummary | null;
|
||||||
startedAt: Date;
|
startedAt: Date;
|
||||||
|
taskStartTime: number | null;
|
||||||
|
successSoundFired: boolean;
|
||||||
|
taskStartSoundFired: boolean;
|
||||||
|
draftText: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createConversationsStore() {
|
function createConversationsStore() {
|
||||||
@@ -66,7 +71,7 @@ function createConversationsStore() {
|
|||||||
characterState: "idle",
|
characterState: "idle",
|
||||||
isProcessing: false,
|
isProcessing: false,
|
||||||
grantedTools: new Set(),
|
grantedTools: new Set(),
|
||||||
pendingPermission: null,
|
pendingPermissions: [],
|
||||||
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(),
|
||||||
@@ -74,6 +79,10 @@ function createConversationsStore() {
|
|||||||
attachments: [],
|
attachments: [],
|
||||||
summary: null,
|
summary: null,
|
||||||
startedAt: new Date(),
|
startedAt: new Date(),
|
||||||
|
taskStartTime: null,
|
||||||
|
successSoundFired: false,
|
||||||
|
taskStartSoundFired: false,
|
||||||
|
draftText: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +129,11 @@ function createConversationsStore() {
|
|||||||
);
|
);
|
||||||
const pendingPermission = derived(
|
const pendingPermission = derived(
|
||||||
activeConversation,
|
activeConversation,
|
||||||
($conv) => $conv?.pendingPermission || null
|
($conv) => $conv?.pendingPermissions[0] || 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);
|
||||||
@@ -133,6 +146,7 @@ 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 },
|
||||||
@@ -190,7 +204,7 @@ function createConversationsStore() {
|
|||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
const conv = convs.get(activeId);
|
const conv = convs.get(activeId);
|
||||||
if (conv) {
|
if (conv) {
|
||||||
conv.pendingPermission = request;
|
conv.pendingPermissions = [...conv.pendingPermissions, request];
|
||||||
conv.lastActivityAt = new Date();
|
conv.lastActivityAt = new Date();
|
||||||
}
|
}
|
||||||
return convs;
|
return convs;
|
||||||
@@ -203,7 +217,7 @@ function createConversationsStore() {
|
|||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
const conv = convs.get(activeId);
|
const conv = convs.get(activeId);
|
||||||
if (conv) {
|
if (conv) {
|
||||||
conv.pendingPermission = null;
|
conv.pendingPermissions = [];
|
||||||
conv.lastActivityAt = new Date();
|
conv.lastActivityAt = new Date();
|
||||||
}
|
}
|
||||||
return convs;
|
return convs;
|
||||||
@@ -213,7 +227,7 @@ function createConversationsStore() {
|
|||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
const conv = convs.get(conversationId);
|
const conv = convs.get(conversationId);
|
||||||
if (conv) {
|
if (conv) {
|
||||||
conv.pendingPermission = request;
|
conv.pendingPermissions = [...conv.pendingPermissions, request];
|
||||||
conv.lastActivityAt = new Date();
|
conv.lastActivityAt = new Date();
|
||||||
}
|
}
|
||||||
return convs;
|
return convs;
|
||||||
@@ -223,7 +237,30 @@ function createConversationsStore() {
|
|||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
const conv = convs.get(conversationId);
|
const conv = convs.get(conversationId);
|
||||||
if (conv) {
|
if (conv) {
|
||||||
conv.pendingPermission = null;
|
conv.pendingPermissions = [];
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
removePermission: (id: string) => {
|
||||||
|
const activeId = get(activeConversationId);
|
||||||
|
if (!activeId) return;
|
||||||
|
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(activeId);
|
||||||
|
if (conv) {
|
||||||
|
conv.pendingPermissions = conv.pendingPermissions.filter((p) => p.id !== id);
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
removePermissionForConversation: (conversationId: string, id: string) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.pendingPermissions = conv.pendingPermissions.filter((p) => p.id !== id);
|
||||||
conv.lastActivityAt = new Date();
|
conv.lastActivityAt = new Date();
|
||||||
}
|
}
|
||||||
return convs;
|
return convs;
|
||||||
@@ -305,6 +342,10 @@ 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;
|
||||||
@@ -331,9 +372,15 @@ function createConversationsStore() {
|
|||||||
if (currentId !== id) {
|
if (currentId !== id) {
|
||||||
activeConversationId.set(id);
|
activeConversationId.set(id);
|
||||||
|
|
||||||
// Update the global character state to match the conversation's state
|
// Update the global character state to match the conversation's state.
|
||||||
|
// Map success/error → idle since those are transient states that have
|
||||||
|
// already been displayed — restoring them would re-trigger sound rules.
|
||||||
if (targetConv) {
|
if (targetConv) {
|
||||||
characterState.setState(targetConv.characterState);
|
const stateToRestore =
|
||||||
|
targetConv.characterState === "success" || targetConv.characterState === "error"
|
||||||
|
? "idle"
|
||||||
|
: targetConv.characterState;
|
||||||
|
characterState.setState(stateToRestore);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -435,7 +482,8 @@ 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);
|
||||||
@@ -448,6 +496,7 @@ function createConversationsStore() {
|
|||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
toolName,
|
toolName,
|
||||||
cost,
|
cost,
|
||||||
|
parentToolUseId,
|
||||||
};
|
};
|
||||||
|
|
||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
@@ -469,7 +518,8 @@ 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();
|
||||||
|
|
||||||
@@ -480,6 +530,7 @@ function createConversationsStore() {
|
|||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
toolName,
|
toolName,
|
||||||
cost,
|
cost,
|
||||||
|
parentToolUseId,
|
||||||
};
|
};
|
||||||
|
|
||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
@@ -779,6 +830,59 @@ function createConversationsStore() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Sound tracking methods
|
||||||
|
resetSoundState: (conversationId: string) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.taskStartTime = null;
|
||||||
|
conv.successSoundFired = false;
|
||||||
|
conv.taskStartSoundFired = false;
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setTaskStartTime: (conversationId: string, time: number | null) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.taskStartTime = time;
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
markSuccessSoundFired: (conversationId: string) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.successSoundFired = true;
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
markTaskStartSoundFired: (conversationId: string) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.taskStartSoundFired = true;
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setDraftText: (conversationId: string, text: string) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.draftText = text;
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// Add initialization helper
|
// Add initialization helper
|
||||||
initialize: () => {
|
initialize: () => {
|
||||||
ensureInitialized();
|
ensureInitialized();
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { writable, derived } from "svelte/store";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
|
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
id: string;
|
||||||
|
timestamp: Date;
|
||||||
|
level: LogLevel;
|
||||||
|
message: string;
|
||||||
|
source: "frontend" | "backend";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DebugConsoleState {
|
||||||
|
logs: LogEntry[];
|
||||||
|
isOpen: boolean;
|
||||||
|
maxLogs: number;
|
||||||
|
filterLevel: LogLevel | "all";
|
||||||
|
autoScroll: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_LOGS = 1000; // Circular buffer size
|
||||||
|
|
||||||
|
function createDebugConsoleStore() {
|
||||||
|
const { subscribe, update } = writable<DebugConsoleState>({
|
||||||
|
logs: [],
|
||||||
|
isOpen: false,
|
||||||
|
maxLogs: MAX_LOGS,
|
||||||
|
filterLevel: "all",
|
||||||
|
autoScroll: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let logCounter = 0;
|
||||||
|
|
||||||
|
function addLog(level: LogLevel, message: string, source: "frontend" | "backend") {
|
||||||
|
update((state) => {
|
||||||
|
const newLog: LogEntry = {
|
||||||
|
id: `log-${Date.now()}-${logCounter++}`,
|
||||||
|
timestamp: new Date(),
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
source,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedLogs = [...state.logs, newLog];
|
||||||
|
|
||||||
|
// Implement circular buffer - remove oldest if exceeding max
|
||||||
|
if (updatedLogs.length > state.maxLogs) {
|
||||||
|
updatedLogs.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...state, logs: updatedLogs };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override console methods to capture frontend logs
|
||||||
|
const originalConsole = {
|
||||||
|
log: console.log,
|
||||||
|
info: console.info,
|
||||||
|
warn: console.warn,
|
||||||
|
error: console.error,
|
||||||
|
debug: console.debug,
|
||||||
|
};
|
||||||
|
|
||||||
|
function setupConsoleCapture() {
|
||||||
|
console.log = (...args: unknown[]) => {
|
||||||
|
originalConsole.log(...args);
|
||||||
|
addLog("info", args.map((arg) => String(arg)).join(" "), "frontend");
|
||||||
|
};
|
||||||
|
|
||||||
|
console.info = (...args: unknown[]) => {
|
||||||
|
originalConsole.info(...args);
|
||||||
|
addLog("info", args.map((arg) => String(arg)).join(" "), "frontend");
|
||||||
|
};
|
||||||
|
|
||||||
|
console.warn = (...args: unknown[]) => {
|
||||||
|
originalConsole.warn(...args);
|
||||||
|
addLog("warn", args.map((arg) => String(arg)).join(" "), "frontend");
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error = (...args: unknown[]) => {
|
||||||
|
originalConsole.error(...args);
|
||||||
|
addLog("error", args.map((arg) => String(arg)).join(" "), "frontend");
|
||||||
|
};
|
||||||
|
|
||||||
|
console.debug = (...args: unknown[]) => {
|
||||||
|
originalConsole.debug(...args);
|
||||||
|
addLog("debug", args.map((arg) => String(arg)).join(" "), "frontend");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Capture unhandled errors
|
||||||
|
window.addEventListener("error", (event) => {
|
||||||
|
addLog(
|
||||||
|
"error",
|
||||||
|
`[Unhandled Error] ${event.message} at ${event.filename}:${event.lineno}:${event.colno}`,
|
||||||
|
"frontend"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Capture unhandled promise rejections
|
||||||
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
|
addLog("error", `[Unhandled Promise Rejection] ${event.reason}`, "frontend");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreConsole() {
|
||||||
|
console.log = originalConsole.log;
|
||||||
|
console.info = originalConsole.info;
|
||||||
|
console.warn = originalConsole.warn;
|
||||||
|
console.error = originalConsole.error;
|
||||||
|
console.debug = originalConsole.debug;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for backend logs
|
||||||
|
async function setupBackendLogsListener() {
|
||||||
|
await listen<{ level: LogLevel; message: string }>("debug:log", (event) => {
|
||||||
|
addLog(event.payload.level, event.payload.message, "backend");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
toggle: () => update((state) => ({ ...state, isOpen: !state.isOpen })),
|
||||||
|
open: () => update((state) => ({ ...state, isOpen: true })),
|
||||||
|
close: () => update((state) => ({ ...state, isOpen: false })),
|
||||||
|
clear: () => update((state) => ({ ...state, logs: [] })),
|
||||||
|
setFilterLevel: (level: LogLevel | "all") =>
|
||||||
|
update((state) => ({ ...state, filterLevel: level })),
|
||||||
|
setAutoScroll: (enabled: boolean) => update((state) => ({ ...state, autoScroll: enabled })),
|
||||||
|
setupConsoleCapture,
|
||||||
|
restoreConsole,
|
||||||
|
setupBackendLogsListener,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const debugConsoleStore = createDebugConsoleStore();
|
||||||
|
|
||||||
|
// Derived store for filtered logs
|
||||||
|
export const filteredLogs = derived(debugConsoleStore, ($debugConsole) => {
|
||||||
|
if ($debugConsole.filterLevel === "all") {
|
||||||
|
return $debugConsole.logs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelPriority: Record<LogLevel, number> = {
|
||||||
|
debug: 0,
|
||||||
|
info: 1,
|
||||||
|
warn: 2,
|
||||||
|
error: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const minPriority = levelPriority[$debugConsole.filterLevel];
|
||||||
|
|
||||||
|
return $debugConsole.logs.filter((log) => levelPriority[log.level] >= minPriority);
|
||||||
|
});
|
||||||
@@ -285,6 +285,73 @@ 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", () => {
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ 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.5)
|
// Current generation (Claude 4.6)
|
||||||
|
"claude-opus-4-6": { input: 5.0, output: 25.0 },
|
||||||
|
"claude-sonnet-4-6": { input: 3.0, output: 15.0 },
|
||||||
|
// Previous generation (Claude 4.5)
|
||||||
"claude-opus-4-5-20251101": { input: 5.0, output: 25.0 },
|
"claude-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 },
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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([]),
|
||||||
|
};
|
||||||
+236
-48
@@ -12,12 +12,16 @@ 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,
|
||||||
handleConnectionStatusChange,
|
handleConnectionStatusChange,
|
||||||
handleNewUserMessage,
|
handleNewUserMessage,
|
||||||
} from "$lib/notifications/rules";
|
} from "$lib/notifications/rules";
|
||||||
|
import { notificationManager } from "$lib/notifications/notificationManager";
|
||||||
|
|
||||||
interface StateChangePayload {
|
interface StateChangePayload {
|
||||||
state: CharacterState;
|
state: CharacterState;
|
||||||
@@ -26,6 +30,7 @@ interface StateChangePayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const connectedConversations = new Set<string>();
|
const connectedConversations = new Set<string>();
|
||||||
|
const greetingPendingConversations = new Set<string>();
|
||||||
let unlisteners: Array<() => void> = [];
|
let unlisteners: Array<() => void> = [];
|
||||||
let skipNextGreeting = false;
|
let skipNextGreeting = false;
|
||||||
|
|
||||||
@@ -52,17 +57,17 @@ function generateGreetingPrompt(): string {
|
|||||||
return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`;
|
return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendGreeting(conversationId: string) {
|
async function sendGreeting(conversationId: string): Promise<boolean> {
|
||||||
// Check if we should skip this greeting
|
// Check if we should skip this greeting
|
||||||
if (skipNextGreeting) {
|
if (skipNextGreeting) {
|
||||||
skipNextGreeting = false; // Reset the flag
|
skipNextGreeting = false; // Reset the flag
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = configStore.getConfig();
|
const config = configStore.getConfig();
|
||||||
|
|
||||||
if (!config.greeting_enabled) {
|
if (!config.greeting_enabled) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const greetingPrompt = config.greeting_custom_prompt?.trim() || generateGreetingPrompt();
|
const greetingPrompt = config.greeting_custom_prompt?.trim() || generateGreetingPrompt();
|
||||||
@@ -78,10 +83,12 @@ async function sendGreeting(conversationId: string) {
|
|||||||
conversationId,
|
conversationId,
|
||||||
message: greetingPrompt,
|
message: greetingPrompt,
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send greeting:", error);
|
console.error("Failed to send greeting:", error);
|
||||||
claudeStore.addLineToConversation(conversationId, "error", `Failed to send greeting: ${error}`);
|
claudeStore.addLineToConversation(conversationId, "error", `Failed to send greeting: ${error}`);
|
||||||
characterState.setTemporaryState("error", 3000);
|
characterState.setTemporaryState("error", 3000);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +102,7 @@ interface OutputPayload {
|
|||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
cost_usd: number;
|
cost_usd: number;
|
||||||
};
|
};
|
||||||
|
parent_tool_use_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConnectionPayload {
|
interface ConnectionPayload {
|
||||||
@@ -114,6 +122,7 @@ interface WorkingDirectoryPayload {
|
|||||||
|
|
||||||
export async function cleanupConversationTracking(conversationId: string) {
|
export async function cleanupConversationTracking(conversationId: string) {
|
||||||
connectedConversations.delete(conversationId);
|
connectedConversations.delete(conversationId);
|
||||||
|
greetingPendingConversations.delete(conversationId);
|
||||||
|
|
||||||
// Clean up any temp files associated with this conversation
|
// Clean up any temp files associated with this conversation
|
||||||
try {
|
try {
|
||||||
@@ -169,15 +178,42 @@ export async function initializeTauriListeners() {
|
|||||||
if (!connectedConversations.has(targetConversationId)) {
|
if (!connectedConversations.has(targetConversationId)) {
|
||||||
connectedConversations.add(targetConversationId);
|
connectedConversations.add(targetConversationId);
|
||||||
resetSessionStats(); // Reset session stats on new connection
|
resetSessionStats(); // Reset session stats on new connection
|
||||||
await sendGreeting(targetConversationId);
|
|
||||||
|
// Immediately hold the tab at yellow while we wait for the greeting response.
|
||||||
|
// This avoids a brief green flash before the greeting is even sent.
|
||||||
|
greetingPendingConversations.add(targetConversationId);
|
||||||
|
claudeStore.setConnectionStatusForConversation(
|
||||||
|
targetConversationId,
|
||||||
|
"connecting" as ConnectionStatus
|
||||||
|
);
|
||||||
|
|
||||||
|
const greetingSent = await sendGreeting(targetConversationId);
|
||||||
|
if (!greetingSent) {
|
||||||
|
// Greeting was disabled or failed — flip straight to connected.
|
||||||
|
greetingPendingConversations.delete(targetConversationId);
|
||||||
|
claudeStore.setConnectionStatusForConversation(
|
||||||
|
targetConversationId,
|
||||||
|
"connected" as ConnectionStatus
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} 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);
|
||||||
|
greetingPendingConversations.delete(targetConversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't add system message if we're about to reconnect
|
// Don't add system message if we're about to reconnect
|
||||||
@@ -185,7 +221,18 @@ export async function initializeTauriListeners() {
|
|||||||
claudeStore.addLineToConversation(
|
claudeStore.addLineToConversation(
|
||||||
targetConversationId,
|
targetConversationId,
|
||||||
"system",
|
"system",
|
||||||
"Disconnected from Claude Code"
|
"Disconnected from Claude Code unexpectedly — the process may have crashed or been stopped by the system"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear todos on real disconnect (not on reconnects for permissions)
|
||||||
|
todos.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the tab's connection status on real disconnects
|
||||||
|
if (!skipNextGreeting && targetConversationId) {
|
||||||
|
claudeStore.setConnectionStatusForConversation(
|
||||||
|
targetConversationId,
|
||||||
|
"disconnected" as ConnectionStatus
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +245,7 @@ export async function initializeTauriListeners() {
|
|||||||
|
|
||||||
if (targetConversationId) {
|
if (targetConversationId) {
|
||||||
connectedConversations.delete(targetConversationId);
|
connectedConversations.delete(targetConversationId);
|
||||||
|
greetingPendingConversations.delete(targetConversationId);
|
||||||
claudeStore.addLineToConversation(targetConversationId, "error", "Connection error");
|
claudeStore.addLineToConversation(targetConversationId, "error", "Connection error");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +271,63 @@ export async function initializeTauriListeners() {
|
|||||||
|
|
||||||
const mappedState = stateMap[state.toLowerCase()] || "idle";
|
const mappedState = stateMap[state.toLowerCase()] || "idle";
|
||||||
|
|
||||||
|
// Per-conversation sound tracking — fires for any tab (active or background).
|
||||||
|
// All sounds are driven from state-change events rather than a global store
|
||||||
|
// subscription, so background tabs receive their sounds correctly and
|
||||||
|
// switching tabs never replays a sound that has already fired.
|
||||||
|
const resolvedConversationId = conversation_id || get(claudeStore.activeConversationId) || null;
|
||||||
|
if (resolvedConversationId) {
|
||||||
|
const conv = get(claudeStore.conversations).get(resolvedConversationId);
|
||||||
|
if (conv) {
|
||||||
|
const previousState = conv.characterState;
|
||||||
|
|
||||||
|
// New response starting — clear all per-task sound flags.
|
||||||
|
if (mappedState === "thinking") {
|
||||||
|
claudeStore.resetSoundState(resolvedConversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record when a long-running phase begins (used for the 2-second
|
||||||
|
// minimum duration check before playing the completion sound).
|
||||||
|
if (
|
||||||
|
(mappedState === "coding" || mappedState === "searching") &&
|
||||||
|
previousState !== "coding" &&
|
||||||
|
previousState !== "searching"
|
||||||
|
) {
|
||||||
|
claudeStore.setTaskStartTime(resolvedConversationId, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task-start sound — fires once when work enters a long-running phase.
|
||||||
|
if (
|
||||||
|
(mappedState === "coding" || mappedState === "searching") &&
|
||||||
|
previousState !== "coding" &&
|
||||||
|
previousState !== "searching" &&
|
||||||
|
!conv.taskStartSoundFired
|
||||||
|
) {
|
||||||
|
notificationManager.notifyTaskStart();
|
||||||
|
claudeStore.markTaskStartSoundFired(resolvedConversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error sound — fires each time a new error state is entered.
|
||||||
|
if (mappedState === "error" && previousState !== "error") {
|
||||||
|
notificationManager.notifyError();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission sound — fires each time a permission request arrives.
|
||||||
|
if (mappedState === "permission") {
|
||||||
|
notificationManager.notifyPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completion sound — fires once per task after sufficient duration.
|
||||||
|
if (mappedState === "success" && !conv.successSoundFired) {
|
||||||
|
const duration = conv.taskStartTime ? Date.now() - conv.taskStartTime : 0;
|
||||||
|
if (duration > 2000) {
|
||||||
|
notificationManager.notifySuccess();
|
||||||
|
}
|
||||||
|
claudeStore.markSuccessSoundFired(resolvedConversationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Always update the conversation's state
|
// Always update the conversation's state
|
||||||
if (conversation_id) {
|
if (conversation_id) {
|
||||||
claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
|
claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
|
||||||
@@ -247,7 +352,8 @@ 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 } = event.payload;
|
const { line_type, content, tool_name, conversation_id, cost, parent_tool_use_id } =
|
||||||
|
event.payload;
|
||||||
|
|
||||||
// Convert snake_case cost to camelCase for TypeScript
|
// Convert snake_case cost to camelCase for TypeScript
|
||||||
const costData = cost
|
const costData = cost
|
||||||
@@ -258,22 +364,57 @@ export async function initializeTauriListeners() {
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Flip to connected when first assistant message arrives after greeting
|
||||||
|
if (
|
||||||
|
conversation_id &&
|
||||||
|
line_type === "assistant" &&
|
||||||
|
greetingPendingConversations.has(conversation_id)
|
||||||
|
) {
|
||||||
|
greetingPendingConversations.delete(conversation_id);
|
||||||
|
claudeStore.setConnectionStatusForConversation(
|
||||||
|
conversation_id,
|
||||||
|
"connected" as ConnectionStatus
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Always store the output to the correct conversation
|
// Always store the output to the correct conversation
|
||||||
if (conversation_id) {
|
if (conversation_id) {
|
||||||
claudeStore.addLineToConversation(
|
claudeStore.addLineToConversation(
|
||||||
conversation_id,
|
conversation_id,
|
||||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
line_type as
|
||||||
|
| "user"
|
||||||
|
| "assistant"
|
||||||
|
| "system"
|
||||||
|
| "tool"
|
||||||
|
| "error"
|
||||||
|
| "thinking"
|
||||||
|
| "rate-limit"
|
||||||
|
| "compact-prompt"
|
||||||
|
| "worktree"
|
||||||
|
| "config-change",
|
||||||
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",
|
line_type as
|
||||||
|
| "user"
|
||||||
|
| "assistant"
|
||||||
|
| "system"
|
||||||
|
| "tool"
|
||||||
|
| "error"
|
||||||
|
| "thinking"
|
||||||
|
| "rate-limit"
|
||||||
|
| "compact-prompt"
|
||||||
|
| "worktree"
|
||||||
|
| "config-change",
|
||||||
content,
|
content,
|
||||||
tool_name || undefined,
|
tool_name || undefined,
|
||||||
costData
|
costData,
|
||||||
|
parent_tool_use_id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -316,35 +457,96 @@ 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 { id, tool_name, tool_input, description, conversation_id } = event.payload;
|
const { permissions, conversation_id } = event.payload;
|
||||||
|
|
||||||
// Store permission request for the specific conversation
|
console.log(
|
||||||
if (conversation_id) {
|
`[Permission] Event received: ${permissions.length} permission(s) for conversation ${conversation_id || "active"}`,
|
||||||
claudeStore.requestPermissionForConversation(conversation_id, {
|
{ permissions, conversation_id }
|
||||||
id,
|
);
|
||||||
tool: tool_name,
|
|
||||||
description,
|
// Store each permission request for the specific conversation
|
||||||
input: tool_input,
|
for (const permission of permissions) {
|
||||||
});
|
const { id, tool_name, tool_input, description } = permission;
|
||||||
claudeStore.addLineToConversation(
|
|
||||||
conversation_id,
|
if (conversation_id) {
|
||||||
"system",
|
claudeStore.requestPermissionForConversation(conversation_id, {
|
||||||
`Permission requested for: ${tool_name}`
|
id,
|
||||||
);
|
tool: tool_name,
|
||||||
} else {
|
description,
|
||||||
// Fallback to active conversation if no conversation_id
|
input: tool_input,
|
||||||
claudeStore.requestPermission({
|
});
|
||||||
id,
|
claudeStore.addLineToConversation(
|
||||||
tool: tool_name,
|
conversation_id,
|
||||||
description,
|
"system",
|
||||||
input: tool_input,
|
`Permission requested for: ${tool_name}`
|
||||||
});
|
);
|
||||||
claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
|
} else {
|
||||||
|
// Fallback to active conversation if no conversation_id
|
||||||
|
claudeStore.requestPermission({
|
||||||
|
id,
|
||||||
|
tool: tool_name,
|
||||||
|
description,
|
||||||
|
input: tool_input,
|
||||||
|
});
|
||||||
|
claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
unlisteners.push(permissionUnlisten);
|
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, last_assistant_message } =
|
||||||
|
event.payload;
|
||||||
|
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||||
|
if (targetConversationId) {
|
||||||
|
agentStore.endAgent(
|
||||||
|
targetConversationId,
|
||||||
|
tool_use_id,
|
||||||
|
ended_at,
|
||||||
|
is_error,
|
||||||
|
last_assistant_message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -382,10 +584,6 @@ 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,
|
||||||
@@ -398,23 +596,13 @@ 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 {
|
||||||
await invoke("log_discord_rpc", {
|
console.log("Discord RPC is disabled in config, skipping initialization");
|
||||||
message: "[FRONTEND] Discord RPC is disabled in config, skipping initialization",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export type AgentStatus = "running" | "completed" | "errored";
|
||||||
|
|
||||||
|
export interface AgentInfo {
|
||||||
|
toolUseId: string;
|
||||||
|
agentId?: string;
|
||||||
|
description: string;
|
||||||
|
subagentType: string;
|
||||||
|
startedAt: number;
|
||||||
|
endedAt?: number;
|
||||||
|
status: AgentStatus;
|
||||||
|
parentToolUseId?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
characterName: string;
|
||||||
|
characterAvatar: string;
|
||||||
|
lastAssistantMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentStartPayload {
|
||||||
|
tool_use_id: string;
|
||||||
|
agent_id?: string;
|
||||||
|
description: string;
|
||||||
|
subagent_type: string;
|
||||||
|
started_at: number;
|
||||||
|
conversation_id?: string;
|
||||||
|
parent_tool_use_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentEndPayload {
|
||||||
|
tool_use_id: string;
|
||||||
|
ended_at: number;
|
||||||
|
is_error: boolean;
|
||||||
|
conversation_id?: string;
|
||||||
|
duration_ms?: number;
|
||||||
|
num_turns?: number;
|
||||||
|
last_assistant_message?: string;
|
||||||
|
}
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
export interface TerminalLine {
|
export interface TerminalLine {
|
||||||
id: string;
|
id: string;
|
||||||
type: "user" | "assistant" | "system" | "tool" | "error";
|
type:
|
||||||
|
| "user"
|
||||||
|
| "assistant"
|
||||||
|
| "system"
|
||||||
|
| "tool"
|
||||||
|
| "error"
|
||||||
|
| "thinking"
|
||||||
|
| "rate-limit"
|
||||||
|
| "compact-prompt"
|
||||||
|
| "worktree"
|
||||||
|
| "config-change";
|
||||||
content: string;
|
content: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
@@ -10,6 +20,8 @@ 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 {
|
||||||
@@ -124,11 +136,15 @@ export interface PermissionRequest {
|
|||||||
input: Record<string, unknown>;
|
input: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PermissionPromptEvent {
|
export interface PermissionPromptEventItem {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +174,13 @@ export interface Attachment {
|
|||||||
previewUrl?: string; // For images, a data URL or object URL for preview
|
previewUrl?: string; // For images, a data URL or object URL for preview
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceHookInfo {
|
||||||
|
has_concerns: boolean;
|
||||||
|
hook_types: string[];
|
||||||
|
mcp_servers: string[];
|
||||||
|
custom_commands: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateInfo {
|
export interface UpdateInfo {
|
||||||
current_version: string;
|
current_version: string;
|
||||||
latest_version: string;
|
latest_version: string;
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { CHARACTER_POOL, assignCharacter } from "./agentCharacters";
|
||||||
|
|
||||||
|
describe("agentCharacters", () => {
|
||||||
|
describe("CHARACTER_POOL", () => {
|
||||||
|
it("contains exactly 6 characters", () => {
|
||||||
|
expect(CHARACTER_POOL).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("each character has a name, avatar, title, and description", () => {
|
||||||
|
for (const character of CHARACTER_POOL) {
|
||||||
|
expect(character.name).toBeTruthy();
|
||||||
|
expect(character.avatar).toBeTruthy();
|
||||||
|
expect(character.avatar).toMatch(/^https:\/\//u);
|
||||||
|
expect(character.title).toBeTruthy();
|
||||||
|
expect(character.description).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all names are unique", () => {
|
||||||
|
const names = CHARACTER_POOL.map((c) => c.name);
|
||||||
|
const uniqueNames = new Set(names);
|
||||||
|
expect(uniqueNames.size).toBe(CHARACTER_POOL.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("assignCharacter", () => {
|
||||||
|
it("returns a character from the pool", () => {
|
||||||
|
const character = assignCharacter([]);
|
||||||
|
const names = CHARACTER_POOL.map((c) => c.name);
|
||||||
|
expect(names).toContain(character.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("avoids names already in use when possible", () => {
|
||||||
|
const takenNames = ["Amari", "Keiko", "Minori", "Reina", "Tatsumi"];
|
||||||
|
// Run many times to confirm we never get a taken name
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const character = assignCharacter(takenNames);
|
||||||
|
expect(takenNames).not.toContain(character.name);
|
||||||
|
expect(character.name).toBe("Yumiko");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks from the full pool when all 6 names are taken", () => {
|
||||||
|
const allNames = CHARACTER_POOL.map((c) => c.name);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
// Run enough times that we'd statistically see variety
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const character = assignCharacter(allNames);
|
||||||
|
seen.add(character.name);
|
||||||
|
}
|
||||||
|
// Should still pick valid characters
|
||||||
|
for (const name of seen) {
|
||||||
|
expect(allNames).toContain(name);
|
||||||
|
}
|
||||||
|
// With 100 runs and 6 characters, we should see at least 2 distinct names
|
||||||
|
expect(seen.size).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a character with name, avatar, title, and description", () => {
|
||||||
|
const character = assignCharacter([]);
|
||||||
|
expect(character.name).toBeTruthy();
|
||||||
|
expect(character.avatar).toBeTruthy();
|
||||||
|
expect(character.title).toBeTruthy();
|
||||||
|
expect(character.description).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works when the active list is empty", () => {
|
||||||
|
const character = assignCharacter([]);
|
||||||
|
expect(character).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export interface AgentCharacter {
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHARACTER_POOL: readonly AgentCharacter[] = [
|
||||||
|
{
|
||||||
|
name: "Amari",
|
||||||
|
avatar: "https://cdn.nhcarrigan.com/amari.png",
|
||||||
|
title: "Executive Assistant",
|
||||||
|
description:
|
||||||
|
"Fey-blooded PA and healer of the team. She always knows when you need a break — and makes sure you take one.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Keiko",
|
||||||
|
avatar: "https://cdn.nhcarrigan.com/keiko.png",
|
||||||
|
title: "Chief Security Officer",
|
||||||
|
description:
|
||||||
|
"Bodyguard and shadow of the family. Conceals blades beneath evening gowns; always watching from the dark.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Minori",
|
||||||
|
avatar: "https://cdn.nhcarrigan.com/minori.png",
|
||||||
|
title: "Chief Compliance Officer",
|
||||||
|
description:
|
||||||
|
"An ancient Automaton built to guard the Great Library. Perfect memory, perfect logic, perfect dedication.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Reina",
|
||||||
|
avatar: "https://cdn.nhcarrigan.com/reina.png",
|
||||||
|
title: "Chief Legal Officer",
|
||||||
|
description:
|
||||||
|
"Demon of the Crossroads turned corporate lawyer. Her binding contracts have held for millennia.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Tatsumi",
|
||||||
|
avatar: "https://cdn.nhcarrigan.com/tatsumi.png",
|
||||||
|
title: "Chief Design Officer",
|
||||||
|
description:
|
||||||
|
"A Siren who traded the ocean for a stylus. Uses her glamour to make every interface welcoming and beautiful.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Yumiko",
|
||||||
|
avatar: "https://cdn.nhcarrigan.com/yumiko.png",
|
||||||
|
title: "Chief Technology Officer",
|
||||||
|
description:
|
||||||
|
"Technomancer and machine whisperer. She communes with machine spirits and keeps the digital world running.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks a character for a new subagent.
|
||||||
|
* Avoids names already assigned to active agents unless all six are taken.
|
||||||
|
*/
|
||||||
|
export function assignCharacter(activeNames: readonly string[]): AgentCharacter {
|
||||||
|
const available = CHARACTER_POOL.filter((c) => !activeNames.includes(c.name));
|
||||||
|
const pool = available.length > 0 ? available : [...CHARACTER_POOL];
|
||||||
|
return pool[Math.floor(Math.random() * pool.length)];
|
||||||
|
}
|
||||||
@@ -135,6 +135,151 @@ 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", () => {
|
||||||
@@ -192,6 +337,36 @@ 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", () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<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();
|
||||||
|
|
||||||
@@ -14,4 +15,5 @@
|
|||||||
|
|
||||||
<div id="app">
|
<div id="app">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
<DebugConsole />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+143
-4
@@ -1,6 +1,7 @@
|
|||||||
<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,
|
||||||
@@ -8,8 +9,10 @@
|
|||||||
initializeDiscordRpc,
|
initializeDiscordRpc,
|
||||||
stopDiscordRpc,
|
stopDiscordRpc,
|
||||||
updateDiscordRpc,
|
updateDiscordRpc,
|
||||||
|
setSkipNextGreeting,
|
||||||
} from "$lib/tauri";
|
} from "$lib/tauri";
|
||||||
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
|
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
|
||||||
|
import { readFile } from "@tauri-apps/plugin-fs";
|
||||||
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
|
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
|
||||||
import { conversationsStore } from "$lib/stores/conversations";
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||||
@@ -30,12 +33,57 @@
|
|||||||
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 backgroundDataUrl = $state<string | null>(null);
|
||||||
|
let backgroundOpacity = $state(0.3);
|
||||||
|
|
||||||
|
const configValues = configStore.config;
|
||||||
|
$effect(() => {
|
||||||
|
const cfg = $configValues;
|
||||||
|
backgroundOpacity = cfg.background_image_opacity;
|
||||||
|
if (cfg.background_image_path) {
|
||||||
|
void loadBackgroundImage(cfg.background_image_path);
|
||||||
|
} else {
|
||||||
|
backgroundDataUrl = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadBackgroundImage(path: string) {
|
||||||
|
try {
|
||||||
|
const data = await readFile(path);
|
||||||
|
const chunks: string[] = [];
|
||||||
|
const chunkSize = 8192;
|
||||||
|
for (let i = 0; i < data.length; i += chunkSize) {
|
||||||
|
chunks.push(String.fromCharCode(...data.slice(i, i + chunkSize)));
|
||||||
|
}
|
||||||
|
const ext = path.split(".").pop()?.toLowerCase() ?? "png";
|
||||||
|
const mimeMap: Record<string, string> = {
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
png: "image/png",
|
||||||
|
webp: "image/webp",
|
||||||
|
gif: "image/gif",
|
||||||
|
avif: "image/avif",
|
||||||
|
};
|
||||||
|
const mime = mimeMap[ext] ?? "image/png";
|
||||||
|
backgroundDataUrl = `data:${mime};base64,${btoa(chunks.join(""))}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load background image:", error);
|
||||||
|
backgroundDataUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -229,6 +277,13 @@
|
|||||||
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();
|
||||||
@@ -278,8 +333,11 @@
|
|||||||
const conversationId = get(claudeStore.activeConversationId);
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
if (!conversationId) return;
|
if (!conversationId) return;
|
||||||
|
|
||||||
|
// Set flag to preserve stats/permissions (don't treat next connect as new session)
|
||||||
|
setSkipNextGreeting(true);
|
||||||
|
|
||||||
await invoke("interrupt_claude", { conversationId });
|
await invoke("interrupt_claude", { conversationId });
|
||||||
claudeStore.addLine("system", "Process interrupted");
|
claudeStore.addLine("system", "Process interrupted by keyboard shortcut (Ctrl+C)");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to interrupt:", error);
|
console.error("Failed to interrupt:", error);
|
||||||
}
|
}
|
||||||
@@ -338,6 +396,50 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -383,6 +485,19 @@
|
|||||||
|
|
||||||
// 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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -390,6 +505,7 @@
|
|||||||
if (initialized) {
|
if (initialized) {
|
||||||
cleanupTauriListeners();
|
cleanupTauriListeners();
|
||||||
cleanupNotificationSync();
|
cleanupNotificationSync();
|
||||||
|
cleanupTodoListener();
|
||||||
stopDiscordRpc();
|
stopDiscordRpc();
|
||||||
window.removeEventListener("keydown", handleGlobalKeydown);
|
window.removeEventListener("keydown", handleGlobalKeydown);
|
||||||
initialized = false;
|
initialized = false;
|
||||||
@@ -397,16 +513,27 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if backgroundDataUrl}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-cover bg-center pointer-events-none"
|
||||||
|
style="background-image: url('{backgroundDataUrl}'); opacity: {backgroundOpacity}; z-index: 0;"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if compactModeActive}
|
{#if compactModeActive}
|
||||||
<!-- Compact mode: minimal widget interface -->
|
<!-- Compact mode: minimal widget interface -->
|
||||||
<div
|
<div
|
||||||
class="app-container compact-app h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"
|
class="app-container compact-app h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"
|
||||||
|
style={backgroundDataUrl ? "background: transparent;" : ""}
|
||||||
>
|
>
|
||||||
<CompactMode onExpand={exitCompactMode} />
|
<CompactMode onExpand={exitCompactMode} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Full mode: standard interface -->
|
<!-- Full mode: standard interface -->
|
||||||
<div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden">
|
<div
|
||||||
|
class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"
|
||||||
|
style={backgroundDataUrl ? "background: transparent;" : ""}
|
||||||
|
>
|
||||||
<StatusBar
|
<StatusBar
|
||||||
onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)}
|
onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)}
|
||||||
onToggleCompact={enterCompactMode}
|
onToggleCompact={enterCompactMode}
|
||||||
@@ -415,8 +542,12 @@
|
|||||||
<main class="flex-1 flex overflow-hidden">
|
<main class="flex-1 flex overflow-hidden">
|
||||||
<!-- Left panel: Character display -->
|
<!-- Left panel: Character display -->
|
||||||
<div
|
<div
|
||||||
class="character-panel {getPanelGlowClass()} flex flex-col items-center justify-center bg-[var(--bg-secondary)]/50"
|
class="character-panel {getPanelGlowClass()} flex flex-col items-center justify-center {backgroundDataUrl
|
||||||
style="width: {panelWidth}px; min-width: {MIN_PANEL_WIDTH}px; max-width: {MAX_PANEL_WIDTH}px;"
|
? ''
|
||||||
|
: 'bg-[var(--bg-secondary)]/50'}"
|
||||||
|
style="width: {panelWidth}px; min-width: {MIN_PANEL_WIDTH}px; max-width: {MAX_PANEL_WIDTH}px;{backgroundDataUrl
|
||||||
|
? ' background: transparent !important;'
|
||||||
|
: ''}"
|
||||||
>
|
>
|
||||||
<AnimeGirl />
|
<AnimeGirl />
|
||||||
</div>
|
</div>
|
||||||
@@ -443,12 +574,20 @@
|
|||||||
<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}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,20 @@ import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
|||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
preprocess: vitePreprocess(),
|
preprocess: vitePreprocess(),
|
||||||
|
// Suppress specific build-time warnings that are intentional patterns:
|
||||||
|
// - a11y_click_events_have_key_events: all overlay/context-menu divs use svelte:window handlers
|
||||||
|
// - state_referenced_locally: InputDialog intentionally captures the initial prop value
|
||||||
|
onwarn: (warning, handler) => {
|
||||||
|
if (
|
||||||
|
warning.code === "a11y_click_events_have_key_events" ||
|
||||||
|
warning.code === "state_referenced_locally" ||
|
||||||
|
// SvelteSet is already reactive; $state wrapping is unnecessary per ESLint,
|
||||||
|
// but vite-plugin-svelte incorrectly fires non_reactive_update on SvelteSet mutations
|
||||||
|
warning.code === "non_reactive_update"
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
handler(warning);
|
||||||
|
},
|
||||||
kit: {
|
kit: {
|
||||||
adapter: adapter({
|
adapter: adapter({
|
||||||
fallback: "index.html",
|
fallback: "index.html",
|
||||||
|
|||||||
+15
-1
@@ -1,13 +1,27 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig, createLogger } from "vite";
|
||||||
import { sveltekit } from "@sveltejs/kit/vite";
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
// @ts-expect-error process is a nodejs global
|
// @ts-expect-error process is a nodejs global
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
// SvelteKit passes codeSplitting to Rollup 4 which no longer recognises it — harmless
|
||||||
|
const logger = createLogger();
|
||||||
|
const baseWarn = logger.warn.bind(logger);
|
||||||
|
logger.warn = (/** @type {string} */ msg, /** @type {any} */ options) => {
|
||||||
|
// SvelteKit passes codeSplitting to Rollup 4 which no longer recognises it
|
||||||
|
if (msg.includes("codeSplitting")) return;
|
||||||
|
// Large chunks are fine for a desktop app — no network penalty
|
||||||
|
if (msg.includes("chunks are larger than")) return;
|
||||||
|
// Dynamic/static import mix in CodeMirror — harmless, module stays in main chunk
|
||||||
|
if (msg.includes("dynamically imported by") && msg.includes("codemirror")) return;
|
||||||
|
baseWarn(msg, options);
|
||||||
|
};
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig(async () => ({
|
export default defineConfig(async () => ({
|
||||||
plugins: [tailwindcss(), sveltekit()],
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
customLogger: logger,
|
||||||
|
|
||||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user