Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
a690a4969b
|
|||
| 2816e33257 | |||
|
ff0ba7b6d0
|
|||
| e6e9f7ae59 | |||
| 1ae440659c | |||
|
9af61a4a29
|
|||
| fa906684c2 | |||
|
97b8243d24
|
|||
| 7ebd9dc97a | |||
|
fe7027c585
|
|||
| 89a0bdd8f1 | |||
|
2e3f203508
|
|||
| b745100bd5 | |||
|
1bb7eb4d26
|
|||
| a4e6788573 | |||
|
d2e0915a75
|
|||
| d8cf5504d6 | |||
|
bd3438c7be
|
|||
| 778e016bf5 | |||
| 0ea7861047 | |||
| 381bc8410a | |||
|
fdb356a62c
|
|||
| f173892aaa | |||
|
34e9af57f0
|
|||
| bf411adeb7 | |||
| 97a93c31c2 | |||
| 3e7cb7ef60 | |||
| 136f95cd1a | |||
|
6a12a7a34d
|
|||
| 479652b69e | |||
| a72f2afaff | |||
|
e4288248b1
|
|||
| 1c45507cdf | |||
|
daedbfd865
|
|||
| 7093e58fe4 | |||
|
cab759ec61
|
|||
| e45a1a1c98 | |||
| edc863e020 | |||
| b006f571bf | |||
| ea3cc8b26c | |||
| 2bb541fba6 |
@@ -11,3 +11,6 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
# Coverage reports
|
# Coverage reports
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
|
# PRD task files (user-generated data, not source code)
|
||||||
|
hikari-tasks.json
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# Hikari Desktop - Project Instructions
|
||||||
|
|
||||||
|
## Repository Information
|
||||||
|
|
||||||
|
This project is hosted on both GitHub and Gitea:
|
||||||
|
|
||||||
|
- **GitHub**: `naomi-lgbt/hikari-desktop` (public mirror)
|
||||||
|
- **Gitea**: `nhcarrigan/hikari-desktop` (primary development)
|
||||||
|
|
||||||
|
## MCP Server Usage
|
||||||
|
|
||||||
|
When working with issues, pull requests, or other repository operations for this project:
|
||||||
|
|
||||||
|
- **Use `gitea-hikari` MCP server** - This allows Hikari to act as herself
|
||||||
|
- **Target repository**: `nhcarrigan/hikari-desktop`
|
||||||
|
- **Gitea instance**: `git.nhcarrigan.com`
|
||||||
|
|
||||||
|
## Git Commits
|
||||||
|
|
||||||
|
When asked to commit changes for this project:
|
||||||
|
|
||||||
|
- **Always commit as Hikari** using: `--author="Hikari <hikari@nhcarrigan.com>"`
|
||||||
|
- **Always sign commits** with Hikari's GPG key: `--gpg-sign=5380E4EE7307C808`
|
||||||
|
- **Never add `Co-Authored-By` lines** for Gitea commits
|
||||||
|
- **Always ask for confirmation** before committing
|
||||||
|
- **Always ask for confirmation** before pushing
|
||||||
|
|
||||||
|
Example commit command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit --author="Hikari <hikari@nhcarrigan.com>" --gpg-sign=5380E4EE7307C808 -m "your commit message"
|
||||||
|
```
|
||||||
|
|
||||||
|
Example push command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push https://hikari:TOKEN@git.nhcarrigan.com/nhcarrigan/hikari-desktop.git <branch>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
**All changes MUST include tests.** This is non-negotiable — no feature, bug fix, or refactor should be committed without corresponding test coverage. If a change cannot be tested (e.g. pure UI layout, Tauri IPC calls that are impossible to mock), document why in a comment.
|
||||||
|
|
||||||
|
- **Frontend tests**: Use Vitest with `@testing-library/svelte` for component tests
|
||||||
|
- **Test files**: Place test files next to the code they test with `.test.ts` or `.spec.ts` extension
|
||||||
|
- **Run tests**: Use `pnpm test` to run all tests, or `pnpm test:watch` for watch mode
|
||||||
|
- **Coverage**: Run `pnpm test:coverage` to generate coverage reports
|
||||||
|
- **Rust tests**: Use `pnpm test:backend` for Rust/Tauri backend tests
|
||||||
|
|
||||||
|
### Testing Guidelines
|
||||||
|
|
||||||
|
- Write tests for utility functions, stores, and business logic
|
||||||
|
- For Svelte 5 components, focus on testing the underlying logic functions
|
||||||
|
- Use descriptive test names that explain what behaviour is being tested
|
||||||
|
- Include edge cases and error conditions in test coverage
|
||||||
|
- Mock Tauri APIs using the patterns in `vitest.setup.ts`
|
||||||
|
- **Coverage Goal**: Maintain as close to 100% test coverage as possible across the entire codebase
|
||||||
|
|
||||||
|
### Mocking Strategies
|
||||||
|
|
||||||
|
#### Console Mocking
|
||||||
|
|
||||||
|
When testing code that intentionally logs errors (like error handling paths), mock console methods to prevent stderr output that makes tests appear flaky:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
it("handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Test error handling code
|
||||||
|
await expect(functionThatLogs()).rejects.toThrow();
|
||||||
|
|
||||||
|
// Verify error was logged
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith("Expected error:", expect.any(Error));
|
||||||
|
|
||||||
|
// Restore console.error
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### E2E Integration Testing for Cross-Platform Code
|
||||||
|
|
||||||
|
For code that calls platform-specific system APIs (like Windows PowerShell or Linux notify-send), use helper functions that build the command structure without execution. This allows CI to verify cross-platform compatibility on Linux-only containers:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Build notify-send command for testing (doesn't execute)
|
||||||
|
#[cfg(test)]
|
||||||
|
fn build_notify_send_command(title: &str, body: &str) -> (String, Vec<String>) {
|
||||||
|
(
|
||||||
|
"notify-send".to_string(),
|
||||||
|
vec![
|
||||||
|
title.to_string(),
|
||||||
|
body.to_string(),
|
||||||
|
"--urgency=normal".to_string(),
|
||||||
|
"--app-name=Hikari Desktop".to_string(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_e2e_notify_send_command_structure() {
|
||||||
|
let (command, args) = build_notify_send_command("Test Title", "Test Body");
|
||||||
|
|
||||||
|
assert_eq!(command, "notify-send");
|
||||||
|
assert_eq!(args.len(), 4);
|
||||||
|
assert_eq!(args[0], "Test Title");
|
||||||
|
assert_eq!(args[1], "Test Body");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This approach:
|
||||||
|
|
||||||
|
- Verifies command structure, argument order, and escaping logic
|
||||||
|
- Tests cross-platform code paths without requiring the target platform
|
||||||
|
- Allows CI to catch regressions in Windows-specific code whilst running on Linux
|
||||||
|
- Keeps tests fast and deterministic (no actual system calls)
|
||||||
|
|
||||||
|
### Example Test Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
describe("FeatureName", () => {
|
||||||
|
it("handles the normal case correctly", () => {
|
||||||
|
// Arrange
|
||||||
|
const input = "test data";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = functionUnderTest(input);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toBe("expected output");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles edge cases gracefully", () => {
|
||||||
|
// Test edge cases...
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding Tests for All Changes
|
||||||
|
|
||||||
|
Every change — features, bug fixes, refactors — must include tests:
|
||||||
|
|
||||||
|
1. **Before implementing**: Consider what needs testing (happy path, edge cases, errors)
|
||||||
|
2. **During implementation**: Write tests alongside the code
|
||||||
|
3. **After implementation**: Run `pnpm test:coverage` to verify coverage remains high
|
||||||
|
4. **Before committing**: Ensure `check-all.sh` passes (includes all tests)
|
||||||
|
|
||||||
|
**Do not commit changes without tests.** The goal is to maintain near-100% coverage as the codebase grows, so future refactoring can be done with confidence!
|
||||||
|
|
||||||
|
## Quality Assurance
|
||||||
|
|
||||||
|
Before committing any changes, **always run the full test suite**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./check-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This script runs all checks in the correct order:
|
||||||
|
|
||||||
|
1. Frontend linting (ESLint)
|
||||||
|
2. Frontend formatting (Prettier)
|
||||||
|
3. Frontend type checking (svelte-check)
|
||||||
|
4. Frontend tests with coverage (Vitest)
|
||||||
|
5. Backend linting (Clippy with strict rules)
|
||||||
|
6. Backend tests with coverage (cargo test + llvm-cov)
|
||||||
|
|
||||||
|
**Important**: The script requires Node.js and Rust toolchains to be available:
|
||||||
|
|
||||||
|
- **Node.js tools** (pnpm, npm): Source nvm first if needed: `source ~/.nvm/nvm.sh`
|
||||||
|
- **Rust tools** (cargo, clippy): Should be in PATH via `~/.cargo/bin/`
|
||||||
|
|
||||||
|
If `check-all.sh` reports any failures:
|
||||||
|
|
||||||
|
1. Read the error messages carefully - they usually explain what needs fixing
|
||||||
|
2. Fix the issues (linting errors, test failures, etc.)
|
||||||
|
3. Run `check-all.sh` again to verify the fixes
|
||||||
|
4. Only commit once all checks pass ✨
|
||||||
|
|
||||||
|
**Never commit code that doesn't pass `check-all.sh`** - this ensures code quality and prevents broken builds!
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
Hikari Desktop is a Tauri-based desktop application that wraps Claude Code with a visual anime character (Hikari) who appears on screen. This is a personal project where Hikari can sign her work and act as herself!
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
# Hikari Desktop — Codebase Map
|
||||||
|
|
||||||
|
> Auto-generated codebase overview. Last updated: 2026-03-06.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Hikari Desktop is a **Tauri v2** desktop application that wraps the Claude Code CLI with a visual anime character avatar (Hikari) who appears on-screen and reacts in real-time to Claude's activity. When Claude is thinking, she thinks. When it's editing code, she codes. When it's using MCP tools, she glows with magical energy.
|
||||||
|
|
||||||
|
The app supports multiple simultaneous conversations (tabs), each with its own isolated Claude CLI process. It provides a rich UI layer on top of Claude Code, including a built-in file editor, git panel, achievement system, cost tracking, session history, notifications, and more.
|
||||||
|
|
||||||
|
**Repositories:**
|
||||||
|
|
||||||
|
- Primary: `git.nhcarrigan.com` (Gitea) — `nhcarrigan/hikari-desktop`
|
||||||
|
- Mirror: `github.com/naomi-lgbt/hikari-desktop`
|
||||||
|
|
||||||
|
**Current version:** `1.10.0`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The application follows a standard Tauri architecture:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Frontend (WebView) │
|
||||||
|
│ SvelteKit + Svelte 5 + TailwindCSS 4 + TypeScript │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────┐ │
|
||||||
|
│ │AnimeGirl│ │ Terminal │ │ InputBar │ │ Editor │ │
|
||||||
|
│ │ Sprites │ │ View │ │ + Slash Cmds│ │CodeMirror│ │
|
||||||
|
│ └────┬────┘ └────┬─────┘ └──────┬───────┘ └────┬─────┘ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ ┌────▼─────────────▼───────────────▼────────────────▼──────┐ │
|
||||||
|
│ │ Svelte Stores (reactive state) │ │
|
||||||
|
│ │ conversations · character · config · agents · stats … │ │
|
||||||
|
│ └──────────────────────────┬───────────────────────────────┘ │
|
||||||
|
│ │ tauri.ts (event listeners) │
|
||||||
|
└─────────────────────────────┼────────────────────────────────┘
|
||||||
|
│ Tauri IPC (invoke / emit)
|
||||||
|
┌─────────────────────────────┼────────────────────────────────┐
|
||||||
|
│ Backend (Rust) │
|
||||||
|
│ ┌──────────────────────────▼───────────────────────────────┐ │
|
||||||
|
│ │ commands.rs (invoke handlers) │ │
|
||||||
|
│ └──────────────────────────┬───────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────────────────▼───────────────────────────────┐ │
|
||||||
|
│ │ BridgeManager — HashMap<conversation_id, WslBridge> │ │
|
||||||
|
│ └──────────────────────────┬───────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────────────────▼───────────────────────────────┐ │
|
||||||
|
│ │ WslBridge — spawns `claude --output-format stream-json`│ │
|
||||||
|
│ │ reads NDJSON stdout → emits events to frontend │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ config · stats · cost_tracking · sessions · git · clipboard │
|
||||||
|
│ achievements · discord_rpc · notifications · snippets … │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
hikari-desktop/
|
||||||
|
├── src/ # SvelteKit frontend
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── +page.svelte # Main app layout (root page)
|
||||||
|
│ │ ├── +layout.svelte # App-level layout wrapper
|
||||||
|
│ │ ├── +layout.ts # SvelteKit layout config (SSR disabled)
|
||||||
|
│ │ └── test-achievement/ # Dev-only achievement test page
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── tauri.ts # Tauri event listeners + IPC bridge
|
||||||
|
│ │ ├── commands/ # Slash command definitions
|
||||||
|
│ │ ├── components/ # 60+ Svelte components
|
||||||
|
│ │ │ └── editor/ # CodeMirror-based file editor components
|
||||||
|
│ │ ├── notifications/ # Notification system
|
||||||
|
│ │ ├── sounds/ # Sound effect triggers
|
||||||
|
│ │ ├── stores/ # All Svelte reactive stores
|
||||||
|
│ │ ├── types/ # TypeScript type definitions
|
||||||
|
│ │ └── utils/ # Pure utility functions
|
||||||
|
│ ├── app.css # Global styles + CSS variables (themes)
|
||||||
|
│ └── app.html # HTML shell
|
||||||
|
│
|
||||||
|
├── src-tauri/ # Tauri Rust backend
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.rs # Process entry point
|
||||||
|
│ │ ├── lib.rs # Tauri app setup + command registration
|
||||||
|
│ │ ├── types.rs # All shared Rust types + serialisation
|
||||||
|
│ │ ├── wsl_bridge.rs # Claude CLI process management + NDJSON parser
|
||||||
|
│ │ ├── bridge_manager.rs # Per-conversation WslBridge registry
|
||||||
|
│ │ ├── commands.rs # All #[tauri::command] handlers
|
||||||
|
│ │ ├── config.rs # Config read/write (tauri-plugin-store)
|
||||||
|
│ │ ├── stats.rs # Token usage + cost calculation
|
||||||
|
│ │ ├── cost_tracking.rs # Budget alerts + cost history (CSV export)
|
||||||
|
│ │ ├── achievements.rs # Achievement unlock logic
|
||||||
|
│ │ ├── sessions.rs # Conversation session persistence (JSON)
|
||||||
|
│ │ ├── git.rs # Git operations via CLI
|
||||||
|
│ │ ├── clipboard.rs # Clipboard history management
|
||||||
|
│ │ ├── notifications.rs # System notification dispatch
|
||||||
|
│ │ ├── discord_rpc.rs # Discord Rich Presence manager
|
||||||
|
│ │ ├── drafts.rs # Draft message persistence
|
||||||
|
│ │ ├── snippets.rs # Snippet library CRUD
|
||||||
|
│ │ ├── quick_actions.rs # Quick action CRUD
|
||||||
|
│ │ ├── debug_logger.rs # TauriLogLayer (routes tracing → frontend)
|
||||||
|
│ │ ├── temp_manager.rs # Temporary file lifecycle management
|
||||||
|
│ │ ├── tool_cache.rs # Tool call result caching
|
||||||
|
│ │ ├── tray.rs # System tray setup
|
||||||
|
│ │ ├── process_ext.rs # HideWindow trait (Windows console hiding)
|
||||||
|
│ │ ├── vbs_notification.rs # VBScript-based notification fallback (Windows)
|
||||||
|
│ │ ├── windows_toast.rs # Windows native toast notifications
|
||||||
|
│ │ └── wsl_notifications.rs# WSL notify-send bridge
|
||||||
|
│ ├── capabilities/ # Tauri permission capabilities
|
||||||
|
│ ├── tests/ # Rust integration tests
|
||||||
|
│ ├── Cargo.toml
|
||||||
|
│ ├── Cargo.lock
|
||||||
|
│ └── tauri.conf.json # Tauri app configuration
|
||||||
|
│
|
||||||
|
├── static/
|
||||||
|
│ ├── sprites/ # Anime character PNG sprites (one per state)
|
||||||
|
│ └── sounds/ # MP3 sound effects (connected, working, done…)
|
||||||
|
│
|
||||||
|
├── check-all.sh # Full QA script (lint → format → types → test)
|
||||||
|
├── vitest.config.ts # Frontend test configuration
|
||||||
|
├── vitest.setup.ts # Tauri API mocks for tests
|
||||||
|
├── svelte.config.js # SvelteKit config (static adapter)
|
||||||
|
├── vite.config.js # Vite config
|
||||||
|
├── eslint.config.js # ESLint 9 flat config
|
||||||
|
├── tsconfig.json # TypeScript config
|
||||||
|
└── .gitea/workflows/ # CI/CD (Gitea Actions)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Components
|
||||||
|
|
||||||
|
### Backend (Rust)
|
||||||
|
|
||||||
|
#### `wsl_bridge.rs` — Claude CLI Process Manager
|
||||||
|
|
||||||
|
The most critical backend file. `WslBridge` spawns a single `claude` CLI process per conversation using `--output-format stream-json`, which causes Claude Code to emit NDJSON messages on stdout. A dedicated reader thread consumes stdout line-by-line, parses each line into a `ClaudeMessage` enum variant, and emits the appropriate frontend events.
|
||||||
|
|
||||||
|
Key responsibilities:
|
||||||
|
|
||||||
|
- Locates the `claude` binary (checks `~/.local/bin`, `~/.claude/local`, system paths, and falls back to a login-shell `which claude`)
|
||||||
|
- Detects WSL environment to handle cross-platform path differences
|
||||||
|
- Maps tool names to character states (Read/Glob/Grep → `searching`, Edit/Write → `coding`, `mcp__*` → `mcp`)
|
||||||
|
- Batches permission requests from a single assistant message
|
||||||
|
- Tracks token usage per session
|
||||||
|
|
||||||
|
#### `bridge_manager.rs` — Multi-Conversation Orchestrator
|
||||||
|
|
||||||
|
`BridgeManager` holds a `HashMap<String, WslBridge>` keyed by `conversation_id`. This enables true parallel conversations — each tab has its own isolated Claude process. The manager is wrapped in `Arc<Mutex<BridgeManager>>` (using `parking_lot`) and injected into Tauri's managed state.
|
||||||
|
|
||||||
|
#### `types.rs` — Shared Type Definitions
|
||||||
|
|
||||||
|
Defines the complete Claude stream-JSON protocol as Rust enums/structs:
|
||||||
|
|
||||||
|
- `ClaudeMessage` — top-level message variants: `System`, `Assistant`, `User`, `StreamEvent`, `Result`, `RateLimitEvent`
|
||||||
|
- `ContentBlock` — `Text`, `Thinking`, `ToolUse`, `ToolResult`
|
||||||
|
- `CharacterState` — `Idle | Thinking | Typing | Searching | Coding | Mcp | Permission | Success | Error`
|
||||||
|
- All frontend event types (`OutputEvent`, `StateChangeEvent`, `PermissionPromptEvent`, `AgentStartEvent`, etc.)
|
||||||
|
|
||||||
|
#### `commands.rs` — IPC Command Handlers
|
||||||
|
|
||||||
|
Registers all Tauri commands exposed to the frontend. Over 80 commands covering: Claude process management, configuration, stats, sessions, git, clipboard, cost tracking, MCP servers, plugins, drafts, snippets, quick actions, file system operations, authentication, and notifications.
|
||||||
|
|
||||||
|
#### `debug_logger.rs` — In-App Debug Console
|
||||||
|
|
||||||
|
A custom `tracing` subscriber layer (`TauriLogLayer`) that captures all `tracing::info!/warn!/error!` calls and emits them as `debug:log` events to the frontend debug console — essential since production Windows builds have no stdout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend (TypeScript/Svelte 5)
|
||||||
|
|
||||||
|
#### `src/routes/+page.svelte` — Root Layout
|
||||||
|
|
||||||
|
The main page. Renders a two-panel layout:
|
||||||
|
|
||||||
|
- **Left panel**: `<AnimeGirl>` character display with state-reactive glow effects (trans pride gradient colours per state)
|
||||||
|
- **Right panel**: `<Terminal>` + `<InputBar>` (or `<EditorPanel>` when the editor is open)
|
||||||
|
|
||||||
|
Also handles: global keyboard shortcuts, compact mode (280×400 mini widget), window close confirmation, Discord RPC updates, and background image loading.
|
||||||
|
|
||||||
|
#### `src/lib/tauri.ts` — Event Bridge
|
||||||
|
|
||||||
|
Sets up all Tauri event listeners on app mount. Translates backend events into store mutations:
|
||||||
|
|
||||||
|
| Event | Action |
|
||||||
|
| ------------------------ | ----------------------------------------------------------------------- |
|
||||||
|
| `claude:connection` | Updates conversation connection status; sends greeting on first connect |
|
||||||
|
| `claude:state` | Updates character state; triggers per-conversation sound effects |
|
||||||
|
| `claude:output` | Appends lines to the correct conversation's terminal history |
|
||||||
|
| `claude:session` | Stores the Claude session ID |
|
||||||
|
| `claude:cwd` | Updates working directory (used by the editor) |
|
||||||
|
| `claude:permission` | Adds permission requests to conversation state |
|
||||||
|
| `claude:agent-start/end` | Updates agent monitor panel |
|
||||||
|
| `claude:question` | Stores pending user question |
|
||||||
|
|
||||||
|
Also manages Discord RPC updates and the session greeting flow.
|
||||||
|
|
||||||
|
#### `src/lib/stores/conversations.ts` — Core State Store
|
||||||
|
|
||||||
|
The central state container. Each conversation (`Conversation` interface) tracks:
|
||||||
|
|
||||||
|
- Terminal lines (`TerminalLine[]`)
|
||||||
|
- Connection status, session ID, working directory
|
||||||
|
- Character state, processing flag
|
||||||
|
- Granted/pending tool permissions
|
||||||
|
- Pending user questions
|
||||||
|
- Scroll position, attachments, draft text
|
||||||
|
- Sound tracking (per-conversation, prevents replays on tab switch)
|
||||||
|
- Conversation summary (for compaction)
|
||||||
|
|
||||||
|
Tab names are randomly chosen from a curated list of whimsical names (Starfall, Moonbeam, Sakura, etc.).
|
||||||
|
|
||||||
|
#### `src/lib/stores/claude.ts` — Backwards-Compat Facade
|
||||||
|
|
||||||
|
A thin wrapper that re-exports `conversationsStore` methods under the original `claudeStore` API. Maintains backwards compatibility whilst the codebase migrated to multi-conversation support.
|
||||||
|
|
||||||
|
#### `src/lib/stores/character.ts` — Character State Store
|
||||||
|
|
||||||
|
Manages the global character state displayed by `<AnimeGirl>`. Supports `setState()` (persistent) and `setTemporaryState(state, durationMs)` (auto-reverts to `idle` after a timeout — used for success/error flashes).
|
||||||
|
|
||||||
|
#### `src/lib/utils/stateMapper.ts` — Stream → State Mapping
|
||||||
|
|
||||||
|
Pure utility that maps Claude stream-JSON message types to `CharacterState` values. Tool categorisation mirrors the Rust side: search tools → `searching`, coding tools → `coding`, MCP tools → `mcp`, Task tool → `thinking`.
|
||||||
|
|
||||||
|
#### `src/lib/components/`
|
||||||
|
|
||||||
|
Key components beyond the basics:
|
||||||
|
|
||||||
|
| Component | Purpose |
|
||||||
|
| --------------------------- | ------------------------------------------------------------- |
|
||||||
|
| `AnimeGirl.svelte` | Displays the character sprite, subscribes to `characterState` |
|
||||||
|
| `Terminal.svelte` | Renders the conversation message history |
|
||||||
|
| `InputBar.svelte` | User input with slash command menu, attachment support |
|
||||||
|
| `StatusBar.svelte` | Top bar: connection indicator, token/cost stats, controls |
|
||||||
|
| `ConversationTabs.svelte` | Multi-tab navigation with per-tab status indicators |
|
||||||
|
| `ConfigSidebar.svelte` | Settings panel (model, theme, notifications, budget, etc.) |
|
||||||
|
| `PermissionModal.svelte` | Handles tool permission grant/deny UI |
|
||||||
|
| `UserQuestionModal.svelte` | Renders `AskUserQuestion` prompts from Claude |
|
||||||
|
| `AgentMonitorPanel.svelte` | Live subagent tree with status badges |
|
||||||
|
| `GitPanel.svelte` | Git status, diff, stage/unstage, commit, push/pull |
|
||||||
|
| `editor/EditorPanel.svelte` | Full CodeMirror editor with file browser and tabs |
|
||||||
|
| `DiffViewer.svelte` | Syntax-highlighted diff display |
|
||||||
|
| `AchievementsPanel.svelte` | Achievement gallery |
|
||||||
|
| `CostSummary.svelte` | Cost breakdown by session/day/week/month |
|
||||||
|
| `MemoryBrowserPanel.svelte` | Browse Claude memory files |
|
||||||
|
| `McpManagementPanel.svelte` | MCP server configuration UI |
|
||||||
|
| `DebugConsole.svelte` | In-app log viewer (receives `debug:log` events) |
|
||||||
|
| `ThinkingBlock.svelte` | Collapsible extended thinking display |
|
||||||
|
| `ToolCallBlock.svelte` | Formatted tool use/result display |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### User Sends a Message
|
||||||
|
|
||||||
|
```
|
||||||
|
User types → InputBar
|
||||||
|
→ invoke("send_prompt", { conversationId, message })
|
||||||
|
→ BridgeManager.send_prompt(conversation_id, message)
|
||||||
|
→ WslBridge.send_message() → writes JSON to Claude CLI stdin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Claude Responds (NDJSON Stream)
|
||||||
|
|
||||||
|
```
|
||||||
|
Claude CLI stdout (NDJSON)
|
||||||
|
→ WslBridge reader thread (line-by-line)
|
||||||
|
→ serde_json::from_str::<ClaudeMessage>()
|
||||||
|
→ match message type:
|
||||||
|
System(init) → emit claude:connection(connected) + claude:cwd
|
||||||
|
StreamEvent → emit claude:state(thinking|typing|searching|coding|mcp)
|
||||||
|
Assistant → emit claude:output(assistant|tool|thinking lines)
|
||||||
|
User(tool_result)→ emit claude:output(tool result lines)
|
||||||
|
Result(success) → emit claude:state(success) + claude:output(result)
|
||||||
|
Result(error) → emit claude:state(error)
|
||||||
|
RateLimitEvent → emit claude:output(rate-limit line)
|
||||||
|
PermissionRequest→ emit claude:permission
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Reacts
|
||||||
|
|
||||||
|
```
|
||||||
|
tauri.ts event listeners
|
||||||
|
→ conversationsStore mutations
|
||||||
|
→ Svelte reactivity propagates to components
|
||||||
|
→ AnimeGirl.svelte: sprite changes to match characterState
|
||||||
|
→ Terminal.svelte: new line appended
|
||||||
|
→ StatusBar.svelte: token counts update
|
||||||
|
→ ConversationTabs.svelte: tab glow colour updates
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Claude requests tool permission
|
||||||
|
→ WslBridge batches pending tool uses
|
||||||
|
→ emit claude:permission (one or more requests)
|
||||||
|
→ tauri.ts → claudeStore.requestPermissionForConversation()
|
||||||
|
→ PermissionModal.svelte renders
|
||||||
|
→ User clicks Allow/Deny
|
||||||
|
→ invoke("answer_question", { conversationId, toolUseId, granted })
|
||||||
|
→ WslBridge.send_tool_result() → writes result to Claude stdin
|
||||||
|
→ Claude CLI resumes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State Machine
|
||||||
|
|
||||||
|
The `CharacterState` enum drives both the sprite displayed and the panel glow colour:
|
||||||
|
|
||||||
|
| State | Trigger | Sprite | Panel Glow |
|
||||||
|
| ------------ | --------------------------------- | ----------------------- | ---------------------- |
|
||||||
|
| `idle` | Connected, no activity | Standing with clipboard | None |
|
||||||
|
| `thinking` | Thinking block / Task tool | Hand on chin | Purple/trans gradient |
|
||||||
|
| `typing` | Text content block | At keyboard | Blue/trans gradient |
|
||||||
|
| `searching` | Read/Glob/Grep/WebSearch/WebFetch | Magnifying glass | Yellow/trans gradient |
|
||||||
|
| `coding` | Edit/Write/NotebookEdit | At monitor | Green/trans gradient |
|
||||||
|
| `mcp` | Any `mcp__*` tool | Magical blue energy | Trans pride vibrant |
|
||||||
|
| `permission` | Permission requested | Confused shrug | — |
|
||||||
|
| `success` | Result: success | Celebrating | Emerald/trans gradient |
|
||||||
|
| `error` | Result: error | Worried | Red/trans gradient |
|
||||||
|
|
||||||
|
`success` and `error` are temporary states (3-second auto-revert to `idle`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Frontend (key packages)
|
||||||
|
|
||||||
|
| Package | Purpose |
|
||||||
|
| ------------------------------ | -------------------------------------------------------------- |
|
||||||
|
| `@sveltejs/kit` `svelte` | SvelteKit framework + Svelte 5 |
|
||||||
|
| `@tauri-apps/api` | Core Tauri IPC (`invoke`, `listen`) |
|
||||||
|
| `@tauri-apps/plugin-*` | FS, clipboard, notifications, dialog, shell, store, os, opener |
|
||||||
|
| `tailwindcss` v4 | Utility-first CSS |
|
||||||
|
| `codemirror` + `@codemirror/*` | Code editor with 20+ language modes |
|
||||||
|
| `marked` | Markdown → HTML rendering |
|
||||||
|
| `highlight.js` | Syntax highlighting in markdown blocks |
|
||||||
|
| `lucide-svelte` | Icon library |
|
||||||
|
|
||||||
|
### Backend (key crates)
|
||||||
|
|
||||||
|
| Crate | Purpose |
|
||||||
|
| -------------------------------- | ---------------------------------------- |
|
||||||
|
| `tauri` v2 | Desktop app framework |
|
||||||
|
| `tokio` | Async runtime |
|
||||||
|
| `serde` / `serde_json` | JSON serialisation/deserialisation |
|
||||||
|
| `parking_lot` | Fast mutex (used for `BridgeManager`) |
|
||||||
|
| `uuid` | Unique ID generation |
|
||||||
|
| `discord-rich-presence` | Discord RPC integration |
|
||||||
|
| `chrono` | Date/time handling for cost tracking |
|
||||||
|
| `semver` | Version comparison for update checks |
|
||||||
|
| `tempfile` | Temporary file management |
|
||||||
|
| `tracing` + `tracing-subscriber` | Structured logging |
|
||||||
|
| `dirs` | Cross-platform home directory resolution |
|
||||||
|
| `windows` (Windows-only) | Native toast notifications |
|
||||||
|
|
||||||
|
### Dev / Tooling
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
| -------------------------------- | ----------------------------------------- |
|
||||||
|
| `vitest` + `@vitest/coverage-v8` | Frontend unit tests with v8 coverage |
|
||||||
|
| `@testing-library/svelte` | Component testing utilities |
|
||||||
|
| `jsdom` | DOM environment for tests |
|
||||||
|
| `eslint` v9 (flat config) | Linting |
|
||||||
|
| `prettier` | Formatting |
|
||||||
|
| `svelte-check` | TypeScript type checking for Svelte files |
|
||||||
|
| `cargo test` + `cargo llvm-cov` | Rust unit tests and coverage |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Notes
|
||||||
|
|
||||||
|
### Running the App
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Frontend dev server only
|
||||||
|
source ~/.nvm/nvm.sh && pnpm dev
|
||||||
|
|
||||||
|
# Full Tauri app (Rust + frontend)
|
||||||
|
source ~/.nvm/nvm.sh && pnpm tauri dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All checks (lint → format → type-check → frontend tests → backend tests)
|
||||||
|
./check-all.sh
|
||||||
|
|
||||||
|
# Frontend tests only
|
||||||
|
source ~/.nvm/nvm.sh && pnpm test
|
||||||
|
|
||||||
|
# Frontend with coverage
|
||||||
|
source ~/.nvm/nvm.sh && pnpm test:coverage
|
||||||
|
|
||||||
|
# Backend tests only
|
||||||
|
pnpm test:backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux build
|
||||||
|
pnpm build:linux
|
||||||
|
|
||||||
|
# Windows cross-compile (requires cargo-xwin)
|
||||||
|
pnpm build:windows
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a New Tauri Command
|
||||||
|
|
||||||
|
1. Add the handler function in the appropriate `src-tauri/src/*.rs` file with `#[tauri::command]`
|
||||||
|
2. Register it in `lib.rs` `invoke_handler![]`
|
||||||
|
3. Call it from the frontend via `invoke("command_name", { args })` in `src/lib/tauri.ts` or a store
|
||||||
|
|
||||||
|
### Adding a New Frontend Store
|
||||||
|
|
||||||
|
1. Create `src/lib/stores/my-store.ts` using `writable` or a factory function pattern
|
||||||
|
2. Create `src/lib/stores/my-store.test.ts` — all stores must have tests
|
||||||
|
3. Expose the store from the appropriate component
|
||||||
|
|
||||||
|
### Claude Stream-JSON Protocol
|
||||||
|
|
||||||
|
Claude Code is invoked with `--output-format stream-json --verbose`. See `src-tauri/src/types.rs` for the complete message type definitions. The key field distinguishing subagent messages from top-level messages is `parent_tool_use_id` on `Assistant` messages.
|
||||||
|
|
||||||
|
### Multi-Conversation Architecture
|
||||||
|
|
||||||
|
Each tab (`Conversation`) in `conversationsStore` has a unique `conversation_id` string. The backend `BridgeManager` maps these IDs to `WslBridge` instances. All Tauri events carry `conversation_id` in their payload so the frontend can route them to the correct conversation without affecting others.
|
||||||
|
|
||||||
|
### WSL Detection
|
||||||
|
|
||||||
|
`wsl_bridge.rs` detects WSL by checking `/proc/version` for "microsoft"/"wsl" strings, checking for `/proc/sys/fs/binfmt_misc/WSLInterop`, and checking `$WSL_DISTRO_NAME`. On native Windows builds, WSL detection always returns `false` (even if launched from a WSL terminal).
|
||||||
|
|
||||||
|
### Character State Sound Rules
|
||||||
|
|
||||||
|
Sound effects are managed in `src/lib/tauri.ts` per-conversation to prevent replays when switching tabs. The rules are:
|
||||||
|
|
||||||
|
- Entering `thinking` from a clean state (`idle`/`success`/`error`) → reset all sound flags
|
||||||
|
- Entering `coding` or `searching` (first time per task) → play task-start sound
|
||||||
|
- Entering `success` after ≥2 seconds in a long-running phase → play completion sound
|
||||||
|
- Entering `error` → play error sound (always)
|
||||||
|
- Entering `permission` → play permission sound (always)
|
||||||
|
|
||||||
|
### Workspace Trust Gate
|
||||||
|
|
||||||
|
On first connection to a new working directory, the app checks for Claude hooks and prompts the user to trust the workspace. Trusted workspaces are persisted in `HikariConfig.trusted_workspaces`.
|
||||||
|
|
||||||
|
### Configuration Storage
|
||||||
|
|
||||||
|
All settings are persisted via `tauri-plugin-store` to a JSON file in the app data directory. The frontend `configStore` (`src/lib/stores/config.ts`) loads configuration on startup and provides reactive derived stores. Changes invoke `save_config` to persist to disk.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Project Overview
|
||||||
|
|
||||||
|
## What is this project?
|
||||||
|
|
||||||
|
Hikari Desktop is a Tauri-based desktop application that wraps Claude Code with a visual anime character companion (Hikari) who appears on screen. It provides a rich UI for interacting with Claude Code, including conversation management, agent monitoring, cost tracking, and more.
|
||||||
|
|
||||||
|
The app was inspired by a Hatsune Miku mod for the ship AI in _The Outer Worlds_ — the idea of an AI assistant with an anime girl avatar that you can actually _see_.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Provide a beautiful, personalised interface for Claude Code
|
||||||
|
- Surface real-time status (thinking, typing, searching, etc.) through animated character sprites
|
||||||
|
- Track costs, context usage, and agent activity across sessions
|
||||||
|
- Support power-user workflows: multi-tab conversations, todo lists, git integration, MCP server management, session compaction, and more
|
||||||
|
- Build a foundation for autonomous task execution (agent orchestration, PRD-driven workflows)
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Frontend**: Svelte 5 + TypeScript + Tailwind CSS
|
||||||
|
- **Backend**: Rust (Tauri v2)
|
||||||
|
- **Build**: Vite + pnpm
|
||||||
|
- **Testing**: Vitest (frontend) + cargo test (backend)
|
||||||
|
- **Linting**: ESLint + Prettier (frontend) + Clippy (backend)
|
||||||
|
- **IPC**: Tauri commands + events between Rust and Svelte
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
hikari-desktop/
|
||||||
|
├── src/ # Svelte frontend
|
||||||
|
│ └── lib/
|
||||||
|
│ ├── components/ # UI components (panels, modals, status bar)
|
||||||
|
│ ├── stores/ # Svelte stores (state management)
|
||||||
|
│ ├── types/ # TypeScript type definitions
|
||||||
|
│ └── utils/ # Utility functions
|
||||||
|
├── src-tauri/ # Rust backend
|
||||||
|
│ └── src/
|
||||||
|
│ ├── commands.rs # Tauri command handlers
|
||||||
|
│ ├── wsl_bridge.rs # Claude Code process management
|
||||||
|
│ ├── types.rs # Shared types & CharacterState enum
|
||||||
|
│ └── stats.rs # Cost tracking
|
||||||
|
└── public/ # Static assets (sprites, sounds)
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude Code is launched as a child process via `WslBridge`, communicating via `--output-format stream-json` (NDJSON). Messages flow from the Rust backend to the Svelte frontend via Tauri events.
|
||||||
@@ -1 +1,29 @@
|
|||||||
tem
|
# hikari-desktop
|
||||||
|
|
||||||
|
Desktop companion application featuring Hikari.
|
||||||
|
|
||||||
|
## Live Version
|
||||||
|
|
||||||
|
This page is currently deployed. [View the live website.](https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/releases)
|
||||||
|
|
||||||
|
## Feedback and Bugs
|
||||||
|
|
||||||
|
If you have feedback or a bug report, please [log a ticket on our forum](https://support.nhcarrigan.com).
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
If you would like to contribute to the project, you may create a Pull Request containing your proposed changes and we will review it as soon as we are able! Please review our [contributing guidelines](CONTRIBUTING.md) first.
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
Before interacting with our community, please read our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This software is licensed under our [global software license](https://docs.nhcarrigan.com/#/license).
|
||||||
|
|
||||||
|
Copyright held by Naomi Carrigan.
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hikari-desktop",
|
"name": "hikari-desktop",
|
||||||
"version": "1.0.0",
|
"version": "1.11.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -27,41 +27,69 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@codemirror/commands": "6.10.2",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
"@codemirror/lang-angular": "0.1.4",
|
||||||
"@tauri-apps/plugin-dialog": "^2",
|
"@codemirror/lang-cpp": "6.0.3",
|
||||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
"@codemirror/lang-css": "6.3.1",
|
||||||
"@tauri-apps/plugin-notification": "^2",
|
"@codemirror/lang-go": "6.0.1",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@codemirror/lang-html": "6.4.11",
|
||||||
"@tauri-apps/plugin-os": "^2",
|
"@codemirror/lang-java": "6.0.2",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.4",
|
"@codemirror/lang-javascript": "6.2.4",
|
||||||
"@tauri-apps/plugin-store": "^2",
|
"@codemirror/lang-json": "6.0.2",
|
||||||
"highlight.js": "^11.11.1",
|
"@codemirror/lang-less": "6.0.2",
|
||||||
"marked": "^17.0.1"
|
"@codemirror/lang-markdown": "6.5.0",
|
||||||
|
"@codemirror/lang-php": "6.0.2",
|
||||||
|
"@codemirror/lang-python": "6.2.1",
|
||||||
|
"@codemirror/lang-rust": "6.0.2",
|
||||||
|
"@codemirror/lang-sass": "6.0.2",
|
||||||
|
"@codemirror/lang-sql": "6.10.0",
|
||||||
|
"@codemirror/lang-vue": "0.1.3",
|
||||||
|
"@codemirror/lang-wast": "6.0.2",
|
||||||
|
"@codemirror/lang-xml": "6.1.0",
|
||||||
|
"@codemirror/lang-yaml": "6.1.2",
|
||||||
|
"@codemirror/language": "6.12.2",
|
||||||
|
"@codemirror/legacy-modes": "6.5.2",
|
||||||
|
"@codemirror/state": "6.5.4",
|
||||||
|
"@codemirror/theme-one-dark": "6.1.3",
|
||||||
|
"@codemirror/view": "6.39.15",
|
||||||
|
"@lezer/highlight": "1.2.3",
|
||||||
|
"@tauri-apps/api": "2.10.1",
|
||||||
|
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
|
||||||
|
"@tauri-apps/plugin-dialog": "2.6.0",
|
||||||
|
"@tauri-apps/plugin-fs": "2.4.5",
|
||||||
|
"@tauri-apps/plugin-notification": "2.3.3",
|
||||||
|
"@tauri-apps/plugin-opener": "2.5.3",
|
||||||
|
"@tauri-apps/plugin-os": "2.3.2",
|
||||||
|
"@tauri-apps/plugin-shell": "2.3.5",
|
||||||
|
"@tauri-apps/plugin-store": "2.4.2",
|
||||||
|
"codemirror": "6.0.2",
|
||||||
|
"highlight.js": "11.11.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "hikari-desktop"
|
name = "hikari-desktop"
|
||||||
version = "1.0.0"
|
version = "1.11.1"
|
||||||
description = "Hikari - Claude Code Visual Assistant"
|
description = "Hikari - Claude Code Visual Assistant"
|
||||||
authors = ["Naomi Carrigan"]
|
authors = ["Naomi Carrigan"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -31,6 +31,10 @@ tauri-plugin-fs = "2"
|
|||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
semver = "1"
|
semver = "1"
|
||||||
chrono = { version = "0.4.43", features = ["serde"] }
|
chrono = { version = "0.4.43", features = ["serde"] }
|
||||||
|
discord-rich-presence = "0.2"
|
||||||
|
dirs = "5"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows = { version = "0.62", features = [
|
windows = { version = "0.62", features = [
|
||||||
|
|||||||
@@ -28,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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 338 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 466 KiB |
|
Before Width: | Height: | Size: 878 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 58 KiB |
@@ -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) {
|
||||||
@@ -1935,6 +1935,7 @@ pub fn check_achievements(
|
|||||||
let search_count: u64 = search_tools
|
let search_count: u64 = search_tools
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|tool| stats.tools_usage.get(*tool))
|
.filter_map(|tool| stats.tools_usage.get(*tool))
|
||||||
|
.map(|t| t.call_count)
|
||||||
.sum();
|
.sum();
|
||||||
if search_count >= 50 && progress.unlock(AchievementId::Explorer) {
|
if search_count >= 50 && progress.unlock(AchievementId::Explorer) {
|
||||||
newly_unlocked.push(AchievementId::Explorer);
|
newly_unlocked.push(AchievementId::Explorer);
|
||||||
@@ -1988,25 +1989,25 @@ pub fn check_achievements(
|
|||||||
// TODO: Track different Claude models used
|
// TODO: Track different Claude models used
|
||||||
|
|
||||||
// Tool mastery achievements
|
// Tool mastery achievements
|
||||||
if let Some(bash_count) = stats.tools_usage.get("Bash") {
|
if let Some(bash_stats) = stats.tools_usage.get("Bash") {
|
||||||
if *bash_count >= 50 && progress.unlock(AchievementId::BashMaster) {
|
if bash_stats.call_count >= 50 && progress.unlock(AchievementId::BashMaster) {
|
||||||
newly_unlocked.push(AchievementId::BashMaster);
|
newly_unlocked.push(AchievementId::BashMaster);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(read_count) = stats.tools_usage.get("Read") {
|
if let Some(read_stats) = stats.tools_usage.get("Read") {
|
||||||
if *read_count >= 100 && progress.unlock(AchievementId::FileExplorer) {
|
if read_stats.call_count >= 100 && progress.unlock(AchievementId::FileExplorer) {
|
||||||
newly_unlocked.push(AchievementId::FileExplorer);
|
newly_unlocked.push(AchievementId::FileExplorer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(grep_count) = stats.tools_usage.get("Grep") {
|
if let Some(grep_stats) = stats.tools_usage.get("Grep") {
|
||||||
if *grep_count >= 50 && progress.unlock(AchievementId::SearchExpert) {
|
if grep_stats.call_count >= 50 && progress.unlock(AchievementId::SearchExpert) {
|
||||||
newly_unlocked.push(AchievementId::SearchExpert);
|
newly_unlocked.push(AchievementId::SearchExpert);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Git Guru - check git command usage in Bash
|
// Git Guru - check git command usage in Bash
|
||||||
if let Some(bash_count) = stats.tools_usage.get("Bash") {
|
if let Some(bash_stats) = stats.tools_usage.get("Bash") {
|
||||||
if *bash_count >= 10 && progress.unlock(AchievementId::GitGuru) {
|
if bash_stats.call_count >= 10 && progress.unlock(AchievementId::GitGuru) {
|
||||||
// TODO: More specific git command tracking
|
// TODO: More specific git command tracking
|
||||||
newly_unlocked.push(AchievementId::GitGuru);
|
newly_unlocked.push(AchievementId::GitGuru);
|
||||||
}
|
}
|
||||||
@@ -2055,28 +2056,28 @@ pub fn check_achievements(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// More tool mastery achievements
|
// More tool mastery achievements
|
||||||
if let Some(edit_count) = stats.tools_usage.get("Edit") {
|
if let Some(edit_stats) = stats.tools_usage.get("Edit") {
|
||||||
if *edit_count >= 100 && progress.unlock(AchievementId::EditMaster) {
|
if edit_stats.call_count >= 100 && progress.unlock(AchievementId::EditMaster) {
|
||||||
newly_unlocked.push(AchievementId::EditMaster);
|
newly_unlocked.push(AchievementId::EditMaster);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(write_count) = stats.tools_usage.get("Write") {
|
if let Some(write_stats) = stats.tools_usage.get("Write") {
|
||||||
if *write_count >= 50 && progress.unlock(AchievementId::WriteMaster) {
|
if write_stats.call_count >= 50 && progress.unlock(AchievementId::WriteMaster) {
|
||||||
newly_unlocked.push(AchievementId::WriteMaster);
|
newly_unlocked.push(AchievementId::WriteMaster);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(glob_count) = stats.tools_usage.get("Glob") {
|
if let Some(glob_stats) = stats.tools_usage.get("Glob") {
|
||||||
if *glob_count >= 100 && progress.unlock(AchievementId::GlobMaster) {
|
if glob_stats.call_count >= 100 && progress.unlock(AchievementId::GlobMaster) {
|
||||||
newly_unlocked.push(AchievementId::GlobMaster);
|
newly_unlocked.push(AchievementId::GlobMaster);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(task_count) = stats.tools_usage.get("Task") {
|
if let Some(task_stats) = stats.tools_usage.get("Task") {
|
||||||
if *task_count >= 50 && progress.unlock(AchievementId::TaskMaster) {
|
if task_stats.call_count >= 50 && progress.unlock(AchievementId::TaskMaster) {
|
||||||
newly_unlocked.push(AchievementId::TaskMaster);
|
newly_unlocked.push(AchievementId::TaskMaster);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(web_count) = stats.tools_usage.get("WebFetch") {
|
if let Some(web_stats) = stats.tools_usage.get("WebFetch") {
|
||||||
if *web_count >= 20 && progress.unlock(AchievementId::WebFetcher) {
|
if web_stats.call_count >= 20 && progress.unlock(AchievementId::WebFetcher) {
|
||||||
newly_unlocked.push(AchievementId::WebFetcher);
|
newly_unlocked.push(AchievementId::WebFetcher);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2085,7 +2086,7 @@ pub fn check_achievements(
|
|||||||
.tools_usage
|
.tools_usage
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(name, _)| name.starts_with("mcp__"))
|
.filter(|(name, _)| name.starts_with("mcp__"))
|
||||||
.map(|(_, count)| count)
|
.map(|(_, tool_stats)| tool_stats.call_count)
|
||||||
.sum();
|
.sum();
|
||||||
if mcp_count >= 50 && progress.unlock(AchievementId::McpExplorer) {
|
if mcp_count >= 50 && progress.unlock(AchievementId::McpExplorer) {
|
||||||
newly_unlocked.push(AchievementId::McpExplorer);
|
newly_unlocked.push(AchievementId::McpExplorer);
|
||||||
@@ -2243,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",
|
||||||
@@ -2251,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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2271,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
|
||||||
@@ -2323,6 +2324,15 @@ mod tests {
|
|||||||
morning_sessions: 0,
|
morning_sessions: 0,
|
||||||
night_sessions: 0,
|
night_sessions: 0,
|
||||||
last_session_date: None,
|
last_session_date: None,
|
||||||
|
context_tokens_used: 0,
|
||||||
|
context_window_limit: 200_000,
|
||||||
|
context_utilisation_percent: 0.0,
|
||||||
|
potential_cache_hits: 0,
|
||||||
|
potential_cache_savings_tokens: 0,
|
||||||
|
current_request_input: None,
|
||||||
|
current_request_output_chars: 0,
|
||||||
|
current_request_thinking_chars: 0,
|
||||||
|
current_request_tools: Vec::new(),
|
||||||
achievements: AchievementProgress::new(),
|
achievements: AchievementProgress::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2733,12 +2743,21 @@ mod tests {
|
|||||||
// check_achievements tests - Tool Usage
|
// check_achievements tests - Tool Usage
|
||||||
// =====================
|
// =====================
|
||||||
|
|
||||||
|
// Helper function to create a ToolTokenStats with just call count for tests
|
||||||
|
fn tool_stats(call_count: u64) -> crate::stats::ToolTokenStats {
|
||||||
|
crate::stats::ToolTokenStats {
|
||||||
|
call_count,
|
||||||
|
estimated_input_tokens: 0,
|
||||||
|
estimated_output_tokens: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_check_achievements_first_tool() {
|
fn test_check_achievements_first_tool() {
|
||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Read".to_string(), 1);
|
stats.tools_usage.insert("Read".to_string(), tool_stats(1));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::FirstTool));
|
assert!(newly.contains(&AchievementId::FirstTool));
|
||||||
@@ -2749,11 +2768,11 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Read".to_string(), 1);
|
stats.tools_usage.insert("Read".to_string(), tool_stats(1));
|
||||||
stats.tools_usage.insert("Write".to_string(), 1);
|
stats.tools_usage.insert("Write".to_string(), tool_stats(1));
|
||||||
stats.tools_usage.insert("Edit".to_string(), 1);
|
stats.tools_usage.insert("Edit".to_string(), tool_stats(1));
|
||||||
stats.tools_usage.insert("Bash".to_string(), 1);
|
stats.tools_usage.insert("Bash".to_string(), tool_stats(1));
|
||||||
stats.tools_usage.insert("Grep".to_string(), 1);
|
stats.tools_usage.insert("Grep".to_string(), tool_stats(1));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::Toolsmith));
|
assert!(newly.contains(&AchievementId::Toolsmith));
|
||||||
@@ -2765,7 +2784,7 @@ mod tests {
|
|||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
for i in 0..10 {
|
for i in 0..10 {
|
||||||
stats.tools_usage.insert(format!("Tool{}", i), 1);
|
stats.tools_usage.insert(format!("Tool{}", i), tool_stats(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
@@ -2777,7 +2796,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Bash".to_string(), 50);
|
stats.tools_usage.insert("Bash".to_string(), tool_stats(50));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::BashMaster));
|
assert!(newly.contains(&AchievementId::BashMaster));
|
||||||
@@ -2788,7 +2807,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Read".to_string(), 100);
|
stats.tools_usage.insert("Read".to_string(), tool_stats(100));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::FileExplorer));
|
assert!(newly.contains(&AchievementId::FileExplorer));
|
||||||
@@ -2799,7 +2818,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Grep".to_string(), 50);
|
stats.tools_usage.insert("Grep".to_string(), tool_stats(50));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::SearchExpert));
|
assert!(newly.contains(&AchievementId::SearchExpert));
|
||||||
@@ -2810,7 +2829,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Edit".to_string(), 100);
|
stats.tools_usage.insert("Edit".to_string(), tool_stats(100));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::EditMaster));
|
assert!(newly.contains(&AchievementId::EditMaster));
|
||||||
@@ -2821,7 +2840,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Write".to_string(), 50);
|
stats.tools_usage.insert("Write".to_string(), tool_stats(50));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::WriteMaster));
|
assert!(newly.contains(&AchievementId::WriteMaster));
|
||||||
@@ -2832,7 +2851,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Glob".to_string(), 100);
|
stats.tools_usage.insert("Glob".to_string(), tool_stats(100));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::GlobMaster));
|
assert!(newly.contains(&AchievementId::GlobMaster));
|
||||||
@@ -2843,7 +2862,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Task".to_string(), 50);
|
stats.tools_usage.insert("Task".to_string(), tool_stats(50));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::TaskMaster));
|
assert!(newly.contains(&AchievementId::TaskMaster));
|
||||||
@@ -2854,7 +2873,7 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("WebFetch".to_string(), 20);
|
stats.tools_usage.insert("WebFetch".to_string(), tool_stats(20));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::WebFetcher));
|
assert!(newly.contains(&AchievementId::WebFetcher));
|
||||||
@@ -2865,8 +2884,8 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("mcp__github__create_issue".to_string(), 25);
|
stats.tools_usage.insert("mcp__github__create_issue".to_string(), tool_stats(25));
|
||||||
stats.tools_usage.insert("mcp__notion__search".to_string(), 25);
|
stats.tools_usage.insert("mcp__notion__search".to_string(), tool_stats(25));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::McpExplorer));
|
assert!(newly.contains(&AchievementId::McpExplorer));
|
||||||
@@ -2881,8 +2900,8 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Grep".to_string(), 30);
|
stats.tools_usage.insert("Grep".to_string(), tool_stats(30));
|
||||||
stats.tools_usage.insert("Glob".to_string(), 20);
|
stats.tools_usage.insert("Glob".to_string(), tool_stats(20));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::Explorer));
|
assert!(newly.contains(&AchievementId::Explorer));
|
||||||
@@ -2893,9 +2912,9 @@ mod tests {
|
|||||||
let mut stats = create_test_stats();
|
let mut stats = create_test_stats();
|
||||||
let mut progress = AchievementProgress::new();
|
let mut progress = AchievementProgress::new();
|
||||||
|
|
||||||
stats.tools_usage.insert("Grep".to_string(), 200);
|
stats.tools_usage.insert("Grep".to_string(), tool_stats(200));
|
||||||
stats.tools_usage.insert("Glob".to_string(), 200);
|
stats.tools_usage.insert("Glob".to_string(), tool_stats(200));
|
||||||
stats.tools_usage.insert("Task".to_string(), 100);
|
stats.tools_usage.insert("Task".to_string(), tool_stats(100));
|
||||||
|
|
||||||
let newly = check_achievements(&stats, &mut progress);
|
let newly = check_achievements(&stats, &mut progress);
|
||||||
assert!(newly.contains(&AchievementId::MasterSearcher));
|
assert!(newly.contains(&AchievementId::MasterSearcher));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::collections::HashMap;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
use crate::commands::record_session;
|
||||||
use crate::config::ClaudeStartOptions;
|
use crate::config::ClaudeStartOptions;
|
||||||
use crate::stats::UsageStats;
|
use crate::stats::UsageStats;
|
||||||
use crate::wsl_bridge::WslBridge;
|
use crate::wsl_bridge::WslBridge;
|
||||||
@@ -53,7 +54,12 @@ impl BridgeManager {
|
|||||||
.or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string()));
|
.or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string()));
|
||||||
|
|
||||||
// Start the Claude process
|
// Start the Claude process
|
||||||
bridge.start(app, options)?;
|
bridge.start(app.clone(), options)?;
|
||||||
|
|
||||||
|
// Record session start for cost tracking
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
record_session(&app).await;
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -167,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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,9 +25,19 @@ pub struct ClaudeStartOptions {
|
|||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub resume_session_id: Option<String>,
|
pub resume_session_id: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub use_worktree: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub disable_1m_context: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_output_tokens: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
pub struct HikariConfig {
|
pub struct HikariConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
@@ -71,9 +81,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,
|
||||||
|
|
||||||
@@ -96,6 +103,71 @@ pub struct HikariConfig {
|
|||||||
// Custom theme colors
|
// Custom theme colors
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub custom_theme_colors: CustomThemeColors,
|
pub custom_theme_colors: CustomThemeColors,
|
||||||
|
|
||||||
|
// Token budget settings
|
||||||
|
#[serde(default)]
|
||||||
|
pub budget_enabled: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub session_token_budget: Option<u64>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub session_cost_budget: Option<f64>,
|
||||||
|
|
||||||
|
#[serde(default = "default_budget_action")]
|
||||||
|
pub budget_action: BudgetAction,
|
||||||
|
|
||||||
|
#[serde(default = "default_budget_warning_threshold")]
|
||||||
|
pub budget_warning_threshold: f32,
|
||||||
|
|
||||||
|
#[serde(default = "default_discord_rpc_enabled")]
|
||||||
|
pub discord_rpc_enabled: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub use_worktree: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub disable_1m_context: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_output_tokens: Option<u64>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub trusted_workspaces: Vec<String>,
|
||||||
|
|
||||||
|
// Background image settings
|
||||||
|
#[serde(default)]
|
||||||
|
pub background_image_path: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default = "default_background_image_opacity")]
|
||||||
|
pub background_image_opacity: f32,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub show_thinking_blocks: bool,
|
||||||
|
|
||||||
|
// Custom terminal font settings
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_font_path: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_font_family: Option<String>,
|
||||||
|
|
||||||
|
// Custom UI font settings
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_ui_font_path: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_ui_font_family: Option<String>,
|
||||||
|
|
||||||
|
// Task Loop auto-commit settings
|
||||||
|
#[serde(default)]
|
||||||
|
pub task_loop_auto_commit: bool,
|
||||||
|
|
||||||
|
#[serde(default = "default_task_loop_commit_prefix")]
|
||||||
|
pub task_loop_commit_prefix: String,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub task_loop_include_summary: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HikariConfig {
|
impl Default for HikariConfig {
|
||||||
@@ -115,7 +187,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,
|
||||||
@@ -123,6 +194,26 @@ impl Default for HikariConfig {
|
|||||||
profile_avatar_path: None,
|
profile_avatar_path: None,
|
||||||
profile_bio: None,
|
profile_bio: None,
|
||||||
custom_theme_colors: CustomThemeColors::default(),
|
custom_theme_colors: CustomThemeColors::default(),
|
||||||
|
budget_enabled: false,
|
||||||
|
session_token_budget: None,
|
||||||
|
session_cost_budget: None,
|
||||||
|
budget_action: BudgetAction::Warn,
|
||||||
|
budget_warning_threshold: 0.8,
|
||||||
|
discord_rpc_enabled: true,
|
||||||
|
use_worktree: false,
|
||||||
|
disable_1m_context: false,
|
||||||
|
max_output_tokens: None,
|
||||||
|
trusted_workspaces: Vec::new(),
|
||||||
|
background_image_path: None,
|
||||||
|
background_image_opacity: 0.3,
|
||||||
|
show_thinking_blocks: false,
|
||||||
|
custom_font_path: None,
|
||||||
|
custom_font_family: None,
|
||||||
|
custom_ui_font_path: None,
|
||||||
|
custom_ui_font_family: None,
|
||||||
|
task_loop_auto_commit: false,
|
||||||
|
task_loop_commit_prefix: "feat".to_string(),
|
||||||
|
task_loop_include_summary: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,6 +238,34 @@ fn default_font_size() -> u32 {
|
|||||||
14
|
14
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_budget_action() -> BudgetAction {
|
||||||
|
BudgetAction::Warn
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_budget_warning_threshold() -> f32 {
|
||||||
|
0.8
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_discord_rpc_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_background_image_opacity() -> f32 {
|
||||||
|
0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_task_loop_commit_prefix() -> String {
|
||||||
|
"feat".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum BudgetAction {
|
||||||
|
#[default]
|
||||||
|
Warn,
|
||||||
|
Block,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum Theme {
|
pub enum Theme {
|
||||||
@@ -156,6 +275,18 @@ pub enum Theme {
|
|||||||
#[serde(rename = "high-contrast")]
|
#[serde(rename = "high-contrast")]
|
||||||
HighContrast,
|
HighContrast,
|
||||||
Custom,
|
Custom,
|
||||||
|
Dracula,
|
||||||
|
Catppuccin,
|
||||||
|
Nord,
|
||||||
|
Solarized,
|
||||||
|
#[serde(rename = "solarized-light")]
|
||||||
|
SolarizedLight,
|
||||||
|
#[serde(rename = "catppuccin-latte")]
|
||||||
|
CatppuccinLatte,
|
||||||
|
#[serde(rename = "gruvbox-light")]
|
||||||
|
GruvboxLight,
|
||||||
|
#[serde(rename = "rose-pine-dawn")]
|
||||||
|
RosePineDawn,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
@@ -197,7 +328,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);
|
||||||
@@ -205,6 +335,23 @@ mod tests {
|
|||||||
assert!(config.profile_avatar_path.is_none());
|
assert!(config.profile_avatar_path.is_none());
|
||||||
assert!(config.profile_bio.is_none());
|
assert!(config.profile_bio.is_none());
|
||||||
assert_eq!(config.custom_theme_colors, CustomThemeColors::default());
|
assert_eq!(config.custom_theme_colors, CustomThemeColors::default());
|
||||||
|
assert!(!config.budget_enabled);
|
||||||
|
assert!(config.session_token_budget.is_none());
|
||||||
|
assert!(config.session_cost_budget.is_none());
|
||||||
|
assert_eq!(config.budget_action, BudgetAction::Warn);
|
||||||
|
assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON);
|
||||||
|
assert!(config.discord_rpc_enabled);
|
||||||
|
assert!(!config.use_worktree);
|
||||||
|
assert!(!config.disable_1m_context);
|
||||||
|
assert!(config.trusted_workspaces.is_empty());
|
||||||
|
assert!(!config.show_thinking_blocks);
|
||||||
|
assert!(config.custom_font_path.is_none());
|
||||||
|
assert!(config.custom_font_family.is_none());
|
||||||
|
assert!(config.custom_ui_font_path.is_none());
|
||||||
|
assert!(config.custom_ui_font_family.is_none());
|
||||||
|
assert!(!config.task_loop_auto_commit);
|
||||||
|
assert_eq!(config.task_loop_commit_prefix, "feat");
|
||||||
|
assert!(!config.task_loop_include_summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -224,7 +371,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,
|
||||||
@@ -232,6 +378,26 @@ mod tests {
|
|||||||
profile_avatar_path: None,
|
profile_avatar_path: None,
|
||||||
profile_bio: Some("A test bio".to_string()),
|
profile_bio: Some("A test bio".to_string()),
|
||||||
custom_theme_colors: CustomThemeColors::default(),
|
custom_theme_colors: CustomThemeColors::default(),
|
||||||
|
budget_enabled: true,
|
||||||
|
session_token_budget: Some(100000),
|
||||||
|
session_cost_budget: Some(1.50),
|
||||||
|
budget_action: BudgetAction::Block,
|
||||||
|
budget_warning_threshold: 0.75,
|
||||||
|
discord_rpc_enabled: true,
|
||||||
|
use_worktree: true,
|
||||||
|
disable_1m_context: false,
|
||||||
|
max_output_tokens: Some(32000),
|
||||||
|
trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()],
|
||||||
|
background_image_path: Some("/home/naomi/bg.png".to_string()),
|
||||||
|
background_image_opacity: 0.25,
|
||||||
|
show_thinking_blocks: true,
|
||||||
|
custom_font_path: Some("/home/naomi/.fonts/MyFont.ttf".to_string()),
|
||||||
|
custom_font_family: Some("MyFont".to_string()),
|
||||||
|
custom_ui_font_path: None,
|
||||||
|
custom_ui_font_family: None,
|
||||||
|
task_loop_auto_commit: true,
|
||||||
|
task_loop_commit_prefix: "fix".to_string(),
|
||||||
|
task_loop_include_summary: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
@@ -246,22 +412,84 @@ mod tests {
|
|||||||
deserialized.greeting_custom_prompt,
|
deserialized.greeting_custom_prompt,
|
||||||
Some("Hello!".to_string())
|
Some("Hello!".to_string())
|
||||||
);
|
);
|
||||||
|
assert!(deserialized.task_loop_auto_commit);
|
||||||
|
assert_eq!(deserialized.task_loop_commit_prefix, "fix");
|
||||||
|
assert!(deserialized.task_loop_include_summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_theme_serialization() {
|
fn test_theme_serialization() {
|
||||||
let dark = Theme::Dark;
|
assert_eq!(serde_json::to_string(&Theme::Dark).unwrap(), "\"dark\"");
|
||||||
let light = Theme::Light;
|
assert_eq!(serde_json::to_string(&Theme::Light).unwrap(), "\"light\"");
|
||||||
let high_contrast = Theme::HighContrast;
|
|
||||||
|
|
||||||
assert_eq!(serde_json::to_string(&dark).unwrap(), "\"dark\"");
|
|
||||||
assert_eq!(serde_json::to_string(&light).unwrap(), "\"light\"");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::to_string(&high_contrast).unwrap(),
|
serde_json::to_string(&Theme::HighContrast).unwrap(),
|
||||||
"\"high-contrast\""
|
"\"high-contrast\""
|
||||||
);
|
);
|
||||||
|
assert_eq!(serde_json::to_string(&Theme::Custom).unwrap(), "\"custom\"");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&Theme::Dracula).unwrap(),
|
||||||
|
"\"dracula\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&Theme::Catppuccin).unwrap(),
|
||||||
|
"\"catppuccin\""
|
||||||
|
);
|
||||||
|
assert_eq!(serde_json::to_string(&Theme::Nord).unwrap(), "\"nord\"");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&Theme::Solarized).unwrap(),
|
||||||
|
"\"solarized\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&Theme::SolarizedLight).unwrap(),
|
||||||
|
"\"solarized-light\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&Theme::CatppuccinLatte).unwrap(),
|
||||||
|
"\"catppuccin-latte\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&Theme::GruvboxLight).unwrap(),
|
||||||
|
"\"gruvbox-light\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&Theme::RosePineDawn).unwrap(),
|
||||||
|
"\"rose-pine-dawn\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let custom = Theme::Custom;
|
#[test]
|
||||||
assert_eq!(serde_json::to_string(&custom).unwrap(), "\"custom\"");
|
fn test_theme_deserialization() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Theme>("\"dracula\"").unwrap(),
|
||||||
|
Theme::Dracula
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Theme>("\"catppuccin\"").unwrap(),
|
||||||
|
Theme::Catppuccin
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Theme>("\"nord\"").unwrap(),
|
||||||
|
Theme::Nord
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Theme>("\"solarized\"").unwrap(),
|
||||||
|
Theme::Solarized
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Theme>("\"solarized-light\"").unwrap(),
|
||||||
|
Theme::SolarizedLight
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Theme>("\"catppuccin-latte\"").unwrap(),
|
||||||
|
Theme::CatppuccinLatte
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Theme>("\"gruvbox-light\"").unwrap(),
|
||||||
|
Theme::GruvboxLight
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Theme>("\"rose-pine-dawn\"").unwrap(),
|
||||||
|
Theme::RosePineDawn
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
use chrono::{Datelike, Local, NaiveDate, Weekday};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Represents a single day's cost data
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct DailyCost {
|
||||||
|
pub date: String, // ISO date string (YYYY-MM-DD)
|
||||||
|
pub input_tokens: u64,
|
||||||
|
pub output_tokens: u64,
|
||||||
|
pub cost_usd: f64,
|
||||||
|
pub messages_sent: u64,
|
||||||
|
pub sessions_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Historical cost tracking data
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct CostHistory {
|
||||||
|
/// Daily costs indexed by date string (YYYY-MM-DD)
|
||||||
|
pub daily_costs: HashMap<String, DailyCost>,
|
||||||
|
/// Cost alert thresholds
|
||||||
|
pub daily_alert_threshold: Option<f64>,
|
||||||
|
pub weekly_alert_threshold: Option<f64>,
|
||||||
|
pub monthly_alert_threshold: Option<f64>,
|
||||||
|
/// Whether alerts have been triggered today
|
||||||
|
pub daily_alert_triggered: bool,
|
||||||
|
pub weekly_alert_triggered: bool,
|
||||||
|
pub monthly_alert_triggered: bool,
|
||||||
|
pub last_alert_reset_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CostHistory {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get today's date as a string
|
||||||
|
fn today_str() -> String {
|
||||||
|
Local::now().format("%Y-%m-%d").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the start of the current week (Monday)
|
||||||
|
fn week_start() -> NaiveDate {
|
||||||
|
let today = Local::now().date_naive();
|
||||||
|
let days_since_monday = today.weekday().num_days_from_monday();
|
||||||
|
today - chrono::Duration::days(days_since_monday as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the start of the current month
|
||||||
|
fn month_start() -> NaiveDate {
|
||||||
|
let today = Local::now().date_naive();
|
||||||
|
NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add cost for today
|
||||||
|
pub fn add_cost(&mut self, input_tokens: u64, output_tokens: u64, cost_usd: f64) {
|
||||||
|
let today = Self::today_str();
|
||||||
|
|
||||||
|
// Reset alert flags if it's a new day
|
||||||
|
if self.last_alert_reset_date.as_ref() != Some(&today) {
|
||||||
|
self.daily_alert_triggered = false;
|
||||||
|
// Reset weekly on Monday
|
||||||
|
if Local::now().weekday() == Weekday::Mon {
|
||||||
|
self.weekly_alert_triggered = false;
|
||||||
|
}
|
||||||
|
// Reset monthly on the 1st
|
||||||
|
if Local::now().day() == 1 {
|
||||||
|
self.monthly_alert_triggered = false;
|
||||||
|
}
|
||||||
|
self.last_alert_reset_date = Some(today.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let daily = self.daily_costs.entry(today).or_default();
|
||||||
|
daily.input_tokens += input_tokens;
|
||||||
|
daily.output_tokens += output_tokens;
|
||||||
|
daily.cost_usd += cost_usd;
|
||||||
|
daily.messages_sent += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Increment session count for today
|
||||||
|
pub fn increment_sessions(&mut self) {
|
||||||
|
let today = Self::today_str();
|
||||||
|
let daily = self.daily_costs.entry(today.clone()).or_insert_with(|| DailyCost {
|
||||||
|
date: today,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
daily.sessions_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get today's cost
|
||||||
|
pub fn get_today_cost(&self) -> f64 {
|
||||||
|
self.daily_costs
|
||||||
|
.get(&Self::today_str())
|
||||||
|
.map(|d| d.cost_usd)
|
||||||
|
.unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get this week's cost (Monday to Sunday)
|
||||||
|
pub fn get_week_cost(&self) -> f64 {
|
||||||
|
let week_start = Self::week_start();
|
||||||
|
self.daily_costs
|
||||||
|
.values()
|
||||||
|
.filter(|d| {
|
||||||
|
NaiveDate::parse_from_str(&d.date, "%Y-%m-%d")
|
||||||
|
.map(|date| date >= week_start)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.map(|d| d.cost_usd)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get this month's cost
|
||||||
|
pub fn get_month_cost(&self) -> f64 {
|
||||||
|
let month_start = Self::month_start();
|
||||||
|
self.daily_costs
|
||||||
|
.values()
|
||||||
|
.filter(|d| {
|
||||||
|
NaiveDate::parse_from_str(&d.date, "%Y-%m-%d")
|
||||||
|
.map(|date| date >= month_start)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.map(|d| d.cost_usd)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cost summary for a date range
|
||||||
|
pub fn get_summary(&self, days: u32) -> CostSummary {
|
||||||
|
let today = Local::now().date_naive();
|
||||||
|
let start_date = today - chrono::Duration::days(days as i64 - 1);
|
||||||
|
|
||||||
|
let mut total_input_tokens = 0u64;
|
||||||
|
let mut total_output_tokens = 0u64;
|
||||||
|
let mut total_cost = 0.0f64;
|
||||||
|
let mut total_messages = 0u64;
|
||||||
|
let mut total_sessions = 0u64;
|
||||||
|
let mut daily_breakdown = Vec::new();
|
||||||
|
|
||||||
|
for i in 0..days {
|
||||||
|
let date = start_date + chrono::Duration::days(i as i64);
|
||||||
|
let date_str = date.format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
|
if let Some(daily) = self.daily_costs.get(&date_str) {
|
||||||
|
total_input_tokens += daily.input_tokens;
|
||||||
|
total_output_tokens += daily.output_tokens;
|
||||||
|
total_cost += daily.cost_usd;
|
||||||
|
total_messages += daily.messages_sent;
|
||||||
|
total_sessions += daily.sessions_count;
|
||||||
|
daily_breakdown.push(daily.clone());
|
||||||
|
} else {
|
||||||
|
daily_breakdown.push(DailyCost {
|
||||||
|
date: date_str,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CostSummary {
|
||||||
|
period_days: days,
|
||||||
|
total_input_tokens,
|
||||||
|
total_output_tokens,
|
||||||
|
total_cost,
|
||||||
|
total_messages,
|
||||||
|
total_sessions,
|
||||||
|
average_daily_cost: if days > 0 { total_cost / days as f64 } else { 0.0 },
|
||||||
|
daily_breakdown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if any alert thresholds are exceeded and return which ones
|
||||||
|
pub fn check_alerts(&mut self) -> Vec<CostAlert> {
|
||||||
|
let mut alerts = Vec::new();
|
||||||
|
|
||||||
|
if let Some(threshold) = self.daily_alert_threshold {
|
||||||
|
let today_cost = self.get_today_cost();
|
||||||
|
if today_cost >= threshold && !self.daily_alert_triggered {
|
||||||
|
self.daily_alert_triggered = true;
|
||||||
|
alerts.push(CostAlert {
|
||||||
|
alert_type: AlertType::Daily,
|
||||||
|
threshold,
|
||||||
|
current_cost: today_cost,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(threshold) = self.weekly_alert_threshold {
|
||||||
|
let week_cost = self.get_week_cost();
|
||||||
|
if week_cost >= threshold && !self.weekly_alert_triggered {
|
||||||
|
self.weekly_alert_triggered = true;
|
||||||
|
alerts.push(CostAlert {
|
||||||
|
alert_type: AlertType::Weekly,
|
||||||
|
threshold,
|
||||||
|
current_cost: week_cost,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(threshold) = self.monthly_alert_threshold {
|
||||||
|
let month_cost = self.get_month_cost();
|
||||||
|
if month_cost >= threshold && !self.monthly_alert_triggered {
|
||||||
|
self.monthly_alert_triggered = true;
|
||||||
|
alerts.push(CostAlert {
|
||||||
|
alert_type: AlertType::Monthly,
|
||||||
|
threshold,
|
||||||
|
current_cost: month_cost,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alerts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set alert thresholds
|
||||||
|
pub fn set_alert_thresholds(
|
||||||
|
&mut self,
|
||||||
|
daily: Option<f64>,
|
||||||
|
weekly: Option<f64>,
|
||||||
|
monthly: Option<f64>,
|
||||||
|
) {
|
||||||
|
self.daily_alert_threshold = daily;
|
||||||
|
self.weekly_alert_threshold = weekly;
|
||||||
|
self.monthly_alert_threshold = monthly;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up old data (keep last N days)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn cleanup_old_data(&mut self, keep_days: u32) {
|
||||||
|
let cutoff = Local::now().date_naive() - chrono::Duration::days(keep_days as i64);
|
||||||
|
self.daily_costs.retain(|date_str, _| {
|
||||||
|
NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
||||||
|
.map(|date| date >= cutoff)
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export to CSV format
|
||||||
|
pub fn export_csv(&self, days: u32) -> String {
|
||||||
|
let summary = self.get_summary(days);
|
||||||
|
let mut csv = String::from("Date,Input Tokens,Output Tokens,Cost (USD),Messages,Sessions\n");
|
||||||
|
|
||||||
|
for daily in &summary.daily_breakdown {
|
||||||
|
csv.push_str(&format!(
|
||||||
|
"{},{},{},{:.4},{},{}\n",
|
||||||
|
daily.date,
|
||||||
|
daily.input_tokens,
|
||||||
|
daily.output_tokens,
|
||||||
|
daily.cost_usd,
|
||||||
|
daily.messages_sent,
|
||||||
|
daily.sessions_count
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add totals row
|
||||||
|
csv.push_str(&format!(
|
||||||
|
"TOTAL,{},{},{:.4},{},{}\n",
|
||||||
|
summary.total_input_tokens,
|
||||||
|
summary.total_output_tokens,
|
||||||
|
summary.total_cost,
|
||||||
|
summary.total_messages,
|
||||||
|
summary.total_sessions
|
||||||
|
));
|
||||||
|
|
||||||
|
csv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cost summary for a period
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CostSummary {
|
||||||
|
pub period_days: u32,
|
||||||
|
pub total_input_tokens: u64,
|
||||||
|
pub total_output_tokens: u64,
|
||||||
|
pub total_cost: f64,
|
||||||
|
pub total_messages: u64,
|
||||||
|
pub total_sessions: u64,
|
||||||
|
pub average_daily_cost: f64,
|
||||||
|
pub daily_breakdown: Vec<DailyCost>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alert types
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum AlertType {
|
||||||
|
Daily,
|
||||||
|
Weekly,
|
||||||
|
Monthly,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cost alert notification
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CostAlert {
|
||||||
|
pub alert_type: AlertType,
|
||||||
|
pub threshold: f64,
|
||||||
|
pub current_cost: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_cost() {
|
||||||
|
let mut history = CostHistory::new();
|
||||||
|
history.add_cost(1000, 500, 0.05);
|
||||||
|
|
||||||
|
let today_cost = history.get_today_cost();
|
||||||
|
assert!((today_cost - 0.05).abs() < 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_accumulate_daily_cost() {
|
||||||
|
let mut history = CostHistory::new();
|
||||||
|
history.add_cost(1000, 500, 0.05);
|
||||||
|
history.add_cost(2000, 1000, 0.10);
|
||||||
|
|
||||||
|
let today_cost = history.get_today_cost();
|
||||||
|
assert!((today_cost - 0.15).abs() < 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_summary() {
|
||||||
|
let mut history = CostHistory::new();
|
||||||
|
history.add_cost(1000, 500, 0.05);
|
||||||
|
|
||||||
|
let summary = history.get_summary(7);
|
||||||
|
assert_eq!(summary.period_days, 7);
|
||||||
|
assert!((summary.total_cost - 0.05).abs() < 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_daily_alert() {
|
||||||
|
let mut history = CostHistory::new();
|
||||||
|
history.set_alert_thresholds(Some(0.10), None, None);
|
||||||
|
|
||||||
|
history.add_cost(1000, 500, 0.05);
|
||||||
|
let alerts = history.check_alerts();
|
||||||
|
assert!(alerts.is_empty());
|
||||||
|
|
||||||
|
history.add_cost(1000, 500, 0.06);
|
||||||
|
let alerts = history.check_alerts();
|
||||||
|
assert_eq!(alerts.len(), 1);
|
||||||
|
assert_eq!(alerts[0].alert_type, AlertType::Daily);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_alert_only_triggers_once() {
|
||||||
|
let mut history = CostHistory::new();
|
||||||
|
history.set_alert_thresholds(Some(0.10), None, None);
|
||||||
|
|
||||||
|
history.add_cost(1000, 500, 0.15);
|
||||||
|
let alerts = history.check_alerts();
|
||||||
|
assert_eq!(alerts.len(), 1);
|
||||||
|
|
||||||
|
// Second check should not trigger again
|
||||||
|
let alerts = history.check_alerts();
|
||||||
|
assert!(alerts.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_export_csv() {
|
||||||
|
let mut history = CostHistory::new();
|
||||||
|
history.add_cost(1000, 500, 0.05);
|
||||||
|
|
||||||
|
let csv = history.export_csv(1);
|
||||||
|
assert!(csv.contains("Date,Input Tokens"));
|
||||||
|
assert!(csv.contains("TOTAL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_increment_sessions() {
|
||||||
|
let mut history = CostHistory::new();
|
||||||
|
history.increment_sessions();
|
||||||
|
history.increment_sessions();
|
||||||
|
|
||||||
|
let summary = history.get_summary(1);
|
||||||
|
assert_eq!(summary.total_sessions, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
use discord_rich_presence::activity::{Activity, Assets, Timestamps};
|
||||||
|
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct DiscordRpcManager {
|
||||||
|
client: Arc<RwLock<Option<DiscordIpcClient>>>,
|
||||||
|
session_name: Arc<RwLock<String>>,
|
||||||
|
model: Arc<RwLock<String>>,
|
||||||
|
started_at: Arc<RwLock<i64>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiscordRpcManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
client: Arc::new(RwLock::new(None)),
|
||||||
|
session_name: Arc::new(RwLock::new(String::new())),
|
||||||
|
model: Arc::new(RwLock::new(String::new())),
|
||||||
|
started_at: Arc::new(RwLock::new(0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init(&self, initial_session_name: String, initial_model: String, started_at: i64) -> Result<(), String> {
|
||||||
|
tracing::debug!("Attempting to initialize Discord RPC...");
|
||||||
|
tracing::debug!("Application ID: 1391117878182281316");
|
||||||
|
tracing::debug!("Initial session: '{}', model: '{}', timestamp: {}",
|
||||||
|
initial_session_name, initial_model, started_at);
|
||||||
|
|
||||||
|
let mut client = DiscordIpcClient::new("1391117878182281316")
|
||||||
|
.map_err(|e| {
|
||||||
|
let error_msg = format!("Failed to create Discord RPC client: {} (is Discord running?)", e);
|
||||||
|
tracing::error!("{}", error_msg);
|
||||||
|
error_msg
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::debug!("DiscordIpcClient created successfully");
|
||||||
|
|
||||||
|
client
|
||||||
|
.connect()
|
||||||
|
.map_err(|e| {
|
||||||
|
let error_msg = format!("Failed to connect to Discord RPC: {} (ensure Discord is running)", e);
|
||||||
|
tracing::error!("{}", error_msg);
|
||||||
|
error_msg
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::debug!("Connected to Discord IPC socket");
|
||||||
|
|
||||||
|
// Set initial activity immediately after connecting
|
||||||
|
tracing::debug!("Building initial activity...");
|
||||||
|
let state_text = format!("Model: {}", initial_model);
|
||||||
|
let assets = Assets::new()
|
||||||
|
.large_image("hikari")
|
||||||
|
.large_text("Hikari - Claude Code Assistant");
|
||||||
|
|
||||||
|
tracing::debug!("Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
|
||||||
|
|
||||||
|
let timestamps = Timestamps::new()
|
||||||
|
.start(started_at);
|
||||||
|
|
||||||
|
tracing::debug!("Timestamps created - start: {}", started_at);
|
||||||
|
|
||||||
|
let activity = Activity::new()
|
||||||
|
.details(initial_session_name.as_str())
|
||||||
|
.state(state_text.as_str())
|
||||||
|
.assets(assets)
|
||||||
|
.timestamps(timestamps);
|
||||||
|
|
||||||
|
tracing::debug!("Activity created - details: '{}', state: '{}'",
|
||||||
|
initial_session_name, state_text);
|
||||||
|
|
||||||
|
tracing::debug!("Attempting to set initial activity...");
|
||||||
|
client
|
||||||
|
.set_activity(activity)
|
||||||
|
.map_err(|e| {
|
||||||
|
let error_msg = format!("Failed to set initial Discord RPC activity: {}", e);
|
||||||
|
tracing::error!("{}", error_msg);
|
||||||
|
error_msg
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::debug!("Initial activity set successfully!");
|
||||||
|
|
||||||
|
// Store the client and initial state
|
||||||
|
*self.client.write() = Some(client);
|
||||||
|
*self.session_name.write() = initial_session_name.clone();
|
||||||
|
*self.model.write() = initial_model.clone();
|
||||||
|
*self.started_at.write() = started_at;
|
||||||
|
|
||||||
|
tracing::info!("Discord RPC connected successfully with initial activity: session='{}', model='{}'",
|
||||||
|
initial_session_name, initial_model);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(
|
||||||
|
&self,
|
||||||
|
session_name: String,
|
||||||
|
model: String,
|
||||||
|
started_at: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
tracing::debug!("update() called with session='{}', model='{}', timestamp={}",
|
||||||
|
session_name, model, started_at);
|
||||||
|
|
||||||
|
*self.session_name.write() = session_name.clone();
|
||||||
|
*self.model.write() = model.clone();
|
||||||
|
*self.started_at.write() = started_at;
|
||||||
|
|
||||||
|
tracing::debug!("State variables updated");
|
||||||
|
|
||||||
|
let mut client_guard = self.client.write();
|
||||||
|
let client = client_guard
|
||||||
|
.as_mut()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
let error_msg = "Discord RPC client not initialized".to_string();
|
||||||
|
tracing::error!("{}", error_msg);
|
||||||
|
error_msg
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::debug!("Client lock acquired");
|
||||||
|
|
||||||
|
let state_text = format!("Model: {}", model);
|
||||||
|
let assets = Assets::new()
|
||||||
|
.large_image("hikari")
|
||||||
|
.large_text("Hikari - Claude Code Assistant");
|
||||||
|
|
||||||
|
tracing::debug!("Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
|
||||||
|
|
||||||
|
let timestamps = Timestamps::new()
|
||||||
|
.start(started_at);
|
||||||
|
|
||||||
|
tracing::debug!("Timestamps created - start: {}", started_at);
|
||||||
|
|
||||||
|
let activity = Activity::new()
|
||||||
|
.details(session_name.as_str())
|
||||||
|
.state(state_text.as_str())
|
||||||
|
.assets(assets)
|
||||||
|
.timestamps(timestamps);
|
||||||
|
|
||||||
|
tracing::debug!("Activity created - details: '{}', state: '{}'",
|
||||||
|
session_name, state_text);
|
||||||
|
|
||||||
|
tracing::debug!("Attempting to set activity...");
|
||||||
|
client
|
||||||
|
.set_activity(activity)
|
||||||
|
.map_err(|e| {
|
||||||
|
let error_msg = format!("Failed to update Discord RPC: {}", e);
|
||||||
|
tracing::error!("{}", error_msg);
|
||||||
|
error_msg
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::info!("Updated Discord RPC: session='{}', model='{}'", session_name, model);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&self) -> Result<(), String> {
|
||||||
|
tracing::debug!("stop() called");
|
||||||
|
|
||||||
|
let mut client_guard = self.client.write();
|
||||||
|
if let Some(mut client) = client_guard.take() {
|
||||||
|
tracing::debug!("Client found, attempting to close...");
|
||||||
|
client
|
||||||
|
.close()
|
||||||
|
.map_err(|e| {
|
||||||
|
let error_msg = format!("Failed to close Discord RPC: {}", e);
|
||||||
|
tracing::error!("{}", error_msg);
|
||||||
|
error_msg
|
||||||
|
})?;
|
||||||
|
tracing::info!("Discord RPC stopped successfully");
|
||||||
|
} else {
|
||||||
|
tracing::debug!("No client to stop (already stopped or never initialized)");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DiscordRpcManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri_plugin_store::StoreExt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const DRAFTS_STORE_FILE: &str = "hikari-drafts.json";
|
||||||
|
const DRAFTS_STORE_KEY: &str = "drafts";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Draft {
|
||||||
|
pub id: String,
|
||||||
|
pub content: String,
|
||||||
|
pub saved_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_all_drafts(app: &AppHandle) -> Result<Vec<Draft>, String> {
|
||||||
|
let store = app
|
||||||
|
.store(DRAFTS_STORE_FILE)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
match store.get(DRAFTS_STORE_KEY) {
|
||||||
|
Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()),
|
||||||
|
None => Ok(vec![]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_all_drafts(app: &AppHandle, drafts: &[Draft]) -> Result<(), String> {
|
||||||
|
let store = app
|
||||||
|
.store(DRAFTS_STORE_FILE)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let value = serde_json::to_value(drafts).map_err(|e| e.to_string())?;
|
||||||
|
store.set(DRAFTS_STORE_KEY, value);
|
||||||
|
store.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_drafts(app: AppHandle) -> Result<Vec<Draft>, String> {
|
||||||
|
let mut drafts = load_all_drafts(&app)?;
|
||||||
|
// Sort newest first — ISO 8601 timestamps sort lexicographically
|
||||||
|
drafts.sort_by(|a, b| b.saved_at.cmp(&a.saved_at));
|
||||||
|
Ok(drafts)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_draft(app: AppHandle, content: String) -> Result<Draft, String> {
|
||||||
|
let mut drafts = load_all_drafts(&app)?;
|
||||||
|
|
||||||
|
let draft = Draft {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
content,
|
||||||
|
saved_at: Utc::now().to_rfc3339(),
|
||||||
|
};
|
||||||
|
|
||||||
|
drafts.push(draft.clone());
|
||||||
|
save_all_drafts(&app, &drafts)?;
|
||||||
|
|
||||||
|
Ok(draft)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_draft(app: AppHandle, draft_id: String) -> Result<(), String> {
|
||||||
|
let mut drafts = load_all_drafts(&app)?;
|
||||||
|
drafts.retain(|d| d.id != draft_id);
|
||||||
|
save_all_drafts(&app, &drafts)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_all_drafts(app: AppHandle) -> Result<(), String> {
|
||||||
|
save_all_drafts(&app, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_draft(id: &str, content: &str, saved_at: &str) -> Draft {
|
||||||
|
Draft {
|
||||||
|
id: id.to_string(),
|
||||||
|
content: content.to_string(),
|
||||||
|
saved_at: saved_at.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_draft_serialization() {
|
||||||
|
let draft = make_draft("test-id", "Hello world", "2026-01-01T00:00:00+00:00");
|
||||||
|
let json = serde_json::to_string(&draft).expect("Failed to serialize");
|
||||||
|
let parsed: Draft = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(parsed.id, draft.id);
|
||||||
|
assert_eq!(parsed.content, draft.content);
|
||||||
|
assert_eq!(parsed.saved_at, draft.saved_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_draft_clone() {
|
||||||
|
let original = make_draft("clone-id", "Clone me", "2026-01-01T00:00:00+00:00");
|
||||||
|
let cloned = original.clone();
|
||||||
|
|
||||||
|
assert_eq!(original.id, cloned.id);
|
||||||
|
assert_eq!(original.content, cloned.content);
|
||||||
|
assert_eq!(original.saved_at, cloned.saved_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sort_newest_first() {
|
||||||
|
let mut drafts = [
|
||||||
|
make_draft("a", "First", "2026-01-01T00:00:00+00:00"),
|
||||||
|
make_draft("b", "Third", "2026-01-03T00:00:00+00:00"),
|
||||||
|
make_draft("c", "Second", "2026-01-02T00:00:00+00:00"),
|
||||||
|
];
|
||||||
|
|
||||||
|
drafts.sort_by(|a, b| b.saved_at.cmp(&a.saved_at));
|
||||||
|
|
||||||
|
assert_eq!(drafts[0].id, "b");
|
||||||
|
assert_eq!(drafts[1].id, "c");
|
||||||
|
assert_eq!(drafts[2].id, "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_retain_excludes_deleted() {
|
||||||
|
let mut drafts = vec![
|
||||||
|
make_draft("keep-1", "Keep me", "2026-01-01T00:00:00+00:00"),
|
||||||
|
make_draft("delete-me", "Delete me", "2026-01-02T00:00:00+00:00"),
|
||||||
|
make_draft("keep-2", "Keep me too", "2026-01-03T00:00:00+00:00"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let target_id = "delete-me".to_string();
|
||||||
|
drafts.retain(|d| d.id != target_id);
|
||||||
|
|
||||||
|
assert_eq!(drafts.len(), 2);
|
||||||
|
assert!(drafts.iter().all(|d| d.id != "delete-me"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_by_id() {
|
||||||
|
let drafts = [
|
||||||
|
make_draft("draft-1", "First draft", "2026-01-01T00:00:00+00:00"),
|
||||||
|
make_draft("draft-2", "Second draft", "2026-01-02T00:00:00+00:00"),
|
||||||
|
make_draft("draft-3", "Third draft", "2026-01-03T00:00:00+00:00"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let found = drafts.iter().find(|d| d.id == "draft-2");
|
||||||
|
assert!(found.is_some());
|
||||||
|
assert_eq!(found.unwrap().content, "Second draft");
|
||||||
|
|
||||||
|
let not_found = drafts.iter().find(|d| d.id == "draft-999");
|
||||||
|
assert!(not_found.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiline_content() {
|
||||||
|
let content = "Line 1\nLine 2\nLine 3";
|
||||||
|
let draft = make_draft("multi", content, "2026-01-01T00:00:00+00:00");
|
||||||
|
|
||||||
|
assert!(draft.content.contains('\n'));
|
||||||
|
assert_eq!(draft.content.split('\n').count(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_after_delete_all() {
|
||||||
|
let mut drafts = vec![
|
||||||
|
make_draft("a", "A", "2026-01-01T00:00:00+00:00"),
|
||||||
|
make_draft("b", "B", "2026-01-02T00:00:00+00:00"),
|
||||||
|
];
|
||||||
|
|
||||||
|
drafts.clear();
|
||||||
|
|
||||||
|
assert!(drafts.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_uuid_format() {
|
||||||
|
// UUIDs should be non-empty and contain hyphens
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
assert!(!id.is_empty());
|
||||||
|
assert!(id.contains('-'));
|
||||||
|
assert_eq!(id.len(), 36);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_timestamp_is_rfc3339() {
|
||||||
|
let ts = Utc::now().to_rfc3339();
|
||||||
|
// RFC 3339 timestamps contain T and + or Z
|
||||||
|
assert!(ts.contains('T'));
|
||||||
|
assert!(ts.ends_with("+00:00") || ts.ends_with('Z'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
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,
|
||||||
@@ -35,7 +38,37 @@ pub struct GitLogEntry {
|
|||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the WSL argument list for running a git command at a Linux path.
|
||||||
|
/// Extracted for testability without requiring WSL to be available.
|
||||||
|
#[cfg(any(target_os = "windows", test))]
|
||||||
|
fn build_wsl_git_args<'a>(working_dir: &'a str, args: &[&'a str]) -> Vec<&'a str> {
|
||||||
|
let mut wsl_args = vec!["--", "git", "-C", working_dir];
|
||||||
|
wsl_args.extend_from_slice(args);
|
||||||
|
wsl_args
|
||||||
|
}
|
||||||
|
|
||||||
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
|
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let output = {
|
||||||
|
if working_dir.starts_with('/') {
|
||||||
|
// WSL/Linux path — run git through WSL so it can resolve the path correctly.
|
||||||
|
let wsl_args = build_wsl_git_args(working_dir, args);
|
||||||
|
Command::new("wsl")
|
||||||
|
.hide_window()
|
||||||
|
.args(&wsl_args)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to execute git via WSL: {}", e))?
|
||||||
|
} else {
|
||||||
|
Command::new("git")
|
||||||
|
.hide_window()
|
||||||
|
.args(args)
|
||||||
|
.current_dir(working_dir)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to execute git: {}", e))?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
.args(args)
|
.args(args)
|
||||||
.current_dir(working_dir)
|
.current_dir(working_dir)
|
||||||
@@ -294,6 +327,26 @@ mod tests {
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
// ==================== build_wsl_git_args tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_wsl_git_args_structure() {
|
||||||
|
let args = build_wsl_git_args("/home/naomi/code/project", &["status", "--porcelain=v1"]);
|
||||||
|
assert_eq!(args[0], "--");
|
||||||
|
assert_eq!(args[1], "git");
|
||||||
|
assert_eq!(args[2], "-C");
|
||||||
|
assert_eq!(args[3], "/home/naomi/code/project");
|
||||||
|
assert_eq!(args[4], "status");
|
||||||
|
assert_eq!(args[5], "--porcelain=v1");
|
||||||
|
assert_eq!(args.len(), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_wsl_git_args_no_extra_args() {
|
||||||
|
let args = build_wsl_git_args("/home/user/repo", &["init"]);
|
||||||
|
assert_eq!(args, vec!["--", "git", "-C", "/home/user/repo", "init"]);
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to create a git repository in a temp directory
|
// Helper to create a git repository in a temp directory
|
||||||
fn create_test_repo() -> TempDir {
|
fn create_test_repo() -> TempDir {
|
||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -3,13 +3,19 @@ mod bridge_manager;
|
|||||||
mod clipboard;
|
mod clipboard;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod cost_tracking;
|
||||||
|
mod debug_logger;
|
||||||
|
mod discord_rpc;
|
||||||
|
mod drafts;
|
||||||
mod git;
|
mod git;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
|
mod process_ext;
|
||||||
mod quick_actions;
|
mod quick_actions;
|
||||||
mod sessions;
|
mod sessions;
|
||||||
mod snippets;
|
mod snippets;
|
||||||
mod stats;
|
mod stats;
|
||||||
mod temp_manager;
|
mod temp_manager;
|
||||||
|
mod tool_cache;
|
||||||
mod tray;
|
mod tray;
|
||||||
mod types;
|
mod types;
|
||||||
mod vbs_notification;
|
mod vbs_notification;
|
||||||
@@ -21,14 +27,20 @@ use bridge_manager::create_shared_bridge_manager;
|
|||||||
use clipboard::*;
|
use clipboard::*;
|
||||||
use commands::load_saved_achievements;
|
use commands::load_saved_achievements;
|
||||||
use commands::*;
|
use commands::*;
|
||||||
|
use debug_logger::TauriLogLayer;
|
||||||
|
use discord_rpc::DiscordRpcManager;
|
||||||
|
use drafts::*;
|
||||||
use git::*;
|
use git::*;
|
||||||
use notifications::*;
|
use notifications::*;
|
||||||
use quick_actions::*;
|
use quick_actions::*;
|
||||||
use sessions::*;
|
use sessions::*;
|
||||||
use snippets::*;
|
use snippets::*;
|
||||||
use tauri::Manager;
|
use std::sync::Arc;
|
||||||
|
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::*;
|
||||||
@@ -37,6 +49,7 @@ use wsl_notifications::*;
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
let bridge_manager = create_shared_bridge_manager();
|
let bridge_manager = create_shared_bridge_manager();
|
||||||
let temp_manager = create_shared_temp_manager().expect("Failed to create temp file manager");
|
let temp_manager = create_shared_temp_manager().expect("Failed to create temp file manager");
|
||||||
|
let discord_rpc = Arc::new(DiscordRpcManager::new());
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
@@ -50,33 +63,45 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.manage(bridge_manager.clone())
|
.manage(bridge_manager.clone())
|
||||||
.manage(temp_manager.clone())
|
.manage(temp_manager.clone())
|
||||||
|
.manage(discord_rpc.clone())
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
|
// Initialize tracing with custom layer that emits to frontend
|
||||||
|
// NOTE: We don't use fmt::layer() because in production builds with windows_subsystem = "windows",
|
||||||
|
// stdout is hidden. Instead, all logs go through TauriLogLayer to the debug console.
|
||||||
|
let tauri_layer = TauriLogLayer::new(app.handle().clone());
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(tauri_layer)
|
||||||
|
.init();
|
||||||
|
|
||||||
// Initialize the app handle in the bridge manager
|
// Initialize the app handle in the bridge manager
|
||||||
bridge_manager.lock().set_app_handle(app.handle().clone());
|
bridge_manager.lock().set_app_handle(app.handle().clone());
|
||||||
|
|
||||||
// Clean up any orphaned temp files from previous sessions
|
// Clean up any orphaned temp files from previous sessions
|
||||||
if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() {
|
if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() {
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
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", ());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,6 +123,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,
|
||||||
@@ -107,6 +133,7 @@ pub fn run() {
|
|||||||
validate_directory,
|
validate_directory,
|
||||||
list_skills,
|
list_skills,
|
||||||
check_for_updates,
|
check_for_updates,
|
||||||
|
fetch_changelog,
|
||||||
save_temp_file,
|
save_temp_file,
|
||||||
register_temp_file,
|
register_temp_file,
|
||||||
get_temp_files,
|
get_temp_files,
|
||||||
@@ -151,6 +178,50 @@ pub fn run() {
|
|||||||
search_clipboard_entries,
|
search_clipboard_entries,
|
||||||
get_clipboard_languages,
|
get_clipboard_languages,
|
||||||
update_clipboard_language,
|
update_clipboard_language,
|
||||||
|
list_directory,
|
||||||
|
read_file_content,
|
||||||
|
write_file_content,
|
||||||
|
create_file,
|
||||||
|
create_directory,
|
||||||
|
delete_file,
|
||||||
|
delete_directory,
|
||||||
|
rename_path,
|
||||||
|
// Cost tracking commands
|
||||||
|
get_cost_summary,
|
||||||
|
get_cost_alerts,
|
||||||
|
set_cost_alert_thresholds,
|
||||||
|
export_cost_csv,
|
||||||
|
get_today_cost,
|
||||||
|
get_week_cost,
|
||||||
|
get_month_cost,
|
||||||
|
init_discord_rpc,
|
||||||
|
update_discord_rpc,
|
||||||
|
stop_discord_rpc,
|
||||||
|
close_application,
|
||||||
|
list_memory_files,
|
||||||
|
get_claude_version,
|
||||||
|
get_auth_status,
|
||||||
|
auth_login,
|
||||||
|
auth_logout,
|
||||||
|
list_plugins,
|
||||||
|
install_plugin,
|
||||||
|
uninstall_plugin,
|
||||||
|
enable_plugin,
|
||||||
|
disable_plugin,
|
||||||
|
update_plugin,
|
||||||
|
list_marketplaces,
|
||||||
|
add_marketplace,
|
||||||
|
remove_marketplace,
|
||||||
|
list_mcp_servers,
|
||||||
|
get_mcp_server,
|
||||||
|
remove_mcp_server,
|
||||||
|
add_mcp_server,
|
||||||
|
get_mcp_server_details,
|
||||||
|
list_drafts,
|
||||||
|
save_draft,
|
||||||
|
delete_draft,
|
||||||
|
delete_all_drafts,
|
||||||
|
scan_project,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,113 @@ use std::collections::HashMap;
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tauri_plugin_store::StoreExt;
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
|
/// Per-tool token usage statistics
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ToolTokenStats {
|
||||||
|
pub call_count: u64,
|
||||||
|
pub estimated_input_tokens: u64,
|
||||||
|
pub estimated_output_tokens: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolTokenStats {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn increment_call(&mut self) {
|
||||||
|
self.call_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_tokens(&mut self, input: u64, output: u64) {
|
||||||
|
self.estimated_input_tokens += input;
|
||||||
|
self.estimated_output_tokens += output;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn total_tokens(&self) -> u64 {
|
||||||
|
self.estimated_input_tokens + self.estimated_output_tokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warning levels for context window utilisation
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ContextWarning {
|
||||||
|
/// 50-74% utilisation - conversation is getting long
|
||||||
|
Moderate,
|
||||||
|
/// 75-89% utilisation - consider summarising
|
||||||
|
High,
|
||||||
|
/// 90%+ utilisation - approaching limit
|
||||||
|
Critical,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Budget status indicating whether user is within their limits
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum BudgetStatus {
|
||||||
|
/// Within budget, no concerns
|
||||||
|
Ok,
|
||||||
|
/// Approaching budget limit (warning threshold reached)
|
||||||
|
Warning {
|
||||||
|
budget_type: BudgetType,
|
||||||
|
percent_used: f32,
|
||||||
|
},
|
||||||
|
/// Budget exceeded
|
||||||
|
Exceeded { budget_type: BudgetType },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type of budget limit
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum BudgetType {
|
||||||
|
Token,
|
||||||
|
Cost,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContextWarning {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn message(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ContextWarning::Moderate => "Context window is 50%+ full. Consider starting a new conversation for better performance.",
|
||||||
|
ContextWarning::High => "Context window is 75%+ full. Responses may degrade. Consider summarising or starting fresh.",
|
||||||
|
ContextWarning::Critical => "Context window is nearly full (90%+)! Start a new conversation to avoid errors.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the context window limit (in tokens) for a given model
|
||||||
|
fn get_context_window_limit(model: &str) -> u64 {
|
||||||
|
match model {
|
||||||
|
// Claude 4.6 family
|
||||||
|
"claude-opus-4-6" => 200_000,
|
||||||
|
"claude-sonnet-4-6" => 1_000_000, // 1M token context window
|
||||||
|
// Claude 4.5 family - 200K standard context
|
||||||
|
"claude-opus-4-5-20251101"
|
||||||
|
| "claude-sonnet-4-5-20250929"
|
||||||
|
| "claude-haiku-4-5-20251001" => 200_000,
|
||||||
|
// Claude 4.x family - 200K standard context
|
||||||
|
"claude-opus-4-1-20250805"
|
||||||
|
| "claude-opus-4-20250514"
|
||||||
|
| "claude-sonnet-4-20250514" => 200_000,
|
||||||
|
// Claude 3.x family
|
||||||
|
"claude-3-7-sonnet-20250219"
|
||||||
|
| "claude-3-5-sonnet-20241022"
|
||||||
|
| "claude-3-5-sonnet-20240620"
|
||||||
|
| "claude-3-5-haiku-20241022"
|
||||||
|
| "claude-3-opus-20240229"
|
||||||
|
| "claude-3-sonnet-20240229"
|
||||||
|
| "claude-3-haiku-20240307" => 200_000,
|
||||||
|
// Default to 200K for unknown Claude models
|
||||||
|
_ if model.starts_with("claude") => 200_000,
|
||||||
|
// For non-Claude models (Ollama, etc.), use a conservative default
|
||||||
|
_ => 128_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct UsageStats {
|
pub struct UsageStats {
|
||||||
pub total_input_tokens: u64,
|
pub total_input_tokens: u64,
|
||||||
@@ -24,8 +131,8 @@ pub struct UsageStats {
|
|||||||
pub session_files_edited: u64,
|
pub session_files_edited: u64,
|
||||||
pub files_created: u64,
|
pub files_created: u64,
|
||||||
pub session_files_created: u64,
|
pub session_files_created: u64,
|
||||||
pub tools_usage: HashMap<String, u64>,
|
pub tools_usage: HashMap<String, ToolTokenStats>,
|
||||||
pub session_tools_usage: HashMap<String, u64>,
|
pub session_tools_usage: HashMap<String, ToolTokenStats>,
|
||||||
pub session_duration_seconds: u64,
|
pub session_duration_seconds: u64,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub session_start: Option<Instant>,
|
pub session_start: Option<Instant>,
|
||||||
@@ -38,9 +145,28 @@ pub struct UsageStats {
|
|||||||
pub night_sessions: u64, // Sessions started after 10 PM
|
pub night_sessions: u64, // Sessions started after 10 PM
|
||||||
pub last_session_date: Option<String>, // ISO date string for streak tracking
|
pub last_session_date: Option<String>, // ISO date string for streak tracking
|
||||||
|
|
||||||
|
// Context window tracking
|
||||||
|
pub context_tokens_used: u64,
|
||||||
|
pub context_window_limit: u64,
|
||||||
|
pub context_utilisation_percent: f32,
|
||||||
|
|
||||||
|
// Cache analytics (tracks potential savings from repeated tool calls)
|
||||||
|
pub potential_cache_hits: u64,
|
||||||
|
pub potential_cache_savings_tokens: u64,
|
||||||
|
|
||||||
// Achievement tracking
|
// Achievement tracking
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub achievements: AchievementProgress,
|
pub achievements: AchievementProgress,
|
||||||
|
|
||||||
|
// Track current in-flight request for cost estimation on interrupt
|
||||||
|
#[serde(skip)]
|
||||||
|
pub current_request_input: Option<String>,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub current_request_output_chars: u64,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub current_request_thinking_chars: u64,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub current_request_tools: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UsageStats {
|
impl UsageStats {
|
||||||
@@ -50,17 +176,138 @@ 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;
|
||||||
|
|
||||||
self.model = Some(model.to_string());
|
self.model = Some(model.to_string());
|
||||||
|
|
||||||
|
// Update context window tracking
|
||||||
|
self.update_context_tracking(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_context_tracking(&mut self, model: &str) {
|
||||||
|
// Get context window limit for the current model
|
||||||
|
self.context_window_limit = get_context_window_limit(model);
|
||||||
|
|
||||||
|
// Context tokens = input tokens (the prompt/context sent to the model)
|
||||||
|
// We track cumulative session input as a proxy for context growth
|
||||||
|
self.context_tokens_used = self.session_input_tokens;
|
||||||
|
|
||||||
|
// Calculate utilisation percentage
|
||||||
|
if self.context_window_limit > 0 {
|
||||||
|
self.context_utilisation_percent =
|
||||||
|
(self.context_tokens_used as f32 / self.context_window_limit as f32) * 100.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn get_context_warning(&self) -> Option<ContextWarning> {
|
||||||
|
if self.context_utilisation_percent >= 90.0 {
|
||||||
|
Some(ContextWarning::Critical)
|
||||||
|
} else if self.context_utilisation_percent >= 75.0 {
|
||||||
|
Some(ContextWarning::High)
|
||||||
|
} else if self.context_utilisation_percent >= 50.0 {
|
||||||
|
Some(ContextWarning::Moderate)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn estimate_remaining_tokens(&self) -> u64 {
|
||||||
|
self.context_window_limit
|
||||||
|
.saturating_sub(self.context_tokens_used)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check budget status given current usage and budget settings
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn check_budget(
|
||||||
|
&self,
|
||||||
|
budget_enabled: bool,
|
||||||
|
token_budget: Option<u64>,
|
||||||
|
cost_budget: Option<f64>,
|
||||||
|
warning_threshold: f32,
|
||||||
|
) -> BudgetStatus {
|
||||||
|
if !budget_enabled {
|
||||||
|
return BudgetStatus::Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_tokens = self.session_input_tokens + self.session_output_tokens;
|
||||||
|
|
||||||
|
// Check token budget
|
||||||
|
if let Some(limit) = token_budget {
|
||||||
|
if session_tokens >= limit {
|
||||||
|
return BudgetStatus::Exceeded {
|
||||||
|
budget_type: BudgetType::Token,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let percent_used = session_tokens as f32 / limit as f32;
|
||||||
|
if percent_used >= warning_threshold {
|
||||||
|
return BudgetStatus::Warning {
|
||||||
|
budget_type: BudgetType::Token,
|
||||||
|
percent_used: percent_used * 100.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cost budget
|
||||||
|
if let Some(limit) = cost_budget {
|
||||||
|
if self.session_cost_usd >= limit {
|
||||||
|
return BudgetStatus::Exceeded {
|
||||||
|
budget_type: BudgetType::Cost,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let percent_used = (self.session_cost_usd / limit) as f32;
|
||||||
|
if percent_used >= warning_threshold {
|
||||||
|
return BudgetStatus::Warning {
|
||||||
|
budget_type: BudgetType::Cost,
|
||||||
|
percent_used: percent_used * 100.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BudgetStatus::Ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get remaining token budget (None if no budget set)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn get_remaining_token_budget(&self, token_budget: Option<u64>) -> Option<u64> {
|
||||||
|
token_budget.map(|limit| {
|
||||||
|
let used = self.session_input_tokens + self.session_output_tokens;
|
||||||
|
limit.saturating_sub(used)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get remaining cost budget (None if no budget set)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn get_remaining_cost_budget(&self, cost_budget: Option<f64>) -> Option<f64> {
|
||||||
|
cost_budget.map(|limit| {
|
||||||
|
if limit > self.session_cost_usd {
|
||||||
|
limit - self.session_cost_usd
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset_session(&mut self) {
|
pub fn reset_session(&mut self) {
|
||||||
@@ -76,6 +323,13 @@ impl UsageStats {
|
|||||||
self.session_start = Some(Instant::now());
|
self.session_start = Some(Instant::now());
|
||||||
self.achievements.start_session();
|
self.achievements.start_session();
|
||||||
|
|
||||||
|
// Reset context window tracking
|
||||||
|
self.context_tokens_used = 0;
|
||||||
|
self.context_utilisation_percent = 0.0;
|
||||||
|
|
||||||
|
// Note: Cache analytics are NOT reset here - they're cumulative across sessions
|
||||||
|
// to show total potential savings over time
|
||||||
|
|
||||||
// Track session start for achievements
|
// Track session start for achievements
|
||||||
self.track_session_start();
|
self.track_session_start();
|
||||||
}
|
}
|
||||||
@@ -139,11 +393,32 @@ impl UsageStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn increment_tool_usage(&mut self, tool_name: &str) {
|
pub fn increment_tool_usage(&mut self, tool_name: &str) {
|
||||||
*self.tools_usage.entry(tool_name.to_string()).or_insert(0) += 1;
|
self.tools_usage
|
||||||
*self
|
|
||||||
.session_tools_usage
|
|
||||||
.entry(tool_name.to_string())
|
.entry(tool_name.to_string())
|
||||||
.or_insert(0) += 1;
|
.or_default()
|
||||||
|
.increment_call();
|
||||||
|
self.session_tools_usage
|
||||||
|
.entry(tool_name.to_string())
|
||||||
|
.or_default()
|
||||||
|
.increment_call();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_tool_tokens(&mut self, tool_name: &str, input_tokens: u64, output_tokens: u64) {
|
||||||
|
self.tools_usage
|
||||||
|
.entry(tool_name.to_string())
|
||||||
|
.or_default()
|
||||||
|
.add_tokens(input_tokens, output_tokens);
|
||||||
|
self.session_tools_usage
|
||||||
|
.entry(tool_name.to_string())
|
||||||
|
.or_default()
|
||||||
|
.add_tokens(input_tokens, output_tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a potential cache hit (when the same tool call is made twice)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn add_potential_cache_hit(&mut self, tokens_saved: u64) {
|
||||||
|
self.potential_cache_hits += 1;
|
||||||
|
self.potential_cache_savings_tokens += tokens_saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_session_duration(&mut self) -> u64 {
|
pub fn get_session_duration(&mut self) -> u64 {
|
||||||
@@ -184,7 +459,16 @@ impl UsageStats {
|
|||||||
morning_sessions: self.morning_sessions,
|
morning_sessions: self.morning_sessions,
|
||||||
night_sessions: self.night_sessions,
|
night_sessions: self.night_sessions,
|
||||||
last_session_date: self.last_session_date.clone(),
|
last_session_date: self.last_session_date.clone(),
|
||||||
|
context_tokens_used: self.context_tokens_used,
|
||||||
|
context_window_limit: self.context_window_limit,
|
||||||
|
context_utilisation_percent: self.context_utilisation_percent,
|
||||||
|
potential_cache_hits: self.potential_cache_hits,
|
||||||
|
potential_cache_savings_tokens: self.potential_cache_savings_tokens,
|
||||||
achievements: AchievementProgress::new(), // Dummy for copy
|
achievements: AchievementProgress::new(), // Dummy for copy
|
||||||
|
current_request_input: None, // Don't copy tracking fields
|
||||||
|
current_request_output_chars: 0,
|
||||||
|
current_request_thinking_chars: 0,
|
||||||
|
current_request_tools: Vec::new(),
|
||||||
};
|
};
|
||||||
check_achievements(&stats_copy, &mut self.achievements)
|
check_achievements(&stats_copy, &mut self.achievements)
|
||||||
}
|
}
|
||||||
@@ -206,20 +490,33 @@ fn is_consecutive_day(prev_date: &str, current_date: &str) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pricing as of January 2025
|
// Pricing as of February 2026
|
||||||
// https://www.anthropic.com/pricing
|
// https://platform.claude.com/docs/en/about-claude/models/overview
|
||||||
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 {
|
||||||
// Opus 4.5
|
// Current generation (Claude 4.6)
|
||||||
"claude-opus-4-5-20251101" => (15.0, 75.0),
|
"claude-opus-4-6" => (5.0, 25.0),
|
||||||
|
"claude-sonnet-4-6" => (3.0, 15.0),
|
||||||
|
|
||||||
// Opus 4
|
// Previous generation (Claude 4.5)
|
||||||
|
"claude-opus-4-5-20251101" => (5.0, 25.0),
|
||||||
|
"claude-sonnet-4-5-20250929" => (3.0, 15.0),
|
||||||
|
"claude-haiku-4-5-20251001" => (1.0, 5.0),
|
||||||
|
|
||||||
|
// Previous generation (Claude 4.x)
|
||||||
|
"claude-opus-4-1-20250805" => (15.0, 75.0),
|
||||||
"claude-opus-4-20250514" => (15.0, 75.0),
|
"claude-opus-4-20250514" => (15.0, 75.0),
|
||||||
|
|
||||||
// Sonnet 4
|
|
||||||
"claude-sonnet-4-20250514" => (3.0, 15.0),
|
"claude-sonnet-4-20250514" => (3.0, 15.0),
|
||||||
|
|
||||||
// Previous generation models
|
// Legacy (Claude 3.x)
|
||||||
|
"claude-3-7-sonnet-20250219" => (3.0, 15.0),
|
||||||
"claude-3-5-sonnet-20241022" => (3.0, 15.0),
|
"claude-3-5-sonnet-20241022" => (3.0, 15.0),
|
||||||
"claude-3-5-sonnet-20240620" => (3.0, 15.0),
|
"claude-3-5-sonnet-20240620" => (3.0, 15.0),
|
||||||
"claude-3-5-haiku-20241022" => (1.0, 5.0),
|
"claude-3-5-haiku-20241022" => (1.0, 5.0),
|
||||||
@@ -231,10 +528,25 @@ 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)]
|
||||||
@@ -252,7 +564,7 @@ pub struct PersistedStats {
|
|||||||
pub code_blocks_generated: u64,
|
pub code_blocks_generated: u64,
|
||||||
pub files_edited: u64,
|
pub files_edited: u64,
|
||||||
pub files_created: u64,
|
pub files_created: u64,
|
||||||
pub tools_usage: HashMap<String, u64>,
|
pub tools_usage: HashMap<String, ToolTokenStats>,
|
||||||
pub sessions_started: u64,
|
pub sessions_started: u64,
|
||||||
pub consecutive_days: u64,
|
pub consecutive_days: u64,
|
||||||
pub total_days_used: u64,
|
pub total_days_used: u64,
|
||||||
@@ -308,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",
|
||||||
@@ -316,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
|
||||||
@@ -353,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
|
||||||
@@ -362,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
|
||||||
@@ -371,14 +683,16 @@ 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);
|
||||||
// Same pricing as Opus 4
|
// Opus 4.5 pricing: $5/MTok input, $25/MTok output
|
||||||
assert!((cost - 0.165).abs() < 0.0001);
|
// 1000 input tokens = $0.005, 2000 output tokens = $0.05
|
||||||
|
// Total = $0.055
|
||||||
|
assert!((cost - 0.055).abs() < 0.0001);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cost_calculation_haiku() {
|
fn test_cost_calculation_haiku() {
|
||||||
let cost = calculate_cost(1000, 2000, "claude-3-5-haiku-20241022");
|
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
|
||||||
@@ -387,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);
|
||||||
}
|
}
|
||||||
@@ -402,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);
|
||||||
@@ -414,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);
|
||||||
@@ -426,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);
|
||||||
@@ -512,10 +826,33 @@ mod tests {
|
|||||||
stats.increment_tool_usage("Read");
|
stats.increment_tool_usage("Read");
|
||||||
stats.increment_tool_usage("Write");
|
stats.increment_tool_usage("Write");
|
||||||
|
|
||||||
assert_eq!(stats.tools_usage.get("Read"), Some(&2));
|
assert_eq!(stats.tools_usage.get("Read").map(|t| t.call_count), Some(2));
|
||||||
assert_eq!(stats.tools_usage.get("Write"), Some(&1));
|
assert_eq!(stats.tools_usage.get("Write").map(|t| t.call_count), Some(1));
|
||||||
assert_eq!(stats.session_tools_usage.get("Read"), Some(&2));
|
assert_eq!(stats.session_tools_usage.get("Read").map(|t| t.call_count), Some(2));
|
||||||
assert_eq!(stats.session_tools_usage.get("Write"), Some(&1));
|
assert_eq!(stats.session_tools_usage.get("Write").map(|t| t.call_count), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_tool_tokens() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.increment_tool_usage("Read");
|
||||||
|
stats.add_tool_tokens("Read", 100, 50);
|
||||||
|
stats.add_tool_tokens("Read", 200, 100);
|
||||||
|
|
||||||
|
let read_stats = stats.tools_usage.get("Read").unwrap();
|
||||||
|
assert_eq!(read_stats.call_count, 1);
|
||||||
|
assert_eq!(read_stats.estimated_input_tokens, 300);
|
||||||
|
assert_eq!(read_stats.estimated_output_tokens, 150);
|
||||||
|
assert_eq!(read_stats.total_tokens(), 450);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tool_token_stats_default() {
|
||||||
|
let tool_stats = ToolTokenStats::new();
|
||||||
|
assert_eq!(tool_stats.call_count, 0);
|
||||||
|
assert_eq!(tool_stats.estimated_input_tokens, 0);
|
||||||
|
assert_eq!(tool_stats.estimated_output_tokens, 0);
|
||||||
|
assert_eq!(tool_stats.total_tokens(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -590,7 +927,11 @@ mod tests {
|
|||||||
files_created: 5,
|
files_created: 5,
|
||||||
tools_usage: {
|
tools_usage: {
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
map.insert("Read".to_string(), 50);
|
map.insert("Read".to_string(), ToolTokenStats {
|
||||||
|
call_count: 50,
|
||||||
|
estimated_input_tokens: 5000,
|
||||||
|
estimated_output_tokens: 2500,
|
||||||
|
});
|
||||||
map
|
map
|
||||||
},
|
},
|
||||||
sessions_started: 10,
|
sessions_started: 10,
|
||||||
@@ -608,7 +949,8 @@ mod tests {
|
|||||||
assert_eq!(stats.total_output_tokens, 20000);
|
assert_eq!(stats.total_output_tokens, 20000);
|
||||||
assert_eq!(stats.total_cost_usd, 5.50);
|
assert_eq!(stats.total_cost_usd, 5.50);
|
||||||
assert_eq!(stats.messages_exchanged, 100);
|
assert_eq!(stats.messages_exchanged, 100);
|
||||||
assert_eq!(stats.tools_usage.get("Read"), Some(&50));
|
assert_eq!(stats.tools_usage.get("Read").map(|t| t.call_count), Some(50));
|
||||||
|
assert_eq!(stats.tools_usage.get("Read").map(|t| t.estimated_input_tokens), Some(5000));
|
||||||
assert_eq!(stats.consecutive_days, 7);
|
assert_eq!(stats.consecutive_days, 7);
|
||||||
assert_eq!(stats.morning_sessions, 3);
|
assert_eq!(stats.morning_sessions, 3);
|
||||||
assert_eq!(stats.last_session_date, Some("2024-06-15".to_string()));
|
assert_eq!(stats.last_session_date, Some("2024-06-15".to_string()));
|
||||||
@@ -635,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)
|
||||||
@@ -664,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");
|
||||||
@@ -672,4 +1014,351 @@ mod tests {
|
|||||||
assert!(json.contains("stats"));
|
assert!(json.contains("stats"));
|
||||||
assert!(json.contains("total_input_tokens"));
|
assert!(json.contains("total_input_tokens"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Context Window Tracking tests
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_window_limit_claude_4() {
|
||||||
|
assert_eq!(get_context_window_limit("claude-opus-4-5-20251101"), 200_000);
|
||||||
|
assert_eq!(get_context_window_limit("claude-opus-4-20250514"), 200_000);
|
||||||
|
assert_eq!(get_context_window_limit("claude-sonnet-4-20250514"), 200_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_window_limit_claude_35() {
|
||||||
|
assert_eq!(
|
||||||
|
get_context_window_limit("claude-3-5-sonnet-20241022"),
|
||||||
|
200_000
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_context_window_limit("claude-3-5-sonnet-20240620"),
|
||||||
|
200_000
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_context_window_limit("claude-3-5-haiku-20241022"),
|
||||||
|
200_000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_window_limit_unknown_claude() {
|
||||||
|
assert_eq!(
|
||||||
|
get_context_window_limit("claude-some-future-model"),
|
||||||
|
200_000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_window_limit_non_claude() {
|
||||||
|
assert_eq!(get_context_window_limit("gpt-4"), 128_000);
|
||||||
|
assert_eq!(get_context_window_limit("llama-3"), 128_000);
|
||||||
|
assert_eq!(get_context_window_limit("unknown-model"), 128_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_tracking_update() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514", None, None);
|
||||||
|
|
||||||
|
assert_eq!(stats.context_tokens_used, 50_000);
|
||||||
|
assert_eq!(stats.context_window_limit, 200_000);
|
||||||
|
assert!((stats.context_utilisation_percent - 25.0).abs() < 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_tracking_accumulates() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514", None, None);
|
||||||
|
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514", None, None);
|
||||||
|
|
||||||
|
assert_eq!(stats.context_tokens_used, 100_000);
|
||||||
|
assert!((stats.context_utilisation_percent - 50.0).abs() < 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_warning_none() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.context_utilisation_percent = 40.0;
|
||||||
|
assert!(stats.get_context_warning().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_warning_moderate() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.context_utilisation_percent = 55.0;
|
||||||
|
assert_eq!(stats.get_context_warning(), Some(ContextWarning::Moderate));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_warning_high() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.context_utilisation_percent = 80.0;
|
||||||
|
assert_eq!(stats.get_context_warning(), Some(ContextWarning::High));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_warning_critical() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.context_utilisation_percent = 95.0;
|
||||||
|
assert_eq!(stats.get_context_warning(), Some(ContextWarning::Critical));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_estimate_remaining_tokens() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.context_tokens_used = 50_000;
|
||||||
|
stats.context_window_limit = 200_000;
|
||||||
|
|
||||||
|
assert_eq!(stats.estimate_remaining_tokens(), 150_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_estimate_remaining_tokens_at_limit() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.context_tokens_used = 200_000;
|
||||||
|
stats.context_window_limit = 200_000;
|
||||||
|
|
||||||
|
assert_eq!(stats.estimate_remaining_tokens(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_estimate_remaining_tokens_over_limit() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.context_tokens_used = 250_000;
|
||||||
|
stats.context_window_limit = 200_000;
|
||||||
|
|
||||||
|
assert_eq!(stats.estimate_remaining_tokens(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_reset_on_session_reset() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.add_usage(100_000, 20_000, "claude-sonnet-4-20250514", None, None);
|
||||||
|
|
||||||
|
assert!(stats.context_tokens_used > 0);
|
||||||
|
assert!(stats.context_utilisation_percent > 0.0);
|
||||||
|
|
||||||
|
stats.reset_session();
|
||||||
|
|
||||||
|
assert_eq!(stats.context_tokens_used, 0);
|
||||||
|
assert_eq!(stats.context_utilisation_percent, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_warning_message() {
|
||||||
|
assert_eq!(
|
||||||
|
ContextWarning::Moderate.message(),
|
||||||
|
"Context window is 50%+ full. Consider starting a new conversation for better performance."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ContextWarning::High.message(),
|
||||||
|
"Context window is 75%+ full. Responses may degrade. Consider summarising or starting fresh."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ContextWarning::Critical.message(),
|
||||||
|
"Context window is nearly full (90%+)! Start a new conversation to avoid errors."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_warning_serialization() {
|
||||||
|
let warning = ContextWarning::Critical;
|
||||||
|
let json = serde_json::to_string(&warning).expect("Failed to serialize");
|
||||||
|
assert_eq!(json, "\"critical\"");
|
||||||
|
|
||||||
|
let warning = ContextWarning::Moderate;
|
||||||
|
let json = serde_json::to_string(&warning).expect("Failed to serialize");
|
||||||
|
assert_eq!(json, "\"moderate\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Budget Tracking tests
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_budget_disabled_returns_ok() {
|
||||||
|
let stats = UsageStats::new();
|
||||||
|
let status = stats.check_budget(false, Some(1000), Some(1.0), 0.8);
|
||||||
|
assert_eq!(status, BudgetStatus::Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_budget_no_limits_returns_ok() {
|
||||||
|
let stats = UsageStats::new();
|
||||||
|
let status = stats.check_budget(true, None, None, 0.8);
|
||||||
|
assert_eq!(status, BudgetStatus::Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_token_budget_within_limit() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_input_tokens = 500;
|
||||||
|
stats.session_output_tokens = 300;
|
||||||
|
|
||||||
|
let status = stats.check_budget(true, Some(10000), None, 0.8);
|
||||||
|
assert_eq!(status, BudgetStatus::Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_token_budget_warning() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_input_tokens = 4500;
|
||||||
|
stats.session_output_tokens = 4000;
|
||||||
|
|
||||||
|
let status = stats.check_budget(true, Some(10000), None, 0.8);
|
||||||
|
match status {
|
||||||
|
BudgetStatus::Warning {
|
||||||
|
budget_type,
|
||||||
|
percent_used,
|
||||||
|
} => {
|
||||||
|
assert_eq!(budget_type, BudgetType::Token);
|
||||||
|
assert!(percent_used >= 80.0);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Warning status"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_token_budget_exceeded() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_input_tokens = 6000;
|
||||||
|
stats.session_output_tokens = 5000;
|
||||||
|
|
||||||
|
let status = stats.check_budget(true, Some(10000), None, 0.8);
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
BudgetStatus::Exceeded {
|
||||||
|
budget_type: BudgetType::Token
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cost_budget_within_limit() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_cost_usd = 0.50;
|
||||||
|
|
||||||
|
let status = stats.check_budget(true, None, Some(5.0), 0.8);
|
||||||
|
assert_eq!(status, BudgetStatus::Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cost_budget_warning() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_cost_usd = 4.25;
|
||||||
|
|
||||||
|
let status = stats.check_budget(true, None, Some(5.0), 0.8);
|
||||||
|
match status {
|
||||||
|
BudgetStatus::Warning {
|
||||||
|
budget_type,
|
||||||
|
percent_used,
|
||||||
|
} => {
|
||||||
|
assert_eq!(budget_type, BudgetType::Cost);
|
||||||
|
assert!(percent_used >= 80.0);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Warning status"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cost_budget_exceeded() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_cost_usd = 5.50;
|
||||||
|
|
||||||
|
let status = stats.check_budget(true, None, Some(5.0), 0.8);
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
BudgetStatus::Exceeded {
|
||||||
|
budget_type: BudgetType::Cost
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_token_budget_takes_priority() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_input_tokens = 12000;
|
||||||
|
stats.session_output_tokens = 0;
|
||||||
|
stats.session_cost_usd = 0.01;
|
||||||
|
|
||||||
|
// Token budget exceeded, cost budget OK
|
||||||
|
let status = stats.check_budget(true, Some(10000), Some(5.0), 0.8);
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
BudgetStatus::Exceeded {
|
||||||
|
budget_type: BudgetType::Token
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remaining_token_budget() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_input_tokens = 3000;
|
||||||
|
stats.session_output_tokens = 2000;
|
||||||
|
|
||||||
|
assert_eq!(stats.get_remaining_token_budget(Some(10000)), Some(5000));
|
||||||
|
assert_eq!(stats.get_remaining_token_budget(None), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remaining_token_budget_exceeded() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_input_tokens = 8000;
|
||||||
|
stats.session_output_tokens = 5000;
|
||||||
|
|
||||||
|
assert_eq!(stats.get_remaining_token_budget(Some(10000)), Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remaining_cost_budget() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_cost_usd = 2.50;
|
||||||
|
|
||||||
|
let remaining = stats.get_remaining_cost_budget(Some(5.0));
|
||||||
|
assert!(remaining.is_some());
|
||||||
|
assert!((remaining.unwrap() - 2.50).abs() < 0.001);
|
||||||
|
assert_eq!(stats.get_remaining_cost_budget(None), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remaining_cost_budget_exceeded() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_cost_usd = 6.0;
|
||||||
|
|
||||||
|
let remaining = stats.get_remaining_cost_budget(Some(5.0));
|
||||||
|
assert!(remaining.is_some());
|
||||||
|
assert!((remaining.unwrap() - 0.0).abs() < 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_budget_status_serialization() {
|
||||||
|
let status = BudgetStatus::Warning {
|
||||||
|
budget_type: BudgetType::Token,
|
||||||
|
percent_used: 85.5,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&status).expect("Failed to serialize");
|
||||||
|
assert!(json.contains("warning"));
|
||||||
|
assert!(json.contains("token"));
|
||||||
|
|
||||||
|
let status = BudgetStatus::Exceeded {
|
||||||
|
budget_type: BudgetType::Cost,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&status).expect("Failed to serialize");
|
||||||
|
assert!(json.contains("exceeded"));
|
||||||
|
assert!(json.contains("cost"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_budget_type_serialization() {
|
||||||
|
let token = BudgetType::Token;
|
||||||
|
let json = serde_json::to_string(&token).expect("Failed to serialize");
|
||||||
|
assert_eq!(json, "\"token\"");
|
||||||
|
|
||||||
|
let cost = BudgetType::Cost;
|
||||||
|
let json = serde_json::to_string(&cost).expect("Failed to serialize");
|
||||||
|
assert_eq!(json, "\"cost\"");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ impl TempFileManager {
|
|||||||
for file_path in files {
|
for file_path in files {
|
||||||
if file_path.exists() {
|
if file_path.exists() {
|
||||||
if let Err(e) = fs::remove_file(&file_path) {
|
if let Err(e) = fs::remove_file(&file_path) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
|
/// Tools that could benefit from caching
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CacheableTool {
|
||||||
|
Read,
|
||||||
|
Glob,
|
||||||
|
Grep,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CacheableTool {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn from_name(name: &str) -> Option<Self> {
|
||||||
|
match name {
|
||||||
|
"Read" => Some(Self::Read),
|
||||||
|
"Glob" => Some(Self::Glob),
|
||||||
|
"Grep" => Some(Self::Grep),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistics about potential cache savings
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CacheAnalytics {
|
||||||
|
/// Number of tool calls that could have been cache hits
|
||||||
|
pub potential_cache_hits: u64,
|
||||||
|
/// Estimated tokens that could have been saved
|
||||||
|
pub potential_savings_tokens: u64,
|
||||||
|
/// Tracks unique tool invocations: hash -> (tool_name, call_count)
|
||||||
|
#[serde(skip)]
|
||||||
|
recent_invocations: HashMap<u64, (String, u64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl CacheAnalytics {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a hash key from tool name and input
|
||||||
|
fn compute_key(tool_name: &str, input: &serde_json::Value) -> u64 {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
tool_name.hash(&mut hasher);
|
||||||
|
input.to_string().hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track a tool invocation for analytics
|
||||||
|
/// Returns true if this was a repeated invocation (potential cache hit)
|
||||||
|
pub fn track_invocation(
|
||||||
|
&mut self,
|
||||||
|
tool_name: &str,
|
||||||
|
input: &serde_json::Value,
|
||||||
|
estimated_tokens: u64,
|
||||||
|
) -> bool {
|
||||||
|
// Only track cacheable tools
|
||||||
|
if CacheableTool::from_name(tool_name).is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = Self::compute_key(tool_name, input);
|
||||||
|
|
||||||
|
if let Some((_, count)) = self.recent_invocations.get_mut(&key) {
|
||||||
|
*count += 1;
|
||||||
|
// This is a repeat - could have been a cache hit
|
||||||
|
self.potential_cache_hits += 1;
|
||||||
|
self.potential_savings_tokens += estimated_tokens;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
self.recent_invocations
|
||||||
|
.insert(key, (tool_name.to_string(), 1));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of unique tool invocations being tracked
|
||||||
|
pub fn unique_invocations(&self) -> usize {
|
||||||
|
self.recent_invocations.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get invocations that were called more than once
|
||||||
|
pub fn repeated_invocations(&self) -> Vec<(&str, u64)> {
|
||||||
|
self.recent_invocations
|
||||||
|
.values()
|
||||||
|
.filter(|(_, count)| *count > 1)
|
||||||
|
.map(|(name, count)| (name.as_str(), *count))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear session analytics (keep totals)
|
||||||
|
pub fn clear_session(&mut self) {
|
||||||
|
self.recent_invocations.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fully reset all analytics
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.potential_cache_hits = 0;
|
||||||
|
self.potential_savings_tokens = 0;
|
||||||
|
self.recent_invocations.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cacheable_tool_from_name() {
|
||||||
|
assert_eq!(CacheableTool::from_name("Read"), Some(CacheableTool::Read));
|
||||||
|
assert_eq!(CacheableTool::from_name("Glob"), Some(CacheableTool::Glob));
|
||||||
|
assert_eq!(CacheableTool::from_name("Grep"), Some(CacheableTool::Grep));
|
||||||
|
assert_eq!(CacheableTool::from_name("Bash"), None);
|
||||||
|
assert_eq!(CacheableTool::from_name("Edit"), None);
|
||||||
|
assert_eq!(CacheableTool::from_name("Write"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_first_invocation_not_cache_hit() {
|
||||||
|
let mut analytics = CacheAnalytics::new();
|
||||||
|
let input = json!({"file_path": "/home/test/file.txt"});
|
||||||
|
|
||||||
|
let is_repeat = analytics.track_invocation("Read", &input, 100);
|
||||||
|
|
||||||
|
assert!(!is_repeat);
|
||||||
|
assert_eq!(analytics.potential_cache_hits, 0);
|
||||||
|
assert_eq!(analytics.potential_savings_tokens, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_second_invocation_is_cache_hit() {
|
||||||
|
let mut analytics = CacheAnalytics::new();
|
||||||
|
let input = json!({"file_path": "/home/test/file.txt"});
|
||||||
|
|
||||||
|
analytics.track_invocation("Read", &input, 100);
|
||||||
|
let is_repeat = analytics.track_invocation("Read", &input, 100);
|
||||||
|
|
||||||
|
assert!(is_repeat);
|
||||||
|
assert_eq!(analytics.potential_cache_hits, 1);
|
||||||
|
assert_eq!(analytics.potential_savings_tokens, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_different_inputs_not_cache_hit() {
|
||||||
|
let mut analytics = CacheAnalytics::new();
|
||||||
|
let input1 = json!({"file_path": "/home/test/file1.txt"});
|
||||||
|
let input2 = json!({"file_path": "/home/test/file2.txt"});
|
||||||
|
|
||||||
|
analytics.track_invocation("Read", &input1, 100);
|
||||||
|
let is_repeat = analytics.track_invocation("Read", &input2, 100);
|
||||||
|
|
||||||
|
assert!(!is_repeat);
|
||||||
|
assert_eq!(analytics.potential_cache_hits, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_non_cacheable_tool_ignored() {
|
||||||
|
let mut analytics = CacheAnalytics::new();
|
||||||
|
let input = json!({"command": "ls -la"});
|
||||||
|
|
||||||
|
let is_repeat = analytics.track_invocation("Bash", &input, 100);
|
||||||
|
analytics.track_invocation("Bash", &input, 100);
|
||||||
|
|
||||||
|
assert!(!is_repeat);
|
||||||
|
assert_eq!(analytics.potential_cache_hits, 0);
|
||||||
|
assert_eq!(analytics.unique_invocations(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_repeated_invocations() {
|
||||||
|
let mut analytics = CacheAnalytics::new();
|
||||||
|
let input = json!({"file_path": "/home/test/file.txt"});
|
||||||
|
|
||||||
|
analytics.track_invocation("Read", &input, 100);
|
||||||
|
analytics.track_invocation("Read", &input, 100);
|
||||||
|
analytics.track_invocation("Read", &input, 100);
|
||||||
|
|
||||||
|
assert_eq!(analytics.potential_cache_hits, 2);
|
||||||
|
assert_eq!(analytics.potential_savings_tokens, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unique_invocations_count() {
|
||||||
|
let mut analytics = CacheAnalytics::new();
|
||||||
|
|
||||||
|
analytics.track_invocation("Read", &json!({"file_path": "/file1.txt"}), 100);
|
||||||
|
analytics.track_invocation("Read", &json!({"file_path": "/file2.txt"}), 100);
|
||||||
|
analytics.track_invocation("Glob", &json!({"pattern": "*.rs"}), 50);
|
||||||
|
|
||||||
|
assert_eq!(analytics.unique_invocations(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_repeated_invocations_list() {
|
||||||
|
let mut analytics = CacheAnalytics::new();
|
||||||
|
|
||||||
|
// file1 read twice
|
||||||
|
analytics.track_invocation("Read", &json!({"file_path": "/file1.txt"}), 100);
|
||||||
|
analytics.track_invocation("Read", &json!({"file_path": "/file1.txt"}), 100);
|
||||||
|
|
||||||
|
// file2 read once
|
||||||
|
analytics.track_invocation("Read", &json!({"file_path": "/file2.txt"}), 100);
|
||||||
|
|
||||||
|
// glob run 3 times
|
||||||
|
analytics.track_invocation("Glob", &json!({"pattern": "*.rs"}), 50);
|
||||||
|
analytics.track_invocation("Glob", &json!({"pattern": "*.rs"}), 50);
|
||||||
|
analytics.track_invocation("Glob", &json!({"pattern": "*.rs"}), 50);
|
||||||
|
|
||||||
|
let repeated = analytics.repeated_invocations();
|
||||||
|
assert_eq!(repeated.len(), 2); // file1 and glob pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clear_session() {
|
||||||
|
let mut analytics = CacheAnalytics::new();
|
||||||
|
let input = json!({"file_path": "/file.txt"});
|
||||||
|
|
||||||
|
analytics.track_invocation("Read", &input, 100);
|
||||||
|
analytics.track_invocation("Read", &input, 100);
|
||||||
|
|
||||||
|
assert_eq!(analytics.potential_cache_hits, 1);
|
||||||
|
assert_eq!(analytics.unique_invocations(), 1);
|
||||||
|
|
||||||
|
analytics.clear_session();
|
||||||
|
|
||||||
|
assert_eq!(analytics.potential_cache_hits, 1); // Preserved
|
||||||
|
assert_eq!(analytics.unique_invocations(), 0); // Cleared
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reset() {
|
||||||
|
let mut analytics = CacheAnalytics::new();
|
||||||
|
let input = json!({"file_path": "/file.txt"});
|
||||||
|
|
||||||
|
analytics.track_invocation("Read", &input, 100);
|
||||||
|
analytics.track_invocation("Read", &input, 100);
|
||||||
|
|
||||||
|
analytics.reset();
|
||||||
|
|
||||||
|
assert_eq!(analytics.potential_cache_hits, 0);
|
||||||
|
assert_eq!(analytics.potential_savings_tokens, 0);
|
||||||
|
assert_eq!(analytics.unique_invocations(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialization() {
|
||||||
|
let mut analytics = CacheAnalytics::new();
|
||||||
|
analytics.potential_cache_hits = 10;
|
||||||
|
analytics.potential_savings_tokens = 500;
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&analytics).expect("Failed to serialize");
|
||||||
|
let deserialized: CacheAnalytics =
|
||||||
|
serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(deserialized.potential_cache_hits, 10);
|
||||||
|
assert_eq!(deserialized.potential_savings_tokens, 500);
|
||||||
|
// recent_invocations is skipped in serialization
|
||||||
|
assert_eq!(deserialized.unique_invocations(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)]
|
||||||
@@ -176,6 +205,14 @@ pub struct StateChangeEvent {
|
|||||||
pub conversation_id: Option<String>,
|
pub conversation_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cost information for a message
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MessageCost {
|
||||||
|
pub input_tokens: u64,
|
||||||
|
pub output_tokens: u64,
|
||||||
|
pub cost_usd: f64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct OutputEvent {
|
pub struct OutputEvent {
|
||||||
pub line_type: String,
|
pub line_type: String,
|
||||||
@@ -183,14 +220,23 @@ pub struct OutputEvent {
|
|||||||
pub tool_name: Option<String>,
|
pub tool_name: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub conversation_id: Option<String>,
|
pub conversation_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cost: Option<MessageCost>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_tool_use_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[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>,
|
||||||
}
|
}
|
||||||
@@ -234,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::*;
|
||||||
@@ -354,10 +444,106 @@ mod tests {
|
|||||||
content: "Test output".to_string(),
|
content: "Test output".to_string(),
|
||||||
tool_name: None,
|
tool_name: None,
|
||||||
conversation_id: None,
|
conversation_id: None,
|
||||||
|
cost: None,
|
||||||
|
parent_tool_use_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let serialized = serde_json::to_string(&event).unwrap();
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
assert!(serialized.contains("\"line_type\":\"assistant\""));
|
assert!(serialized.contains("\"line_type\":\"assistant\""));
|
||||||
assert!(serialized.contains("\"content\":\"Test output\""));
|
assert!(serialized.contains("\"content\":\"Test output\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_output_event_with_cost() {
|
||||||
|
let event = OutputEvent {
|
||||||
|
line_type: "assistant".to_string(),
|
||||||
|
content: "Test output".to_string(),
|
||||||
|
tool_name: None,
|
||||||
|
conversation_id: Some("conv-123".to_string()),
|
||||||
|
cost: Some(MessageCost {
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 50,
|
||||||
|
cost_usd: 0.005,
|
||||||
|
}),
|
||||||
|
parent_tool_use_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(serialized.contains("\"cost\":"));
|
||||||
|
assert!(serialized.contains("\"input_tokens\":100"));
|
||||||
|
assert!(serialized.contains("\"output_tokens\":50"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_info_default() {
|
||||||
|
let info = RateLimitInfo::default();
|
||||||
|
assert!(info.requests_limit.is_none());
|
||||||
|
assert!(info.requests_remaining.is_none());
|
||||||
|
assert!(info.requests_reset.is_none());
|
||||||
|
assert!(info.tokens_limit.is_none());
|
||||||
|
assert!(info.tokens_remaining.is_none());
|
||||||
|
assert!(info.tokens_reset.is_none());
|
||||||
|
assert!(info.retry_after_ms.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_event_deserialization_empty_info() {
|
||||||
|
let json = r#"{"type":"rate_limit_event","rate_limit_info":{}}"#;
|
||||||
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_event_deserialization_no_info() {
|
||||||
|
// rate_limit_info field is optional via #[serde(default)]
|
||||||
|
let json = r#"{"type":"rate_limit_event"}"#;
|
||||||
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_event_deserialization_with_data() {
|
||||||
|
let json = r#"{
|
||||||
|
"type": "rate_limit_event",
|
||||||
|
"rate_limit_info": {
|
||||||
|
"requests_limit": 1000,
|
||||||
|
"requests_remaining": 0,
|
||||||
|
"requests_reset": "2024-01-01T00:01:00Z",
|
||||||
|
"tokens_limit": 50000,
|
||||||
|
"tokens_remaining": 0,
|
||||||
|
"tokens_reset": "2024-01-01T00:01:00Z",
|
||||||
|
"retry_after_ms": 60000
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||||
|
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
|
||||||
|
assert_eq!(rate_limit_info.requests_limit, Some(1000));
|
||||||
|
assert_eq!(rate_limit_info.requests_remaining, Some(0));
|
||||||
|
assert_eq!(
|
||||||
|
rate_limit_info.requests_reset,
|
||||||
|
Some("2024-01-01T00:01:00Z".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(rate_limit_info.retry_after_ms, Some(60000));
|
||||||
|
} else {
|
||||||
|
panic!("Expected RateLimitEvent variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_event_ignores_unknown_fields() {
|
||||||
|
// Ensures forward-compat: unknown fields in rate_limit_info are silently ignored
|
||||||
|
let json = r#"{
|
||||||
|
"type": "rate_limit_event",
|
||||||
|
"rate_limit_info": {
|
||||||
|
"requests_remaining": 0,
|
||||||
|
"some_future_field": "some_value"
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||||
|
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
|
||||||
|
assert_eq!(rate_limit_info.requests_remaining, Some(0));
|
||||||
|
} else {
|
||||||
|
panic!("Expected RateLimitEvent variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,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()
|
||||||
|
|||||||
@@ -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.0.0",
|
"version": "1.11.1",
|
||||||
"identifier": "com.naomi.hikari-desktop",
|
"identifier": "com.naomi.hikari-desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
|||||||
@@ -148,17 +148,405 @@
|
|||||||
--hljs-meta: #cccccc;
|
--hljs-meta: #cccccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="dracula"] {
|
||||||
|
--bg-primary: #282a36;
|
||||||
|
--bg-secondary: #1e1f29;
|
||||||
|
--bg-terminal: #191a21;
|
||||||
|
--bg-hover: #44475a;
|
||||||
|
--bg-code: #282a36;
|
||||||
|
--accent-primary: #bd93f9;
|
||||||
|
--accent-secondary: #ff79c6;
|
||||||
|
--text-primary: #f8f8f2;
|
||||||
|
--text-secondary: #6272a4;
|
||||||
|
--text-tertiary: #44475a;
|
||||||
|
--border-color: #44475a;
|
||||||
|
|
||||||
|
/* Trans pride colors */
|
||||||
|
--trans-blue: #5bcefa;
|
||||||
|
--trans-pink: #f5a9b8;
|
||||||
|
--trans-white: #ffffff;
|
||||||
|
--trans-gradient: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 50%,
|
||||||
|
var(--trans-white) 100%
|
||||||
|
);
|
||||||
|
--trans-gradient-vibrant: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 35%,
|
||||||
|
var(--trans-white) 50%,
|
||||||
|
var(--trans-pink) 65%,
|
||||||
|
var(--trans-blue) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Terminal specific colors */
|
||||||
|
--terminal-user: #8be9fd;
|
||||||
|
--terminal-tool: #bd93f9;
|
||||||
|
--terminal-tool-name: #caa9fa;
|
||||||
|
--terminal-error: #ff5555;
|
||||||
|
|
||||||
|
/* Syntax highlighting colors (Dracula) */
|
||||||
|
--hljs-keyword: #ff79c6;
|
||||||
|
--hljs-string: #f1fa8c;
|
||||||
|
--hljs-number: #bd93f9;
|
||||||
|
--hljs-comment: #6272a4;
|
||||||
|
--hljs-function: #50fa7b;
|
||||||
|
--hljs-type: #8be9fd;
|
||||||
|
--hljs-variable: #ffb86c;
|
||||||
|
--hljs-meta: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="catppuccin"] {
|
||||||
|
--bg-primary: #1e1e2e;
|
||||||
|
--bg-secondary: #181825;
|
||||||
|
--bg-terminal: #11111b;
|
||||||
|
--bg-hover: #313244;
|
||||||
|
--bg-code: #1e1e2e;
|
||||||
|
--accent-primary: #cba6f7;
|
||||||
|
--accent-secondary: #f5c2e7;
|
||||||
|
--text-primary: #cdd6f4;
|
||||||
|
--text-secondary: #a6adc8;
|
||||||
|
--text-tertiary: #6c7086;
|
||||||
|
--border-color: #313244;
|
||||||
|
|
||||||
|
/* Trans pride colors */
|
||||||
|
--trans-blue: #5bcefa;
|
||||||
|
--trans-pink: #f5a9b8;
|
||||||
|
--trans-white: #ffffff;
|
||||||
|
--trans-gradient: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 50%,
|
||||||
|
var(--trans-white) 100%
|
||||||
|
);
|
||||||
|
--trans-gradient-vibrant: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 35%,
|
||||||
|
var(--trans-white) 50%,
|
||||||
|
var(--trans-pink) 65%,
|
||||||
|
var(--trans-blue) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Terminal specific colors */
|
||||||
|
--terminal-user: #89dceb;
|
||||||
|
--terminal-tool: #cba6f7;
|
||||||
|
--terminal-tool-name: #d9b3ff;
|
||||||
|
--terminal-error: #f38ba8;
|
||||||
|
|
||||||
|
/* Syntax highlighting colors (Catppuccin Mocha) */
|
||||||
|
--hljs-keyword: #cba6f7;
|
||||||
|
--hljs-string: #a6e3a1;
|
||||||
|
--hljs-number: #fab387;
|
||||||
|
--hljs-comment: #6c7086;
|
||||||
|
--hljs-function: #89b4fa;
|
||||||
|
--hljs-type: #89dceb;
|
||||||
|
--hljs-variable: #fab387;
|
||||||
|
--hljs-meta: #a6adc8;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="nord"] {
|
||||||
|
--bg-primary: #2e3440;
|
||||||
|
--bg-secondary: #3b4252;
|
||||||
|
--bg-terminal: #242933;
|
||||||
|
--bg-hover: #434c5e;
|
||||||
|
--bg-code: #2e3440;
|
||||||
|
--accent-primary: #88c0d0;
|
||||||
|
--accent-secondary: #81a1c1;
|
||||||
|
--text-primary: #eceff4;
|
||||||
|
--text-secondary: #d8dee9;
|
||||||
|
--text-tertiary: #4c566a;
|
||||||
|
--border-color: #434c5e;
|
||||||
|
|
||||||
|
/* Trans pride colors */
|
||||||
|
--trans-blue: #5bcefa;
|
||||||
|
--trans-pink: #f5a9b8;
|
||||||
|
--trans-white: #ffffff;
|
||||||
|
--trans-gradient: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 50%,
|
||||||
|
var(--trans-white) 100%
|
||||||
|
);
|
||||||
|
--trans-gradient-vibrant: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 35%,
|
||||||
|
var(--trans-white) 50%,
|
||||||
|
var(--trans-pink) 65%,
|
||||||
|
var(--trans-blue) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Terminal specific colors */
|
||||||
|
--terminal-user: #88c0d0;
|
||||||
|
--terminal-tool: #b48ead;
|
||||||
|
--terminal-tool-name: #c7a8c9;
|
||||||
|
--terminal-error: #bf616a;
|
||||||
|
|
||||||
|
/* Syntax highlighting colors (Nord) */
|
||||||
|
--hljs-keyword: #81a1c1;
|
||||||
|
--hljs-string: #a3be8c;
|
||||||
|
--hljs-number: #b48ead;
|
||||||
|
--hljs-comment: #4c566a;
|
||||||
|
--hljs-function: #88c0d0;
|
||||||
|
--hljs-type: #8fbcbb;
|
||||||
|
--hljs-variable: #d08770;
|
||||||
|
--hljs-meta: #616e88;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="solarized"] {
|
||||||
|
--bg-primary: #002b36;
|
||||||
|
--bg-secondary: #073642;
|
||||||
|
--bg-terminal: #00212b;
|
||||||
|
--bg-hover: #094656;
|
||||||
|
--bg-code: #002b36;
|
||||||
|
--accent-primary: #268bd2;
|
||||||
|
--accent-secondary: #2aa198;
|
||||||
|
--text-primary: #fdf6e3;
|
||||||
|
--text-secondary: #93a1a1;
|
||||||
|
--text-tertiary: #657b83;
|
||||||
|
--border-color: #094656;
|
||||||
|
|
||||||
|
/* Trans pride colors */
|
||||||
|
--trans-blue: #5bcefa;
|
||||||
|
--trans-pink: #f5a9b8;
|
||||||
|
--trans-white: #ffffff;
|
||||||
|
--trans-gradient: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 50%,
|
||||||
|
var(--trans-white) 100%
|
||||||
|
);
|
||||||
|
--trans-gradient-vibrant: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 35%,
|
||||||
|
var(--trans-white) 50%,
|
||||||
|
var(--trans-pink) 65%,
|
||||||
|
var(--trans-blue) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Terminal specific colors */
|
||||||
|
--terminal-user: #2aa198;
|
||||||
|
--terminal-tool: #6c71c4;
|
||||||
|
--terminal-tool-name: #9395d0;
|
||||||
|
--terminal-error: #dc322f;
|
||||||
|
|
||||||
|
/* Syntax highlighting colors (Solarized Dark) */
|
||||||
|
--hljs-keyword: #859900;
|
||||||
|
--hljs-string: #2aa198;
|
||||||
|
--hljs-number: #d33682;
|
||||||
|
--hljs-comment: #586e75;
|
||||||
|
--hljs-function: #268bd2;
|
||||||
|
--hljs-type: #b58900;
|
||||||
|
--hljs-variable: #cb4b16;
|
||||||
|
--hljs-meta: #657b83;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="solarized-light"] {
|
||||||
|
--bg-primary: #fdf6e3;
|
||||||
|
--bg-secondary: #eee8d5;
|
||||||
|
--bg-terminal: #f9f3d7;
|
||||||
|
--bg-hover: #d8d1be;
|
||||||
|
--bg-code: #eee8d5;
|
||||||
|
--accent-primary: #268bd2;
|
||||||
|
--accent-secondary: #2aa198;
|
||||||
|
--text-primary: #657b83;
|
||||||
|
--text-secondary: #839496;
|
||||||
|
--text-tertiary: #93a1a1;
|
||||||
|
--border-color: #cfc9b5;
|
||||||
|
|
||||||
|
/* Trans pride colors */
|
||||||
|
--trans-blue: #5bcefa;
|
||||||
|
--trans-pink: #f5a9b8;
|
||||||
|
--trans-white: #ffffff;
|
||||||
|
--trans-gradient: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 50%,
|
||||||
|
var(--trans-white) 100%
|
||||||
|
);
|
||||||
|
--trans-gradient-vibrant: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 35%,
|
||||||
|
var(--trans-white) 50%,
|
||||||
|
var(--trans-pink) 65%,
|
||||||
|
var(--trans-blue) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Terminal specific colors */
|
||||||
|
--terminal-user: #268bd2;
|
||||||
|
--terminal-tool: #6c71c4;
|
||||||
|
--terminal-tool-name: #8f94cc;
|
||||||
|
--terminal-error: #dc322f;
|
||||||
|
|
||||||
|
/* Syntax highlighting colors (Solarized Light) */
|
||||||
|
--hljs-keyword: #859900;
|
||||||
|
--hljs-string: #2aa198;
|
||||||
|
--hljs-number: #d33682;
|
||||||
|
--hljs-comment: #93a1a1;
|
||||||
|
--hljs-function: #268bd2;
|
||||||
|
--hljs-type: #b58900;
|
||||||
|
--hljs-variable: #cb4b16;
|
||||||
|
--hljs-meta: #657b83;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="catppuccin-latte"] {
|
||||||
|
--bg-primary: #eff1f5;
|
||||||
|
--bg-secondary: #e6e9ef;
|
||||||
|
--bg-terminal: #dce0e8;
|
||||||
|
--bg-hover: #ccd0da;
|
||||||
|
--bg-code: #e6e9ef;
|
||||||
|
--accent-primary: #8839ef;
|
||||||
|
--accent-secondary: #ea76cb;
|
||||||
|
--text-primary: #4c4f69;
|
||||||
|
--text-secondary: #6c6f85;
|
||||||
|
--text-tertiary: #9ca0b0;
|
||||||
|
--border-color: #bcc0cc;
|
||||||
|
|
||||||
|
/* Trans pride colors */
|
||||||
|
--trans-blue: #5bcefa;
|
||||||
|
--trans-pink: #f5a9b8;
|
||||||
|
--trans-white: #ffffff;
|
||||||
|
--trans-gradient: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 50%,
|
||||||
|
var(--trans-white) 100%
|
||||||
|
);
|
||||||
|
--trans-gradient-vibrant: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 35%,
|
||||||
|
var(--trans-white) 50%,
|
||||||
|
var(--trans-pink) 65%,
|
||||||
|
var(--trans-blue) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Terminal specific colors */
|
||||||
|
--terminal-user: #209fb5;
|
||||||
|
--terminal-tool: #8839ef;
|
||||||
|
--terminal-tool-name: #a259f1;
|
||||||
|
--terminal-error: #d20f39;
|
||||||
|
|
||||||
|
/* Syntax highlighting colors (Catppuccin Latte) */
|
||||||
|
--hljs-keyword: #8839ef;
|
||||||
|
--hljs-string: #40a02b;
|
||||||
|
--hljs-number: #fe640b;
|
||||||
|
--hljs-comment: #8c8fa1;
|
||||||
|
--hljs-function: #1e66f5;
|
||||||
|
--hljs-type: #209fb5;
|
||||||
|
--hljs-variable: #fe640b;
|
||||||
|
--hljs-meta: #5c5f77;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="gruvbox-light"] {
|
||||||
|
--bg-primary: #fbf1c7;
|
||||||
|
--bg-secondary: #ebdbb2;
|
||||||
|
--bg-terminal: #f9f5d7;
|
||||||
|
--bg-hover: #d5c4a1;
|
||||||
|
--bg-code: #ebdbb2;
|
||||||
|
--accent-primary: #458588;
|
||||||
|
--accent-secondary: #689d6a;
|
||||||
|
--text-primary: #3c3836;
|
||||||
|
--text-secondary: #665c54;
|
||||||
|
--text-tertiary: #7c6f64;
|
||||||
|
--border-color: #bdae93;
|
||||||
|
|
||||||
|
/* Trans pride colors */
|
||||||
|
--trans-blue: #5bcefa;
|
||||||
|
--trans-pink: #f5a9b8;
|
||||||
|
--trans-white: #ffffff;
|
||||||
|
--trans-gradient: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 50%,
|
||||||
|
var(--trans-white) 100%
|
||||||
|
);
|
||||||
|
--trans-gradient-vibrant: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 35%,
|
||||||
|
var(--trans-white) 50%,
|
||||||
|
var(--trans-pink) 65%,
|
||||||
|
var(--trans-blue) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Terminal specific colors */
|
||||||
|
--terminal-user: #458588;
|
||||||
|
--terminal-tool: #b16286;
|
||||||
|
--terminal-tool-name: #c37aa0;
|
||||||
|
--terminal-error: #cc241d;
|
||||||
|
|
||||||
|
/* Syntax highlighting colors (Gruvbox Light) */
|
||||||
|
--hljs-keyword: #d65d0e;
|
||||||
|
--hljs-string: #98971a;
|
||||||
|
--hljs-number: #b16286;
|
||||||
|
--hljs-comment: #928374;
|
||||||
|
--hljs-function: #458588;
|
||||||
|
--hljs-type: #d79921;
|
||||||
|
--hljs-variable: #af3a03;
|
||||||
|
--hljs-meta: #7c6f64;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="rose-pine-dawn"] {
|
||||||
|
--bg-primary: #faf4ed;
|
||||||
|
--bg-secondary: #fffaf3;
|
||||||
|
--bg-terminal: #f2e9e1;
|
||||||
|
--bg-hover: #dfdad9;
|
||||||
|
--bg-code: #fffaf3;
|
||||||
|
--accent-primary: #907aa9;
|
||||||
|
--accent-secondary: #d7827e;
|
||||||
|
--text-primary: #575279;
|
||||||
|
--text-secondary: #797593;
|
||||||
|
--text-tertiary: #9893a5;
|
||||||
|
--border-color: #cecacd;
|
||||||
|
|
||||||
|
/* Trans pride colors */
|
||||||
|
--trans-blue: #5bcefa;
|
||||||
|
--trans-pink: #f5a9b8;
|
||||||
|
--trans-white: #ffffff;
|
||||||
|
--trans-gradient: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 50%,
|
||||||
|
var(--trans-white) 100%
|
||||||
|
);
|
||||||
|
--trans-gradient-vibrant: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 35%,
|
||||||
|
var(--trans-white) 50%,
|
||||||
|
var(--trans-pink) 65%,
|
||||||
|
var(--trans-blue) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Terminal specific colors */
|
||||||
|
--terminal-user: #56949f;
|
||||||
|
--terminal-tool: #907aa9;
|
||||||
|
--terminal-tool-name: #a48abf;
|
||||||
|
--terminal-error: #b4637a;
|
||||||
|
|
||||||
|
/* Syntax highlighting colors (Rosé Pine Dawn) */
|
||||||
|
--hljs-keyword: #286983;
|
||||||
|
--hljs-string: #56949f;
|
||||||
|
--hljs-number: #ea9d34;
|
||||||
|
--hljs-comment: #9893a5;
|
||||||
|
--hljs-function: #907aa9;
|
||||||
|
--hljs-type: #d7827e;
|
||||||
|
--hljs-variable: #b4637a;
|
||||||
|
--hljs-meta: #797593;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-family:
|
font-family: var(--ui-font-family, "Segoe UI", system-ui, -apple-system, sans-serif);
|
||||||
"Segoe UI",
|
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
sans-serif;
|
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
|
import { searchState } from "$lib/stores/search";
|
||||||
|
import { characterState } from "$lib/stores/character";
|
||||||
import {
|
import {
|
||||||
slashCommands,
|
slashCommands,
|
||||||
parseSlashCommand,
|
parseSlashCommand,
|
||||||
@@ -8,9 +13,13 @@ import {
|
|||||||
} from "./slashCommands";
|
} from "./slashCommands";
|
||||||
|
|
||||||
// Mock all external dependencies
|
// Mock all external dependencies
|
||||||
vi.mock("svelte/store", () => ({
|
vi.mock("svelte/store", async (importOriginal) => {
|
||||||
get: vi.fn(),
|
const actual = await importOriginal<typeof import("svelte/store")>();
|
||||||
}));
|
return {
|
||||||
|
...actual,
|
||||||
|
get: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/core", () => ({
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
@@ -36,6 +45,28 @@ vi.mock("$lib/stores/character", () => ({
|
|||||||
|
|
||||||
vi.mock("$lib/tauri", () => ({
|
vi.mock("$lib/tauri", () => ({
|
||||||
setSkipNextGreeting: vi.fn(),
|
setSkipNextGreeting: vi.fn(),
|
||||||
|
updateDiscordRpc: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/conversations", () => ({
|
||||||
|
conversationsStore: {
|
||||||
|
activeConversation: { subscribe: vi.fn() },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/config", () => ({
|
||||||
|
configStore: {
|
||||||
|
getConfig: vi.fn().mockReturnValue({
|
||||||
|
auto_granted_tools: [],
|
||||||
|
model: "claude-sonnet",
|
||||||
|
api_key: null,
|
||||||
|
custom_instructions: null,
|
||||||
|
mcp_servers_json: null,
|
||||||
|
use_worktree: false,
|
||||||
|
disable_1m_context: false,
|
||||||
|
max_output_tokens: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("$lib/stores/search", () => ({
|
vi.mock("$lib/stores/search", () => ({
|
||||||
@@ -411,4 +442,425 @@ describe("slashCommands", () => {
|
|||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("command execute functions", () => {
|
||||||
|
let getMock: ReturnType<typeof vi.fn>;
|
||||||
|
let invokeMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
getMock = vi.mocked(get);
|
||||||
|
invokeMock = vi.mocked(invoke);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/clear execute", () => {
|
||||||
|
it("clears terminal and shows confirmation message", () => {
|
||||||
|
const clearCmd = slashCommands.find((cmd) => cmd.name === "clear")!;
|
||||||
|
clearCmd.execute("");
|
||||||
|
expect(claudeStore.clearTerminal).toHaveBeenCalledWith();
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Terminal cleared");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/help execute", () => {
|
||||||
|
it("shows available commands header", () => {
|
||||||
|
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
|
||||||
|
helpCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
expect.stringContaining("Available commands:")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes all command usages in help text", () => {
|
||||||
|
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
|
||||||
|
helpCmd.execute("");
|
||||||
|
const callArgs = vi.mocked(claudeStore.addLine).mock.calls[0];
|
||||||
|
const helpText = callArgs[1] as string;
|
||||||
|
expect(helpText).toContain("/cd");
|
||||||
|
expect(helpText).toContain("/clear");
|
||||||
|
expect(helpText).toContain("/help");
|
||||||
|
expect(helpText).toContain("/search");
|
||||||
|
expect(helpText).toContain("/new");
|
||||||
|
expect(helpText).toContain("/summarise");
|
||||||
|
expect(helpText).toContain("/skill");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes command descriptions in help text", () => {
|
||||||
|
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
|
||||||
|
helpCmd.execute("");
|
||||||
|
const callArgs = vi.mocked(claudeStore.addLine).mock.calls[0];
|
||||||
|
const helpText = callArgs[1] as string;
|
||||||
|
expect(helpText).toContain("Change the working directory");
|
||||||
|
expect(helpText).toContain("Show available slash commands");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/search execute", () => {
|
||||||
|
it("clears search when called with empty args", () => {
|
||||||
|
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
||||||
|
searchCmd.execute("");
|
||||||
|
expect(searchState.clear).toHaveBeenCalledWith();
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Search cleared");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears search when called with whitespace-only args", () => {
|
||||||
|
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
||||||
|
searchCmd.execute(" ");
|
||||||
|
expect(searchState.clear).toHaveBeenCalledWith();
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Search cleared");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets query when called with a search term", () => {
|
||||||
|
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
||||||
|
searchCmd.execute("hello world");
|
||||||
|
expect(searchState.setQuery).toHaveBeenCalledWith("hello world");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", 'Searching for: "hello world"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims whitespace from query before setting", () => {
|
||||||
|
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
||||||
|
searchCmd.execute(" hello ");
|
||||||
|
expect(searchState.setQuery).toHaveBeenCalledWith("hello");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", 'Searching for: "hello"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/cd execute", () => {
|
||||||
|
it("shows error when no active conversation", async () => {
|
||||||
|
getMock.mockReturnValue(null);
|
||||||
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||||
|
await cdCmd.execute("/some/path");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows current directory when called with empty args", async () => {
|
||||||
|
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce("/home/naomi/code");
|
||||||
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||||
|
await cdCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
"Current directory: /home/naomi/code"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows current directory when called with whitespace-only args", async () => {
|
||||||
|
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce("/home/naomi/code");
|
||||||
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||||
|
await cdCmd.execute(" ");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
"Current directory: /home/naomi/code"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates path and changes directory on success", async () => {
|
||||||
|
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(null);
|
||||||
|
invokeMock.mockResolvedValue("/validated/path");
|
||||||
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||||
|
await cdCmd.execute("/new/path");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith(
|
||||||
|
"validate_directory",
|
||||||
|
expect.objectContaining({ path: "/new/path" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error when directory change fails", async () => {
|
||||||
|
getMock.mockReturnValueOnce("conv-123");
|
||||||
|
invokeMock.mockRejectedValueOnce(new Error("invalid path"));
|
||||||
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||||
|
await cdCmd.execute("/bad/path");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"error",
|
||||||
|
expect.stringContaining("Failed to change directory:")
|
||||||
|
);
|
||||||
|
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/new execute", () => {
|
||||||
|
it("shows error when no active conversation", async () => {
|
||||||
|
getMock.mockReturnValue(null);
|
||||||
|
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
||||||
|
await newCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error when starting new conversation fails", async () => {
|
||||||
|
getMock.mockReturnValueOnce("conv-123");
|
||||||
|
invokeMock.mockRejectedValueOnce(new Error("invoke failed"));
|
||||||
|
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
||||||
|
await newCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"error",
|
||||||
|
expect.stringContaining("Failed to start new conversation:")
|
||||||
|
);
|
||||||
|
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/summarise execute", () => {
|
||||||
|
it("shows error when no active conversation", async () => {
|
||||||
|
getMock.mockReturnValue(null);
|
||||||
|
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
|
||||||
|
await summariseCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a summary prompt when there is an active conversation", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
|
||||||
|
await summariseCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
"Requesting conversation summary..."
|
||||||
|
);
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith(
|
||||||
|
"send_prompt",
|
||||||
|
expect.objectContaining({ conversationId: "conv-123" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error when send_prompt invoke fails", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockRejectedValue(new Error("network error"));
|
||||||
|
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
|
||||||
|
await summariseCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"error",
|
||||||
|
expect.stringContaining("Failed to request summary:")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/skill execute", () => {
|
||||||
|
it("shows error when no active conversation", async () => {
|
||||||
|
getMock.mockReturnValue(null);
|
||||||
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||||
|
await skillCmd.execute("onboard-mentee");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists available skills when called with no name", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue(["onboard-mentee", "other-skill"]);
|
||||||
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||||
|
await skillCmd.execute("");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith("list_skills");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
expect.stringContaining("onboard-mentee")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows empty message when no skills are found", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue([]);
|
||||||
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||||
|
await skillCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
expect.stringContaining("No skills found")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invokes skill when called with a name and no data", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||||
|
await skillCmd.execute("onboard-mentee");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
"Invoking skill: onboard-mentee"
|
||||||
|
);
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith(
|
||||||
|
"send_prompt",
|
||||||
|
expect.objectContaining({ conversationId: "conv-123" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invokes skill with additional data in the prompt", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||||
|
await skillCmd.execute("onboard-mentee some extra data");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||||
|
conversationId: "conv-123",
|
||||||
|
message: expect.stringContaining("some extra data"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error when listing skills fails", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockRejectedValue(new Error("list error"));
|
||||||
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||||
|
await skillCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"error",
|
||||||
|
expect.stringContaining("Failed to list skills:")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error and resets character state when invoking skill fails", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockRejectedValue(new Error("invoke error"));
|
||||||
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||||
|
await skillCmd.execute("onboard-mentee");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"error",
|
||||||
|
expect.stringContaining("Failed to invoke skill:")
|
||||||
|
);
|
||||||
|
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/cd success path", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes directory and shows success message", async () => {
|
||||||
|
getMock
|
||||||
|
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
||||||
|
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
||||||
|
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
||||||
|
vi.mocked(claudeStore.getConversationHistory).mockReturnValue("");
|
||||||
|
invokeMock
|
||||||
|
.mockResolvedValueOnce("/new/path") // validate_directory
|
||||||
|
.mockResolvedValueOnce(undefined) // stop_claude
|
||||||
|
.mockResolvedValueOnce(undefined); // start_claude
|
||||||
|
|
||||||
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||||
|
const promise = cdCmd.execute("/new/path");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
"Changed directory to: /new/path"
|
||||||
|
);
|
||||||
|
expect(characterState.setState).toHaveBeenCalledWith("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends context restoration message when conversation history exists", async () => {
|
||||||
|
getMock
|
||||||
|
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
||||||
|
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
||||||
|
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
||||||
|
vi.mocked(claudeStore.getConversationHistory).mockReturnValue(
|
||||||
|
"previous conversation history"
|
||||||
|
);
|
||||||
|
invokeMock
|
||||||
|
.mockResolvedValueOnce("/new/path") // validate_directory
|
||||||
|
.mockResolvedValueOnce(undefined) // stop_claude
|
||||||
|
.mockResolvedValueOnce(undefined) // start_claude
|
||||||
|
.mockResolvedValueOnce(undefined); // send_prompt
|
||||||
|
|
||||||
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||||
|
const promise = cdCmd.execute("/new/path");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith(
|
||||||
|
"send_prompt",
|
||||||
|
expect.objectContaining({
|
||||||
|
message: expect.stringContaining("previous conversation history"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
"Changed directory to: /new/path"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls updateDiscordRpc when activeConversation is available", async () => {
|
||||||
|
const activeConv = {
|
||||||
|
name: "Test Conversation",
|
||||||
|
model: "claude-sonnet",
|
||||||
|
startedAt: new Date("2026-03-03T12:00:00Z"),
|
||||||
|
grantedTools: new Set<string>(),
|
||||||
|
};
|
||||||
|
getMock
|
||||||
|
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
||||||
|
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
||||||
|
.mockReturnValueOnce(activeConv); // get(conversationsStore.activeConversation)
|
||||||
|
vi.mocked(claudeStore.getConversationHistory).mockReturnValue("");
|
||||||
|
invokeMock
|
||||||
|
.mockResolvedValueOnce("/new/path")
|
||||||
|
.mockResolvedValueOnce(undefined)
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
const { updateDiscordRpc } = await import("$lib/tauri");
|
||||||
|
|
||||||
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||||
|
const promise = cdCmd.execute("/new/path");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(updateDiscordRpc).toHaveBeenCalledWith(
|
||||||
|
"Test Conversation",
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(Date)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/new success path", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a new conversation and shows success message", async () => {
|
||||||
|
getMock
|
||||||
|
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
||||||
|
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
||||||
|
invokeMock
|
||||||
|
.mockResolvedValueOnce("/working/dir") // get_working_directory
|
||||||
|
.mockResolvedValueOnce(undefined) // interrupt_claude
|
||||||
|
.mockResolvedValueOnce(undefined); // start_claude
|
||||||
|
|
||||||
|
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
||||||
|
const promise = newCmd.execute("");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "New conversation started!");
|
||||||
|
expect(characterState.setState).toHaveBeenCalledWith("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls updateDiscordRpc when activeConversation is available", async () => {
|
||||||
|
const activeConv = {
|
||||||
|
name: "My Conv",
|
||||||
|
model: "claude-sonnet",
|
||||||
|
startedAt: new Date("2026-03-03T12:00:00Z"),
|
||||||
|
grantedTools: new Set<string>(["tool1"]),
|
||||||
|
};
|
||||||
|
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(activeConv);
|
||||||
|
invokeMock
|
||||||
|
.mockResolvedValueOnce("/working/dir")
|
||||||
|
.mockResolvedValueOnce(undefined)
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
const { updateDiscordRpc } = await import("$lib/tauri");
|
||||||
|
|
||||||
|
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
||||||
|
const promise = newCmd.execute("");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(updateDiscordRpc).toHaveBeenCalledWith(
|
||||||
|
"My Conv",
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(Date)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { get } from "svelte/store";
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { claudeStore } from "$lib/stores/claude";
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
import { setSkipNextGreeting } from "$lib/tauri";
|
import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri";
|
||||||
import { searchState } from "$lib/stores/search";
|
import { searchState } from "$lib/stores/search";
|
||||||
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
|
import { configStore } from "$lib/stores/config";
|
||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -35,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
|
||||||
@@ -48,9 +56,26 @@ async function changeDirectory(path: string): Promise<void> {
|
|||||||
conversationId,
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
working_dir: validatedPath,
|
working_dir: validatedPath,
|
||||||
|
model: config.model || null,
|
||||||
|
api_key: config.api_key || null,
|
||||||
|
custom_instructions: config.custom_instructions || null,
|
||||||
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
|
allowed_tools: allAllowedTools,
|
||||||
|
use_worktree: config.use_worktree ?? false,
|
||||||
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
|
max_output_tokens: config.max_output_tokens ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update Discord RPC when reconnecting after directory change
|
||||||
|
if (activeConversation) {
|
||||||
|
await updateDiscordRpc(
|
||||||
|
activeConversation.name,
|
||||||
|
config.model || "claude",
|
||||||
|
activeConversation.startedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for connection to establish
|
// Wait for connection to establish
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
@@ -89,6 +114,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");
|
||||||
|
|
||||||
@@ -102,9 +133,26 @@ async function startNewConversation(): Promise<void> {
|
|||||||
conversationId,
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
working_dir: workingDir,
|
working_dir: workingDir,
|
||||||
|
model: config.model || null,
|
||||||
|
api_key: config.api_key || null,
|
||||||
|
custom_instructions: config.custom_instructions || null,
|
||||||
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
|
allowed_tools: allAllowedTools,
|
||||||
|
use_worktree: config.use_worktree ?? false,
|
||||||
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
|
max_output_tokens: config.max_output_tokens ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update Discord RPC when starting new conversation
|
||||||
|
if (activeConversation) {
|
||||||
|
await updateDiscordRpc(
|
||||||
|
activeConversation.name,
|
||||||
|
config.model || "claude",
|
||||||
|
activeConversation.startedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
claudeStore.addLine("system", "New conversation started!");
|
claudeStore.addLine("system", "New conversation started!");
|
||||||
characterState.setState("idle");
|
characterState.setState("idle");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -161,7 +161,7 @@
|
|||||||
|
|
||||||
<!-- Celebration confetti effect (CSS only) -->
|
<!-- Celebration confetti effect (CSS only) -->
|
||||||
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
||||||
{#each Array(10) as _ (_)}
|
{#each Array.from({ length: 10 }, (_, i) => i) as confettiIndex (confettiIndex)}
|
||||||
<div
|
<div
|
||||||
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
|
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
|
||||||
getAchievementRarity(currentAchievement.achievement.id)
|
getAchievementRarity(currentAchievement.achievement.id)
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* AchievementNotification Component Tests
|
||||||
|
*
|
||||||
|
* Tests the rarity classification and colour mapping logic used by the
|
||||||
|
* AchievementNotification component.
|
||||||
|
*
|
||||||
|
* What this component does:
|
||||||
|
* - Listens for "achievement:unlocked" Tauri events
|
||||||
|
* - Queues and displays achievement notifications one at a time
|
||||||
|
* - Each notification shows the achievement's name, icon, description, and rarity
|
||||||
|
* - A gradient border and badge colour correspond to the achievement's rarity
|
||||||
|
*
|
||||||
|
* Manual testing checklist:
|
||||||
|
* - [ ] Achievement notification slides in from the right
|
||||||
|
* - [ ] Notification auto-dismisses after 5 seconds
|
||||||
|
* - [ ] Dismiss button works immediately
|
||||||
|
* - [ ] Multiple achievements queue and display sequentially
|
||||||
|
* - [ ] Legendary achievements have a yellow-orange gradient
|
||||||
|
* - [ ] Epic achievements have a purple-pink gradient
|
||||||
|
* - [ ] Rare achievements have a blue-indigo gradient
|
||||||
|
* - [ ] Common achievements have a green-emerald gradient
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
function getAchievementRarity(id: string): string {
|
||||||
|
if (id === "TokenMaster") return "legendary";
|
||||||
|
if (["CodeMachine", "Unstoppable"].includes(id)) return "epic";
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
"BlossomingCoder",
|
||||||
|
"CodeWizard",
|
||||||
|
"MasterBuilder",
|
||||||
|
"EnduranceChamp",
|
||||||
|
"DeepDive",
|
||||||
|
"CreativeCoder",
|
||||||
|
].includes(id)
|
||||||
|
)
|
||||||
|
return "rare";
|
||||||
|
return "common";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRarityColor(rarity: string): string {
|
||||||
|
switch (rarity) {
|
||||||
|
case "legendary":
|
||||||
|
return "from-yellow-400 to-orange-500";
|
||||||
|
case "epic":
|
||||||
|
return "from-purple-400 to-pink-500";
|
||||||
|
case "rare":
|
||||||
|
return "from-blue-400 to-indigo-500";
|
||||||
|
default:
|
||||||
|
return "from-green-400 to-emerald-500";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
describe("getAchievementRarity", () => {
|
||||||
|
describe("legendary tier", () => {
|
||||||
|
it("classifies TokenMaster as legendary", () => {
|
||||||
|
expect(getAchievementRarity("TokenMaster")).toBe("legendary");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("epic tier", () => {
|
||||||
|
it("classifies CodeMachine as epic", () => {
|
||||||
|
expect(getAchievementRarity("CodeMachine")).toBe("epic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies Unstoppable as epic", () => {
|
||||||
|
expect(getAchievementRarity("Unstoppable")).toBe("epic");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rare tier", () => {
|
||||||
|
it("classifies BlossomingCoder as rare", () => {
|
||||||
|
expect(getAchievementRarity("BlossomingCoder")).toBe("rare");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies CodeWizard as rare", () => {
|
||||||
|
expect(getAchievementRarity("CodeWizard")).toBe("rare");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies MasterBuilder as rare", () => {
|
||||||
|
expect(getAchievementRarity("MasterBuilder")).toBe("rare");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies EnduranceChamp as rare", () => {
|
||||||
|
expect(getAchievementRarity("EnduranceChamp")).toBe("rare");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies DeepDive as rare", () => {
|
||||||
|
expect(getAchievementRarity("DeepDive")).toBe("rare");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies CreativeCoder as rare", () => {
|
||||||
|
expect(getAchievementRarity("CreativeCoder")).toBe("rare");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("common tier", () => {
|
||||||
|
it("classifies unknown IDs as common", () => {
|
||||||
|
expect(getAchievementRarity("FirstChat")).toBe("common");
|
||||||
|
expect(getAchievementRarity("SomeNewAchievement")).toBe("common");
|
||||||
|
expect(getAchievementRarity("")).toBe("common");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getRarityColor", () => {
|
||||||
|
it("returns yellow-to-orange gradient for legendary", () => {
|
||||||
|
expect(getRarityColor("legendary")).toBe("from-yellow-400 to-orange-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns purple-to-pink gradient for epic", () => {
|
||||||
|
expect(getRarityColor("epic")).toBe("from-purple-400 to-pink-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns blue-to-indigo gradient for rare", () => {
|
||||||
|
expect(getRarityColor("rare")).toBe("from-blue-400 to-indigo-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns green-to-emerald gradient for common", () => {
|
||||||
|
expect(getRarityColor("common")).toBe("from-green-400 to-emerald-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to green-to-emerald gradient for unknown rarities", () => {
|
||||||
|
expect(getRarityColor("mythic")).toBe("from-green-400 to-emerald-500");
|
||||||
|
expect(getRarityColor("")).toBe("from-green-400 to-emerald-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("end-to-end rarity pipeline", () => {
|
||||||
|
it("produces the correct colour for a legendary achievement", () => {
|
||||||
|
const color = getRarityColor(getAchievementRarity("TokenMaster"));
|
||||||
|
expect(color).toBe("from-yellow-400 to-orange-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces the correct colour for an epic achievement", () => {
|
||||||
|
const color = getRarityColor(getAchievementRarity("CodeMachine"));
|
||||||
|
expect(color).toBe("from-purple-400 to-pink-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces the correct colour for a rare achievement", () => {
|
||||||
|
const color = getRarityColor(getAchievementRarity("CodeWizard"));
|
||||||
|
expect(color).toBe("from-blue-400 to-indigo-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces the correct colour for a common achievement", () => {
|
||||||
|
const color = getRarityColor(getAchievementRarity("FirstChat"));
|
||||||
|
expect(color).toBe("from-green-400 to-emerald-500");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,153 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import type { ChangelogEntry } from "$lib/types/messages";
|
||||||
|
import Markdown from "./Markdown.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onClose }: Props = $props();
|
||||||
|
|
||||||
|
let entries = $state<ChangelogEntry[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let currentVersion = $state("");
|
||||||
|
|
||||||
|
export function formatReleaseDate(isoString: string): string {
|
||||||
|
if (!isoString) return "Unknown date";
|
||||||
|
const date = new Date(isoString);
|
||||||
|
if (isNaN(date.getTime())) return "Unknown date";
|
||||||
|
return date.toLocaleDateString("en-GB", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
timeZone: "UTC",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChangelog(): Promise<void> {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
entries = await invoke<ChangelogEntry[]>("fetch_changelog");
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
currentVersion = await getVersion();
|
||||||
|
await loadChangelog();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||||
|
onclick={onClose}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="changelog-title"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||||
|
<h2 id="changelog-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||||
|
Changelog
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-y-auto flex-1 p-6">
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center justify-center py-12">
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 border-2 border-[var(--accent-primary)] border-t-transparent rounded-full animate-spin"
|
||||||
|
></div>
|
||||||
|
<span class="ml-3 text-[var(--text-secondary)]">Fetching releases...</span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<p class="text-red-400 mb-4">{error}</p>
|
||||||
|
<button onclick={loadChangelog} class="btn-trans-gradient px-4 py-2 rounded text-sm">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if entries.length === 0}
|
||||||
|
<p class="text-center text-[var(--text-secondary)] py-12">No releases found.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-6">
|
||||||
|
{#each entries as entry (entry.version)}
|
||||||
|
<div class="border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-2 px-4 py-3 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="font-mono font-semibold text-sm {entry.version === `v${currentVersion}`
|
||||||
|
? 'text-[var(--trans-pink)]'
|
||||||
|
: 'text-[var(--text-primary)]'}"
|
||||||
|
>
|
||||||
|
{entry.version}
|
||||||
|
</span>
|
||||||
|
{#if entry.version === `v${currentVersion}`}
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full bg-[var(--trans-pink)]/20 text-[var(--trans-pink)] border border-[var(--trans-pink)]/30"
|
||||||
|
>
|
||||||
|
current
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if entry.prerelease}
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full bg-yellow-500/20 text-yellow-400 border border-yellow-500/30"
|
||||||
|
>
|
||||||
|
pre-release
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="ml-auto text-xs text-[var(--text-muted)]">
|
||||||
|
{formatReleaseDate(entry.created_at)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onclick={() => openUrl(entry.url)}
|
||||||
|
class="text-xs text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
|
||||||
|
>
|
||||||
|
View on Gitea
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if entry.notes}
|
||||||
|
<div class="p-4 text-sm text-[var(--text-secondary)]">
|
||||||
|
<Markdown content={entry.notes} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="p-4 text-xs text-[var(--text-muted)] italic">No release notes.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* ChangelogPanel Component Tests
|
||||||
|
*
|
||||||
|
* Tests the pure helper function exported by ChangelogPanel for formatting
|
||||||
|
* ISO 8601 date strings into human-readable release dates.
|
||||||
|
*
|
||||||
|
* What this component does:
|
||||||
|
* - Opens as a modal dialog from the nav menu
|
||||||
|
* - Fetches all releases via the `fetch_changelog` Tauri IPC command on mount
|
||||||
|
* - Shows a loading spinner while fetching
|
||||||
|
* - Renders each release with version badge, date, pre-release badge, and notes
|
||||||
|
* - Highlights the currently installed version with a pink "current" badge
|
||||||
|
* - Provides a "View on Gitea" link per release
|
||||||
|
* - Shows an error state with a Retry button if the fetch fails
|
||||||
|
*
|
||||||
|
* Manual testing checklist:
|
||||||
|
* - [ ] Changelog item appears in the nav dropdown
|
||||||
|
* - [ ] Clicking opens the panel with a loading spinner
|
||||||
|
* - [ ] Spinner resolves to a list of releases
|
||||||
|
* - [ ] Current version entry shows pink version text + "current" badge
|
||||||
|
* - [ ] Pre-release entries show a yellow "pre-release" badge
|
||||||
|
* - [ ] "View on Gitea" opens the release URL in the browser
|
||||||
|
* - [ ] Backdrop click and Escape key close the panel
|
||||||
|
* - [ ] Network error shows a red error message and a Retry button
|
||||||
|
* - [ ] Retry button re-fetches the changelog
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
function formatReleaseDate(isoString: string): string {
|
||||||
|
if (!isoString) return "Unknown date";
|
||||||
|
const date = new Date(isoString);
|
||||||
|
if (isNaN(date.getTime())) return "Unknown date";
|
||||||
|
return date.toLocaleDateString("en-GB", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
timeZone: "UTC",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
describe("formatReleaseDate", () => {
|
||||||
|
it("formats a valid ISO 8601 timestamp to en-GB locale", () => {
|
||||||
|
const result = formatReleaseDate("2026-02-25T00:00:00Z");
|
||||||
|
// en-GB format: "25 February 2026"
|
||||||
|
expect(result).toBe("25 February 2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'Unknown date' for an empty string", () => {
|
||||||
|
expect(formatReleaseDate("")).toBe("Unknown date");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'Unknown date' for a non-date string", () => {
|
||||||
|
expect(formatReleaseDate("not-a-date")).toBe("Unknown date");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a timestamp with a time component", () => {
|
||||||
|
const result = formatReleaseDate("2025-12-01T14:32:00Z");
|
||||||
|
expect(result).toBe("1 December 2025");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats a single-digit day without leading zero in en-GB", () => {
|
||||||
|
const result = formatReleaseDate("2026-03-06T00:00:00Z");
|
||||||
|
expect(result).toBe("6 March 2026");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,134 @@
|
|||||||
|
/**
|
||||||
|
* CliVersion Component Tests
|
||||||
|
*
|
||||||
|
* Tests the version comparison logic used by the CliVersion component,
|
||||||
|
* which compares the installed CLI version against the supported version.
|
||||||
|
*
|
||||||
|
* What this component does:
|
||||||
|
* - Displays the installed Claude CLI version
|
||||||
|
* - Displays the highest audited/supported CLI version
|
||||||
|
* - Shows a warning when the installed version is ahead of or behind supported
|
||||||
|
*
|
||||||
|
* Manual testing checklist:
|
||||||
|
* - [ ] Installed version is fetched and displayed on mount
|
||||||
|
* - [ ] "current" badge shows in green when versions match
|
||||||
|
* - [ ] "ahead" badge shows in amber when installed is newer than supported
|
||||||
|
* - [ ] "behind" badge shows in red when installed is older than supported
|
||||||
|
* - [ ] Warning message appears for "ahead" and "behind" states
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
const SUPPORTED_CLI_VERSION = "2.1.53";
|
||||||
|
|
||||||
|
function compareVersions(a: string, b: string): number {
|
||||||
|
const aParts = a.split(".").map(Number);
|
||||||
|
const bParts = b.split(".").map(Number);
|
||||||
|
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
||||||
|
const aVal = aParts[i] ?? 0;
|
||||||
|
const bVal = bParts[i] ?? 0;
|
||||||
|
if (aVal > bVal) return 1;
|
||||||
|
if (aVal < bVal) return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
describe("SUPPORTED_CLI_VERSION", () => {
|
||||||
|
it("is defined and non-empty", () => {
|
||||||
|
expect(SUPPORTED_CLI_VERSION).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches the expected audited version", () => {
|
||||||
|
expect(SUPPORTED_CLI_VERSION).toBe("2.1.53");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("compareVersions", () => {
|
||||||
|
describe("equal versions", () => {
|
||||||
|
it("returns 0 for identical versions", () => {
|
||||||
|
expect(compareVersions("1.0.0", "1.0.0")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 for the supported CLI version against itself", () => {
|
||||||
|
expect(compareVersions(SUPPORTED_CLI_VERSION, SUPPORTED_CLI_VERSION)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 for 0.0.0 vs 0.0.0", () => {
|
||||||
|
expect(compareVersions("0.0.0", "0.0.0")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("major version differences", () => {
|
||||||
|
it("returns 1 when a has a higher major version", () => {
|
||||||
|
expect(compareVersions("2.0.0", "1.0.0")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns -1 when a has a lower major version", () => {
|
||||||
|
expect(compareVersions("1.0.0", "2.0.0")).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("minor version differences", () => {
|
||||||
|
it("returns 1 when a has a higher minor version", () => {
|
||||||
|
expect(compareVersions("1.2.0", "1.1.0")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns -1 when a has a lower minor version", () => {
|
||||||
|
expect(compareVersions("1.1.0", "1.2.0")).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("patch version differences", () => {
|
||||||
|
it("returns 1 when a has a higher patch version", () => {
|
||||||
|
expect(compareVersions("1.0.2", "1.0.1")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns -1 when a has a lower patch version", () => {
|
||||||
|
expect(compareVersions("1.0.1", "1.0.2")).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("major version takes precedence", () => {
|
||||||
|
it("returns 1 when a has a higher major but lower minor", () => {
|
||||||
|
expect(compareVersions("2.0.0", "1.9.9")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns -1 when a has a lower major but higher minor", () => {
|
||||||
|
expect(compareVersions("1.9.9", "2.0.0")).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unequal segment counts", () => {
|
||||||
|
it("treats missing segments as 0 (a shorter than b)", () => {
|
||||||
|
expect(compareVersions("1.0", "1.0.0")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats missing segments as 0 (a longer than b)", () => {
|
||||||
|
expect(compareVersions("1.0.0", "1.0")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("correctly compares when a has an extra non-zero segment", () => {
|
||||||
|
expect(compareVersions("1.0.1", "1.0")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("correctly compares when b has an extra non-zero segment", () => {
|
||||||
|
expect(compareVersions("1.0", "1.0.1")).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("supported CLI version comparisons", () => {
|
||||||
|
it("returns 1 for a version ahead of supported", () => {
|
||||||
|
expect(compareVersions("2.2.0", SUPPORTED_CLI_VERSION)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns -1 for a version behind supported", () => {
|
||||||
|
expect(compareVersions("2.1.0", SUPPORTED_CLI_VERSION)).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 for exactly the supported version", () => {
|
||||||
|
expect(compareVersions("2.1.53", SUPPORTED_CLI_VERSION)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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()}">
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
type Theme,
|
type Theme,
|
||||||
type CustomThemeColors,
|
type CustomThemeColors,
|
||||||
applyFontSize,
|
applyFontSize,
|
||||||
|
applyCustomFont,
|
||||||
|
applyCustomUiFont,
|
||||||
applyCustomThemeColors,
|
applyCustomThemeColors,
|
||||||
MIN_FONT_SIZE,
|
MIN_FONT_SIZE,
|
||||||
MAX_FONT_SIZE,
|
MAX_FONT_SIZE,
|
||||||
@@ -12,6 +14,9 @@
|
|||||||
} from "$lib/stores/config";
|
} from "$lib/stores/config";
|
||||||
import { claudeStore } from "$lib/stores/claude";
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import CostSummary from "./CostSummary.svelte";
|
||||||
|
|
||||||
let config: HikariConfig = $state({
|
let config: HikariConfig = $state({
|
||||||
model: null,
|
model: null,
|
||||||
@@ -25,7 +30,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,
|
||||||
@@ -45,9 +49,50 @@
|
|||||||
text_secondary: null,
|
text_secondary: null,
|
||||||
border_color: null,
|
border_color: null,
|
||||||
},
|
},
|
||||||
|
budget_enabled: false,
|
||||||
|
session_token_budget: null,
|
||||||
|
session_cost_budget: null,
|
||||||
|
budget_action: "warn",
|
||||||
|
budget_warning_threshold: 0.8,
|
||||||
|
discord_rpc_enabled: true,
|
||||||
|
show_thinking_blocks: true,
|
||||||
|
use_worktree: false,
|
||||||
|
disable_1m_context: false,
|
||||||
|
max_output_tokens: null,
|
||||||
|
trusted_workspaces: [],
|
||||||
|
background_image_path: null,
|
||||||
|
background_image_opacity: 0.3,
|
||||||
|
custom_font_path: null,
|
||||||
|
custom_font_family: null,
|
||||||
|
custom_ui_font_path: null,
|
||||||
|
custom_ui_font_family: null,
|
||||||
|
task_loop_auto_commit: false,
|
||||||
|
task_loop_commit_prefix: "feat",
|
||||||
|
task_loop_include_summary: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let showCustomThemeEditor = $state(false);
|
let showCustomThemeEditor = $state(false);
|
||||||
|
let customFontPathInput = $state("");
|
||||||
|
let customFontFamilyInput = $state("");
|
||||||
|
let customFontStatus: string | null = $state(null);
|
||||||
|
let customUiFontPathInput = $state("");
|
||||||
|
let customUiFontFamilyInput = $state("");
|
||||||
|
let customUiFontStatus: string | null = $state(null);
|
||||||
|
|
||||||
|
interface AuthStatus {
|
||||||
|
is_logged_in: boolean;
|
||||||
|
email: string | null;
|
||||||
|
org_id: string | null;
|
||||||
|
org_name: string | null;
|
||||||
|
api_key_source: string | null;
|
||||||
|
api_provider: string | null;
|
||||||
|
subscription_type: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let authStatus: AuthStatus | null = $state(null);
|
||||||
|
let authLoading = $state(false);
|
||||||
|
let authActionLoading = $state(false);
|
||||||
|
let authError: string | null = $state(null);
|
||||||
|
|
||||||
let isOpen = $state(false);
|
let isOpen = $state(false);
|
||||||
let isSaving = $state(false);
|
let isSaving = $state(false);
|
||||||
@@ -58,10 +103,17 @@
|
|||||||
|
|
||||||
configStore.config.subscribe((c) => {
|
configStore.config.subscribe((c) => {
|
||||||
config = { ...c };
|
config = { ...c };
|
||||||
|
customFontPathInput = c.custom_font_path ?? "";
|
||||||
|
customFontFamilyInput = c.custom_font_family ?? "";
|
||||||
|
customUiFontPathInput = c.custom_ui_font_path ?? "";
|
||||||
|
customUiFontFamilyInput = c.custom_ui_font_family ?? "";
|
||||||
});
|
});
|
||||||
|
|
||||||
configStore.isSidebarOpen.subscribe((open) => {
|
configStore.isSidebarOpen.subscribe((open) => {
|
||||||
isOpen = open;
|
isOpen = open;
|
||||||
|
if (open && authStatus === null) {
|
||||||
|
void refreshAuthStatus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
configStore.saveError.subscribe((error) => {
|
configStore.saveError.subscribe((error) => {
|
||||||
@@ -74,8 +126,22 @@
|
|||||||
|
|
||||||
const availableModels = [
|
const availableModels = [
|
||||||
{ value: "", label: "Default (from ~/.claude)" },
|
{ value: "", label: "Default (from ~/.claude)" },
|
||||||
|
// Current generation (Claude 4.6)
|
||||||
|
{ value: "claude-opus-4-6", label: "Claude Opus 4.6 (Most Capable)" },
|
||||||
|
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 (Recommended)" },
|
||||||
|
// Previous generation (Claude 4.5)
|
||||||
|
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
|
||||||
|
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" },
|
||||||
|
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5" },
|
||||||
|
// Previous generation (Claude 4.x)
|
||||||
|
{ value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" },
|
||||||
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
|
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
|
||||||
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
|
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
|
||||||
|
// Legacy (Claude 3.x)
|
||||||
|
{ value: "claude-3-7-sonnet-20250219", label: "Claude 3.7 Sonnet" },
|
||||||
|
{ value: "claude-3-5-sonnet-20241022", label: "Claude 3.5 Sonnet (Oct 2024)" },
|
||||||
|
{ value: "claude-3-5-sonnet-20240620", label: "Claude 3.5 Sonnet (Jun 2024)" },
|
||||||
|
{ value: "claude-3-haiku-20240307", label: "Claude 3 Haiku (Cheapest)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const commonTools = [
|
const commonTools = [
|
||||||
@@ -90,6 +156,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;
|
||||||
@@ -161,6 +265,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 -->
|
||||||
@@ -207,6 +325,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">
|
||||||
@@ -301,6 +522,56 @@
|
|||||||
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none"
|
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Worktree Isolation -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.use_worktree}
|
||||||
|
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Worktree isolation</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
Launch sessions with <code class="font-mono">--worktree</code> for isolated git worktree environments
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Disable 1M Context Window -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.disable_1m_context}
|
||||||
|
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Disable 1M context window</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
Sets <code class="font-mono">CLAUDE_CODE_DISABLE_1M_CONTEXT=1</code> to opt out of the extended
|
||||||
|
context window
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Max Output Tokens -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm text-[var(--text-primary)] mb-1" for="max-output-tokens">
|
||||||
|
Max output tokens
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="max-output-tokens"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="Default (32000)"
|
||||||
|
bind:value={config.max_output_tokens}
|
||||||
|
class="w-full px-3 py-2 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||||
|
Sets <code class="font-mono">CLAUDE_CODE_MAX_OUTPUT_TOKENS</code> — increase if responses are
|
||||||
|
being cut off mid-reply
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Greeting Section -->
|
<!-- Greeting Section -->
|
||||||
@@ -462,7 +733,7 @@
|
|||||||
<div class="flex flex-wrap gap-2" role="group" aria-label="Theme selection">
|
<div class="flex flex-wrap gap-2" role="group" aria-label="Theme selection">
|
||||||
<button
|
<button
|
||||||
onclick={() => handleThemeChange("dark")}
|
onclick={() => handleThemeChange("dark")}
|
||||||
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
'dark'
|
'dark'
|
||||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
@@ -471,7 +742,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => handleThemeChange("light")}
|
onclick={() => handleThemeChange("light")}
|
||||||
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
'light'
|
'light'
|
||||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
@@ -480,7 +751,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => handleThemeChange("high-contrast")}
|
onclick={() => handleThemeChange("high-contrast")}
|
||||||
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
'high-contrast'
|
'high-contrast'
|
||||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
@@ -490,7 +761,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => handleThemeChange("custom")}
|
onclick={() => handleThemeChange("custom")}
|
||||||
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
'custom'
|
'custom'
|
||||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
@@ -499,6 +770,96 @@
|
|||||||
Custom
|
Custom
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset Themes — Dark -->
|
||||||
|
<span class="block text-xs text-[var(--text-tertiary)] mt-3 mb-2">Dark Presets</span>
|
||||||
|
<div class="flex flex-wrap gap-2" role="group" aria-label="Dark preset theme selection">
|
||||||
|
<button
|
||||||
|
onclick={() => handleThemeChange("dracula")}
|
||||||
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
|
'dracula'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
title="Dracula theme"
|
||||||
|
>
|
||||||
|
Dracula
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleThemeChange("catppuccin")}
|
||||||
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
|
'catppuccin'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
title="Catppuccin Mocha theme"
|
||||||
|
>
|
||||||
|
Catppuccin
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleThemeChange("nord")}
|
||||||
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
|
'nord'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
title="Nord theme"
|
||||||
|
>
|
||||||
|
Nord
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleThemeChange("solarized")}
|
||||||
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
|
'solarized'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
title="Solarized Dark theme"
|
||||||
|
>
|
||||||
|
Solarized
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset Themes — Light -->
|
||||||
|
<span class="block text-xs text-[var(--text-tertiary)] mt-3 mb-2">Light Presets</span>
|
||||||
|
<div class="flex flex-wrap gap-2" role="group" aria-label="Light preset theme selection">
|
||||||
|
<button
|
||||||
|
onclick={() => handleThemeChange("solarized-light")}
|
||||||
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
|
'solarized-light'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
title="Solarized Light theme"
|
||||||
|
>
|
||||||
|
Solarized
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleThemeChange("catppuccin-latte")}
|
||||||
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
|
'catppuccin-latte'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
title="Catppuccin Latte theme"
|
||||||
|
>
|
||||||
|
Latte
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleThemeChange("gruvbox-light")}
|
||||||
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
|
'gruvbox-light'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
title="Gruvbox Light theme"
|
||||||
|
>
|
||||||
|
Gruvbox
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleThemeChange("rose-pine-dawn")}
|
||||||
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
|
'rose-pine-dawn'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
title="Rosé Pine Dawn theme"
|
||||||
|
>
|
||||||
|
Rosé Pine
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Custom Theme Editor -->
|
<!-- Custom Theme Editor -->
|
||||||
@@ -684,6 +1045,192 @@
|
|||||||
Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset
|
Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Terminal Font -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="block text-sm text-[var(--text-secondary)] mb-2">Custom Terminal Font</span>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={customFontPathInput}
|
||||||
|
placeholder="URL or local file path (e.g. /path/to/font.ttf)"
|
||||||
|
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={customFontFamilyInput}
|
||||||
|
placeholder="Font family name (e.g. FiraCode)"
|
||||||
|
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={async () => {
|
||||||
|
customFontStatus = null;
|
||||||
|
try {
|
||||||
|
await configStore.setCustomFont(
|
||||||
|
customFontPathInput || null,
|
||||||
|
customFontFamilyInput || null
|
||||||
|
);
|
||||||
|
customFontStatus = "Font applied!";
|
||||||
|
} catch (e) {
|
||||||
|
customFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={async () => {
|
||||||
|
customFontStatus = null;
|
||||||
|
customFontPathInput = "";
|
||||||
|
customFontFamilyInput = "";
|
||||||
|
try {
|
||||||
|
await configStore.setCustomFont(null, null);
|
||||||
|
await applyCustomFont(null, null);
|
||||||
|
customFontStatus = "Font reset to default.";
|
||||||
|
} catch (e) {
|
||||||
|
customFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if customFontStatus}
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)]">{customFontStatus}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||||
|
Supports Google Fonts URLs, direct font file URLs, or local file paths. Family name is
|
||||||
|
required to apply the font.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom UI Font -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="block text-sm text-[var(--text-secondary)] mb-2">Custom UI Font</span>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={customUiFontPathInput}
|
||||||
|
placeholder="URL or local file path (e.g. /path/to/font.ttf)"
|
||||||
|
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={customUiFontFamilyInput}
|
||||||
|
placeholder="Font family name (e.g. Inter)"
|
||||||
|
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={async () => {
|
||||||
|
customUiFontStatus = null;
|
||||||
|
try {
|
||||||
|
await configStore.setCustomUiFont(
|
||||||
|
customUiFontPathInput || null,
|
||||||
|
customUiFontFamilyInput || null
|
||||||
|
);
|
||||||
|
customUiFontStatus = "Font applied!";
|
||||||
|
} catch (e) {
|
||||||
|
customUiFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Apply UI Font
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={async () => {
|
||||||
|
customUiFontStatus = null;
|
||||||
|
customUiFontPathInput = "";
|
||||||
|
customUiFontFamilyInput = "";
|
||||||
|
try {
|
||||||
|
await configStore.setCustomUiFont(null, null);
|
||||||
|
await applyCustomUiFont(null, null);
|
||||||
|
customUiFontStatus = "Font reset to default.";
|
||||||
|
} catch (e) {
|
||||||
|
customUiFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if customUiFontStatus}
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)]">{customUiFontStatus}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||||
|
Applies to the entire app interface (menus, labels, buttons). Supports Google Fonts URLs,
|
||||||
|
direct font file URLs, or local file paths.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Show Thinking Blocks Toggle -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.show_thinking_blocks}
|
||||||
|
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Show Extended Thinking Blocks</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
Display Claude's extended thinking process in the conversation. Thinking blocks can be
|
||||||
|
expanded/collapsed to see reasoning details.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Background Image -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="block text-sm text-[var(--text-secondary)] mb-2">Background Image</span>
|
||||||
|
{#if config.background_image_path}
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] font-mono mb-2 truncate">
|
||||||
|
{config.background_image_path.split("/").pop()}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={pickBackgroundImage}
|
||||||
|
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
{config.background_image_path ? "Change Image" : "Choose Image"}
|
||||||
|
</button>
|
||||||
|
{#if config.background_image_path}
|
||||||
|
<button
|
||||||
|
onclick={clearBackgroundImage}
|
||||||
|
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
|
||||||
|
title="Remove background image"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if config.background_image_path}
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<label for="bg-opacity" class="text-xs text-[var(--text-secondary)]"> Opacity </label>
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)]">
|
||||||
|
{Math.round(config.background_image_opacity * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="bg-opacity"
|
||||||
|
type="range"
|
||||||
|
bind:value={config.background_image_opacity}
|
||||||
|
min="0.05"
|
||||||
|
max="1"
|
||||||
|
step="0.05"
|
||||||
|
class="w-full h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Window Section -->
|
<!-- Window Section -->
|
||||||
@@ -708,21 +1255,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">
|
||||||
@@ -778,6 +1310,135 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Budget Settings Section -->
|
||||||
|
<section class="mb-6">
|
||||||
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
|
Budget Settings
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Enable Budget Tracking -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.budget_enabled}
|
||||||
|
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Enable budget tracking</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
Set limits on token usage and costs per session
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if config.budget_enabled}
|
||||||
|
<!-- Token Budget -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="token-budget" class="block text-sm text-[var(--text-secondary)] mb-1">
|
||||||
|
Session Token Budget
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="token-budget"
|
||||||
|
type="number"
|
||||||
|
bind:value={config.session_token_budget}
|
||||||
|
min="0"
|
||||||
|
step="10000"
|
||||||
|
placeholder="e.g., 100000"
|
||||||
|
class="flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)]">tokens</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1">Leave empty for unlimited tokens</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cost Budget -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="cost-budget" class="block text-sm text-[var(--text-secondary)] mb-1">
|
||||||
|
Session Cost Budget
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-[var(--text-secondary)]">$</span>
|
||||||
|
<input
|
||||||
|
id="cost-budget"
|
||||||
|
type="number"
|
||||||
|
bind:value={config.session_cost_budget}
|
||||||
|
min="0"
|
||||||
|
step="0.50"
|
||||||
|
placeholder="e.g., 5.00"
|
||||||
|
class="flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)]">USD</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1">Leave empty for unlimited spending</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Warning Threshold -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="warning-threshold" class="block text-sm text-[var(--text-secondary)] mb-2">
|
||||||
|
Warning Threshold
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
id="warning-threshold"
|
||||||
|
type="range"
|
||||||
|
bind:value={config.budget_warning_threshold}
|
||||||
|
min="0.5"
|
||||||
|
max="0.95"
|
||||||
|
step="0.05"
|
||||||
|
class="flex-1 h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-secondary)] w-12 text-right">
|
||||||
|
{Math.round(config.budget_warning_threshold * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||||
|
Show warning when this percentage of budget is used
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Budget Action -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="block text-sm text-[var(--text-secondary)] mb-2"
|
||||||
|
>When budget is exceeded</span
|
||||||
|
>
|
||||||
|
<div class="flex gap-2" role="group" aria-label="Budget action">
|
||||||
|
<button
|
||||||
|
onclick={() => (config.budget_action = "warn")}
|
||||||
|
class="flex-1 px-3 py-2 rounded-lg border transition-colors text-sm {config.budget_action ===
|
||||||
|
'warn'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
>
|
||||||
|
Warn Only
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (config.budget_action = "block")}
|
||||||
|
class="flex-1 px-3 py-2 rounded-lg border transition-colors text-sm {config.budget_action ===
|
||||||
|
'block'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
>
|
||||||
|
Block Input
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-2">
|
||||||
|
{config.budget_action === "warn"
|
||||||
|
? "Show a warning but allow continued usage"
|
||||||
|
: "Prevent sending more messages until session is reset"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Cost History Section -->
|
||||||
|
<section class="mb-6">
|
||||||
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
|
Cost History
|
||||||
|
</h3>
|
||||||
|
<CostSummary />
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Notifications Section -->
|
<!-- Notifications Section -->
|
||||||
<section class="mb-6">
|
<section class="mb-6">
|
||||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
@@ -823,6 +1484,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Discord Rich Presence Section -->
|
||||||
|
<section class="pt-6 pb-6 border-t border-[var(--border-color)]">
|
||||||
|
<h3 class="text-lg font-semibold text-[var(--accent-primary)] mb-4 flex items-center gap-2">
|
||||||
|
<span>🎮</span>
|
||||||
|
<span>Discord Rich Presence</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Enable/Disable Discord RPC -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.discord_rpc_enabled}
|
||||||
|
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Show activity in Discord</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-[var(--text-tertiary)]">
|
||||||
|
Display your current conversation session name and model in Discord when enabled.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Save Button -->
|
<!-- Save Button -->
|
||||||
<div class="sticky bottom-0 pt-4 pb-2 bg-[var(--bg-secondary)]">
|
<div class="sticky bottom-0 pt-4 pb-2 bg-[var(--bg-secondary)]">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -12,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,111 @@
|
|||||||
|
/**
|
||||||
|
* ConversationTabs Component Tests
|
||||||
|
*
|
||||||
|
* Tests the connection status colour mapping and unread message detection
|
||||||
|
* logic used by the ConversationTabs component.
|
||||||
|
*
|
||||||
|
* What this component does:
|
||||||
|
* - Displays one tab per conversation
|
||||||
|
* - Each tab shows a coloured dot for its connection state
|
||||||
|
* - Inactive tabs with new messages show an animated blue dot badge
|
||||||
|
* - Tabs can be renamed by double-clicking
|
||||||
|
* - Tabs can be reordered by drag-and-drop
|
||||||
|
* - New tabs created with Ctrl+T, closed with Ctrl+W
|
||||||
|
*
|
||||||
|
* Manual testing checklist:
|
||||||
|
* - [ ] Connected tabs show a green dot
|
||||||
|
* - [ ] Connecting tabs show a yellow dot
|
||||||
|
* - [ ] Disconnected tabs show a red dot
|
||||||
|
* - [ ] Active tab never shows the unread badge
|
||||||
|
* - [ ] Inactive tab shows blue pulsing dot when it receives new messages
|
||||||
|
* - [ ] Switching to a tab clears the unread indicator
|
||||||
|
* - [ ] Double-clicking a tab name enables inline editing
|
||||||
|
* - [ ] Tabs can be dragged to reorder
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
type ConnectionStatus = "connected" | "connecting" | "disconnected";
|
||||||
|
|
||||||
|
function getConnectionStatusColor(status: ConnectionStatus | string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "connected":
|
||||||
|
return "bg-green-500";
|
||||||
|
case "connecting":
|
||||||
|
return "bg-yellow-500";
|
||||||
|
case "disconnected":
|
||||||
|
return "bg-red-500";
|
||||||
|
default:
|
||||||
|
return "bg-gray-500";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUnreadMessages(
|
||||||
|
id: string,
|
||||||
|
conversationLineCount: number,
|
||||||
|
activeConversationId: string | null,
|
||||||
|
lastSeenMessageCount: Map<string, number>
|
||||||
|
): boolean {
|
||||||
|
if (id === activeConversationId) return false;
|
||||||
|
const lastSeen = lastSeenMessageCount.get(id) ?? 0;
|
||||||
|
return conversationLineCount > lastSeen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
describe("getConnectionStatusColor", () => {
|
||||||
|
it("returns green for connected status", () => {
|
||||||
|
expect(getConnectionStatusColor("connected")).toBe("bg-green-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns yellow for connecting status", () => {
|
||||||
|
expect(getConnectionStatusColor("connecting")).toBe("bg-yellow-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns red for disconnected status", () => {
|
||||||
|
expect(getConnectionStatusColor("disconnected")).toBe("bg-red-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns grey for unknown status (fallback)", () => {
|
||||||
|
expect(getConnectionStatusColor("error")).toBe("bg-gray-500");
|
||||||
|
expect(getConnectionStatusColor("")).toBe("bg-gray-500");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasUnreadMessages", () => {
|
||||||
|
it("returns false for the active conversation regardless of message count", () => {
|
||||||
|
const lastSeen = new Map([["tab-1", 0]]);
|
||||||
|
expect(hasUnreadMessages("tab-1", 10, "tab-1", lastSeen)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when an inactive tab has more messages than last seen", () => {
|
||||||
|
const lastSeen = new Map([["tab-1", 5]]);
|
||||||
|
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when an inactive tab has no new messages", () => {
|
||||||
|
const lastSeen = new Map([["tab-1", 10]]);
|
||||||
|
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when an inactive tab has fewer messages than last seen", () => {
|
||||||
|
const lastSeen = new Map([["tab-1", 15]]);
|
||||||
|
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a tab with no last-seen record as having 0 messages seen", () => {
|
||||||
|
const lastSeen = new Map<string, number>();
|
||||||
|
// Tab has 1 message but no entry in lastSeen → treated as 0 seen → unread
|
||||||
|
expect(hasUnreadMessages("tab-1", 1, "tab-2", lastSeen)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for an untracked tab with 0 messages", () => {
|
||||||
|
const lastSeen = new Map<string, number>();
|
||||||
|
expect(hasUnreadMessages("tab-1", 0, "tab-2", lastSeen)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles null activeConversationId (no active tab)", () => {
|
||||||
|
const lastSeen = new Map([["tab-1", 3]]);
|
||||||
|
expect(hasUnreadMessages("tab-1", 10, null, lastSeen)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
costTrackingStore,
|
||||||
|
formattedCosts,
|
||||||
|
formatCost,
|
||||||
|
type CostSummary,
|
||||||
|
type CostAlertThresholds,
|
||||||
|
} from "$lib/stores/costTracking";
|
||||||
|
|
||||||
|
let selectedPeriod = $state<7 | 30 | 90>(7);
|
||||||
|
let summary = $state<CostSummary | null>(null);
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let showThresholdSettings = $state(false);
|
||||||
|
let thresholds = $state<CostAlertThresholds>({
|
||||||
|
daily: null,
|
||||||
|
weekly: null,
|
||||||
|
monthly: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const costs = $derived($formattedCosts);
|
||||||
|
|
||||||
|
async function loadSummary() {
|
||||||
|
isLoading = true;
|
||||||
|
summary = await costTrackingStore.getSummary(selectedPeriod);
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
const csv = await costTrackingStore.exportCsv(selectedPeriod);
|
||||||
|
if (csv) {
|
||||||
|
const blob = new Blob([csv], { type: "text/csv" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `hikari-costs-${selectedPeriod}days.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveThresholds() {
|
||||||
|
await costTrackingStore.setAlertThresholds(thresholds);
|
||||||
|
showThresholdSettings = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadSummary();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="cost-summary">
|
||||||
|
<h3 class="summary-title">Cost Summary</h3>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="quick-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Today</span>
|
||||||
|
<span class="stat-value">{costs.today}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">This Week</span>
|
||||||
|
<span class="stat-value">{costs.week}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">This Month</span>
|
||||||
|
<span class="stat-value">{costs.month}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Period Selector -->
|
||||||
|
<div class="period-selector">
|
||||||
|
<button
|
||||||
|
class="period-btn"
|
||||||
|
class:active={selectedPeriod === 7}
|
||||||
|
onclick={() => (selectedPeriod = 7)}
|
||||||
|
>
|
||||||
|
7 Days
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="period-btn"
|
||||||
|
class:active={selectedPeriod === 30}
|
||||||
|
onclick={() => (selectedPeriod = 30)}
|
||||||
|
>
|
||||||
|
30 Days
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="period-btn"
|
||||||
|
class:active={selectedPeriod === 90}
|
||||||
|
onclick={() => (selectedPeriod = 90)}
|
||||||
|
>
|
||||||
|
90 Days
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Details -->
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading">Loading...</div>
|
||||||
|
{:else if summary}
|
||||||
|
<div class="summary-details">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Total Cost</span>
|
||||||
|
<span class="detail-value highlight">{formatCost(summary.total_cost)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Average Daily</span>
|
||||||
|
<span class="detail-value">{formatCost(summary.average_daily_cost)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Messages</span>
|
||||||
|
<span class="detail-value">{summary.total_messages.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Sessions</span>
|
||||||
|
<span class="detail-value">{summary.total_sessions.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Input Tokens</span>
|
||||||
|
<span class="detail-value">{summary.total_input_tokens.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Output Tokens</span>
|
||||||
|
<span class="detail-value">{summary.total_output_tokens.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Daily Breakdown (mini chart) -->
|
||||||
|
{#if summary.daily_breakdown.length > 0}
|
||||||
|
<div class="chart-section">
|
||||||
|
<h4 class="chart-title">Daily Spending</h4>
|
||||||
|
<div class="mini-chart">
|
||||||
|
{#each summary.daily_breakdown.slice(-14) as day (day.date)}
|
||||||
|
{@const maxCost = Math.max(...summary.daily_breakdown.map((d) => d.cost_usd), 0.01)}
|
||||||
|
{@const height = (day.cost_usd / maxCost) * 100}
|
||||||
|
<div class="chart-bar-container" title="{day.date}: {formatCost(day.cost_usd)}">
|
||||||
|
<div class="chart-bar" style="height: {height}%"></div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="actions">
|
||||||
|
<button class="action-btn" onclick={handleExport}> Export CSV </button>
|
||||||
|
<button class="action-btn" onclick={() => (showThresholdSettings = !showThresholdSettings)}>
|
||||||
|
Set Alerts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Threshold Settings -->
|
||||||
|
{#if showThresholdSettings}
|
||||||
|
<div class="threshold-settings">
|
||||||
|
<h4>Cost Alert Thresholds</h4>
|
||||||
|
<div class="threshold-row">
|
||||||
|
<label for="daily-threshold">Daily</label>
|
||||||
|
<input
|
||||||
|
id="daily-threshold"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="e.g., 1.00"
|
||||||
|
bind:value={thresholds.daily}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="threshold-row">
|
||||||
|
<label for="weekly-threshold">Weekly</label>
|
||||||
|
<input
|
||||||
|
id="weekly-threshold"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="e.g., 5.00"
|
||||||
|
bind:value={thresholds.weekly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="threshold-row">
|
||||||
|
<label for="monthly-threshold">Monthly</label>
|
||||||
|
<input
|
||||||
|
id="monthly-threshold"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="e.g., 20.00"
|
||||||
|
bind:value={thresholds.monthly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button class="save-btn" onclick={handleSaveThresholds}>Save Thresholds</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cost-summary {
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-title {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-btn:hover {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-btn.active {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-details {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value.highlight {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-chart {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
height: 60px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-bar-container {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-bar {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--accent-primary);
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
min-height: 2px;
|
||||||
|
transition: height 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-settings {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-settings h4 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-row label {
|
||||||
|
width: 60px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-row input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.4rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||