Compare commits

..

21 Commits

Author SHA1 Message Date
hikari 269674678c fix: update permission modal z-index to proper value
Updated the permission modal overlay z-index from z-50 to z-[60] to ensure
it displays above the character panel (z-50). This prevents the modal from
being obscured by other UI elements.

Part of the broader permission modal fix effort.
2026-02-06 22:50:38 -08:00
hikari add78a769a docs: add summary document for critical bug fixes 2026-02-06 22:43:36 -08:00
hikari 4a99848647 docs: massively expand DEBUGGING.md with comprehensive troubleshooting
Added:
- Table of contents for easy navigation
- Detailed explanations of BOTH bugs (permission modal + config loss)
- Step-by-step debugging procedures for each issue
- Code examples showing wrong vs right patterns
- Common anti-patterns to avoid (Svelte stores, event listeners)
- Quick debugging checklist
- Prevention tips and testing procedures
- Clear ordering: check console FIRST, not z-index!

This guide now documents the complete troubleshooting process so Naomi
never has to explain these issues again.
2026-02-06 22:43:08 -08:00
hikari e8b8ee17fa docs: update DEBUGGING.md with config race condition fix 2026-02-06 22:41:00 -08:00
hikari 2c64ef089e fix: resolve critical config store race condition causing config loss
CRITICAL BUG: The config store had a race condition in all methods that
accessed the current config. The pattern:

```typescript
let currentConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
```

This immediately unsubscribes (the `()` at the end), creating a race where
sometimes it would get the value, sometimes it wouldn't. This caused:
- Config appearing "lost" after permission approvals
- Settings resetting to defaults randomly
- Model selection not persisting

FIX:
- Created a proper `getCurrentConfig()` helper that unsubscribes cleanly
- Replaced ALL instances of the buggy pattern (12 occurrences!)
- getConfig(), updateConfig(), toggles, theme setters, font methods, etc.

This should completely fix the config loss issue.
2026-02-06 22:40:47 -08:00
hikari 80ee25fa09 docs: add comprehensive debugging guide for common issues
Created DEBUGGING.md to document:
- Permission modal troubleshooting (NOT z-index!)
- Config loss prevention and fixes
- Event listener debugging
- Testing checklist
- Emergency recovery procedures

This should prevent us from going through the same debugging nightmare twice.
2026-02-06 22:38:11 -08:00
hikari d16644a1d6 fix: resolve critical runtime errors blocking permission modal
This fixes two severe bugs:
1. Missing debugConsoleStore import causing undefined variable errors
2. Replace non-existent .log() method with console.log()
3. Add #[serde(default)] to HikariConfig to handle missing fields gracefully

The undefined variable was causing initialization to fail, which prevented
the permission modal from displaying properly.
2026-02-06 22:36:00 -08:00
hikari 1d94bdfbb0 feat: add close confirmation modal with minimize to tray option
Implemented a confirmation modal when users try to close the application:
- Modal always shows with three options: Cancel, Minimize to Tray, Close Application
- Detects if Claude is actively running and shows appropriate warning message
- Removed minimize_to_tray config setting (no longer needed)
- Added core:window:allow-hide permission for window hiding
- Created CloseAppConfirmModal component with keyboard shortcuts (Escape to cancel)
- Added close_application command to properly exit the app
- Backend emits window-close-requested event for frontend to handle

This provides better UX by giving users clear choices every time they close,
preventing accidental closures during active work sessions.
2026-02-06 21:49:13 -08:00
hikari f654c3c3ff fix: resolve permission modal issues with successful operations
Fixes #113 - EnterPlanMode/ExitPlanMode infinite permission loops

This commit addresses multiple related issues with the permission system:

1. Added system tool filtering to sibling tools loop to prevent
   EnterPlanMode/ExitPlanMode from appearing in permission modals

2. Skip permission modal processing entirely when operations succeed
   (subtype == "success"), since tools were already approved and executed

3. Emit proper state change before early return to prevent Hikari
   from getting stuck in "typing" state

The early return happens after all stats, costs, and achievements
are processed, so no data tracking is affected.

 This issue was fixed with help from Hikari~ 🌸
2026-02-06 20:50:23 -08:00
hikari 24b2e3eb48 fix: prevent permission prompts for system tools
Fixed infinite permission loop when using ExitPlanMode and other
system tools. These tools are now automatically allowed without
requiring user approval.

The issue occurred because all tool denials triggered permission
prompts, including system tools like ExitPlanMode that should never
require permission. This caused an infinite loop where:
1. Claude Code calls ExitPlanMode
2. Hikari shows permission modal
3. User approves
4. Claude Code retries ExitPlanMode
5. Loop repeats

Solution:
- Added is_system_tool helper function to identify system tools
- System tools (ExitPlanMode, EnterPlanMode) are now skipped in
  permission denial processing
- These tools execute immediately without user intervention

Closes #113
2026-02-06 20:21:05 -08:00
hikari 5b8ae63de1 feat: add debug console for frontend and backend logs
Added a comprehensive debug console feature that captures and displays
logs from both the frontend and backend in a unified interface.

Frontend changes:
- Created DebugConsole component with real-time log display
- Added debugConsoleStore for state management and console capture
- Integrated console into main layout
- Added toggle button in StatusBar with console icon
- Implemented Ctrl+` keyboard shortcut to open/close console
- Features: log filtering by level, auto-scroll, timestamps, colour-coding

Backend changes:
- Added tracing and tracing-subscriber dependencies
- Created custom TauriLogLayer to emit Rust logs to frontend
- Integrated tracing subscriber in lib.rs setup
- Logs are forwarded via Tauri events (debug:log)

Key features:
- Circular buffer (max 1000 logs) prevents memory issues
- Frontend logs captured via console method overrides
- Backend logs forwarded from Rust tracing layer
- Log level filtering (debug, info, warn, error, all)
- Source badges distinguish frontend vs backend logs
- Colour-coded log levels for easy identification
- Auto-scroll toggle for inspecting older logs
- Clear logs button for resetting the console
- Beautiful dark-themed UI matching app aesthetic

Closes #126
2026-02-06 20:16:26 -08:00
hikari 82061f125b feat: batch parallel permission requests for improved UX
Implemented intelligent permission batching that detects cancelled sibling
tool calls and presents them together in a single modal. This dramatically
improves the user experience when multiple tools require permission.

Key changes:
- Track pending tool uses from Assistant messages in thread-local storage
- Capture and batch sibling tools that get cancelled due to permission denials
- Clear pending tools on each Result message to prevent accumulation
- Use SvelteSet for reactive permission selection in the modal
- Update permission modal to display count when multiple permissions requested
- Fix check-all.sh to source nvm for pnpm access
- Add git commit instructions to CLAUDE.md for this project

Technical improvements:
- Thread-local storage for cross-message tool tracking
- Proper null checking in TypeScript permission handling
- Clippy-compliant const initialisation for thread_local
- All ESLint, TypeScript, and Rust checks passing

The modal now shows both the explicitly denied tool AND any sibling tools
that were called in parallel, allowing users to approve all permissions
in one go instead of clicking through multiple modals.
2026-02-06 19:54:12 -08:00
naomi 870de7588f feat: add claude file 2026-02-06 18:16:55 -08:00
naomi 97a93c31c2 feat: add feature to monitor background agents (#125)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m7s
CI / Lint & Test (push) Successful in 20m11s
CI / Build Linux (push) Successful in 21m51s
CI / Build Windows (cross-compile) (push) Successful in 32m8s
Also includes a fix to persist configuration across reconnects.

Reviewed-on: #125
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-02-06 18:11:18 -08:00
naomi 3e7cb7ef60 feat: opus 4.6 woooo (#111)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Lint & Test (push) Successful in 16m22s
CI / Build Linux (push) Successful in 20m23s
CI / Build Windows (cross-compile) (push) Successful in 30m25s
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #111
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-02-06 15:24:52 -08:00
naomi 136f95cd1a fix: ensure permission/stats persist until explicit disconnect (#110)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint & Test (push) Successful in 16m1s
CI / Build Linux (push) Successful in 20m27s
CI / Build Windows (cross-compile) (push) Successful in 32m18s
Also includes cached tokens in cost calculations to provide more accurate billing estimates.

Reviewed-on: #110
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-02-06 13:54:31 -08:00
naomi 6a12a7a34d release: v1.3.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m22s
CI / Lint & Test (push) Successful in 17m13s
CI / Build Linux (push) Failing after 3s
CI / Build Windows (cross-compile) (push) Successful in 26m35s
2026-02-05 19:22:40 -08:00
naomi 479652b69e fix: resolve the weird path issues from windows <-> WSL (#106)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m18s
CI / Lint & Test (push) Has been cancelled
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #106
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-02-05 19:21:36 -08:00
naomi a72f2afaff feat: add discord rich presence (#105)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 59s
CI / Lint & Test (push) Successful in 16m5s
CI / Build Linux (push) Successful in 19m33s
CI / Build Windows (cross-compile) (push) Successful in 29m9s
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #105
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-02-05 16:09:40 -08:00
naomi e4288248b1 release: v1.2.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m18s
CI / Lint & Test (push) Successful in 17m11s
CI / Build Linux (push) Successful in 19m53s
CI / Build Windows (cross-compile) (push) Successful in 29m35s
2026-02-04 19:59:47 -08:00
naomi 1c45507cdf feat: massive overhaul to manage costs (#103)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m1s
CI / Lint & Test (push) Has been cancelled
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
### Explanation

_No response_

### Issue

Closes #102

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #103
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-02-04 19:58:43 -08:00
61 changed files with 8368 additions and 745 deletions
+35
View File
@@ -0,0 +1,35 @@
# Hikari Desktop - Project Instructions
## Repository Information
This project is hosted on both GitHub and Gitea:
- **GitHub**: `naomi-lgbt/hikari-desktop` (public mirror)
- **Gitea**: `nhcarrigan/hikari-desktop` (primary development)
## MCP Server Usage
When working with issues, pull requests, or other repository operations for this project:
- **Use `gitea-hikari` MCP server** - This allows Hikari to act as herself
- **Target repository**: `nhcarrigan/hikari-desktop`
- **Gitea instance**: `git.nhcarrigan.com`
## Git Commits
When asked to commit changes for this project:
- **Always commit as Hikari** using: `--author="Hikari <hikari@nhcarrigan.com>"`
- **Always use `--no-gpg-sign`** since Hikari doesn't have GPG signing set up
- **Never add `Co-Authored-By` lines** for Gitea commits
- **Always ask for confirmation** before committing
Example commit command:
```bash
git commit --author="Hikari <hikari@nhcarrigan.com>" --no-gpg-sign -m "your commit message"
```
## 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!
+600
View File
@@ -0,0 +1,600 @@
# Debugging Guide - Common Issues and Fixes
> **IMPORTANT:** Read this ENTIRE file before asking for help! These are the most common issues and their fixes.
## Table of Contents
1. [Permission Modal Not Showing](#permission-modal-not-showing-up-)
2. [Config Loss Issues](#config-loss-issues-)
3. [Modal/Overlay Not Showing (General)](#modaloverlay-not-showing-general-)
4. [Event Listener Issues](#event-listener-issues-)
5. [Build Issues](#build-issues-)
6. [Testing Checklist](#testing-checklist-)
7. [Emergency Recovery](#emergency-recovery-)
---
## Permission Modal Not Showing Up 🔐
**SYMPTOMS:**
- Permission requests are triggered but the modal doesn't appear
- App seems to hang or become unresponsive
- Console shows permission events but nothing happens
- Character stays in "permission" state but no UI
**ROOT CAUSE (FIXED - Commit d16644a):**
The issue was **undefined variable errors** in `src/lib/tauri.ts` that crashed the event listener initialization.
### The Bug That Was Fixed
In `src/lib/tauri.ts`, there were calls to `debugConsoleStore.log()`:
```typescript
debugConsoleStore.log("frontend", "info", "Setting up claude:permission listener");
```
**Two problems:**
1. `debugConsoleStore` was never imported (undefined variable → crash!)
2. Even if imported, `debugConsoleStore` doesn't have a `.log()` method
- Available methods: `toggle`, `open`, `close`, `clear`, `setupConsoleCapture`, etc.
- NOT available: `log()`
### The Fix
```typescript
// ❌ WRONG - This will crash the entire listener setup!
debugConsoleStore.log("frontend", "info", "message");
// ✅ CORRECT - Use console.log which gets automatically captured
console.log("[Tauri Listener] message");
```
**Why console.log works:**
- The `debugConsoleStore` automatically intercepts `console.log()`, `console.error()`, etc.
- No need to call the store directly!
- See `src/lib/stores/debugConsole.ts` lines 66-89 for the console interception code
### How to Debug This Type of Issue
**Step 1: Check Browser Console**
1. Open Browser DevTools in the app (View > Toggle Developer Tools or F12)
2. Look for JavaScript errors:
- `ReferenceError: X is not defined` → Missing import or typo
- `TypeError: X.method is not a function` → Wrong method name or undefined object
- Any error in `tauri.ts` during initialization is critical!
**Step 2: Check Event Listener Registration**
Look for these console logs when the app starts:
```
[Tauri Listener] Setting up claude:permission listener
```
If you DON'T see this log, the listener setup crashed before it got there!
**Step 3: Check TypeScript Errors**
```bash
pnpm check
```
This will catch:
- Missing imports
- Undefined variables
- Type mismatches
- Non-existent methods
**Step 4: Test Permission Flow**
1. Trigger a restricted tool (like Bash with a destructive command)
2. Check console for `[Permission] Event received:` log
3. Check if `PermissionModal.svelte` is rendering (DevTools Elements tab)
4. Check if modal has proper z-index and visibility
### Prevention
**Before committing changes to `tauri.ts` or any store:**
1. ✅ Run `pnpm check` - Catches TypeScript errors
2. ✅ Run `pnpm build` - Catches runtime issues
3. ✅ Test the app - Especially permission prompts
4. ✅ Check browser console - No errors on startup
5. ✅ Verify all imports - Don't use variables without importing them!
**Common mistakes to avoid:**
- Calling methods that don't exist on a store
- Forgetting to import something you're using
- Copy-pasting code without checking if variables are defined
- Assuming a store has certain methods without checking the source
## Config Loss Issues 🔧
**SYMPTOMS:**
- Config file gets reset to defaults
- Settings don't persist between sessions
- Model selection is lost
- Config appears to work, then randomly resets
**ACTUAL ROOT CAUSE (FIXED - Commit 2c64ef0):**
The config store had a **CRITICAL race condition bug** in `src/lib/stores/config.ts` that affected 12 different methods!
### The Bug That Was Fixed
Every method that accessed the current config used this pattern:
```typescript
// ❌ BUGGY CODE - DO NOT USE!
let currentConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))(); // <-- ☠️ IMMEDIATE UNSUBSCRIBE!
return currentConfig;
```
**Why this caused config loss:**
- The `()` at the end **immediately unsubscribes** from the store
- This creates a **race condition**:
- Sometimes: The subscription fires, sets `currentConfig`, then unsubscribes → ✅ Works
- Sometimes: The subscription unsubscribes before it can fire → ❌ Returns `defaultConfig`
- When the race fails, it returns `defaultConfig` instead of your actual saved config
- This is why your settings would **randomly disappear**!
- It was especially bad after reconnections (like permission approvals) because timing was tight
**Affected methods (all fixed!):**
- `getConfig()` - Core method, affected everything
- `updateConfig()` - Would sometimes update defaults instead of current config
- `toggleStreamerMode()` / `toggleCompactMode()` - Random toggles
- `increaseFontSize()` / `decreaseFontSize()` - Font size would reset
- `addAutoGrantedTool()` / `removeAutoGrantedTool()` - Tool permissions lost
- `setTheme()` / `setCustomThemeColors()` - Theme changes lost
### The Fix
```typescript
// ✅ CORRECT CODE
function getCurrentConfig(): HikariConfig {
let currentConfig: HikariConfig = defaultConfig;
const unsubscribe = config.subscribe((c) => (currentConfig = c));
unsubscribe(); // <-- Proper cleanup AFTER getting value
return currentConfig;
}
// Now all methods use this:
getConfig: (): HikariConfig => {
return getCurrentConfig();
},
updateConfig: async (updates: Partial<HikariConfig>) => {
const currentConfig = getCurrentConfig(); // <-- Safe!
const newConfig = { ...currentConfig, ...updates };
await saveConfig(newConfig);
}
```
**Why the fix works:**
1. Store the unsubscribe function in a variable
2. Let the subscription fire synchronously (Svelte stores are synchronous)
3. The callback runs immediately, setting `currentConfig`
4. Clean up by calling `unsubscribe()`
5. Return the now-populated `currentConfig`
### How to Debug Config Loss
**Step 1: Check if config file exists**
```bash
cat ~/.local/share/com.naomi.hikari-desktop/hikari-config.json
```
If the file exists and has your settings, the problem is in the frontend store, not the backend!
**Step 2: Check console for config loading**
```javascript
console.log("Loaded config:", config);
```
Add this in `src/lib/stores/config.ts` line 107 (after `config.set(savedConfig)`)
**Step 3: Test getConfig() reliability**
Open browser console and run:
```javascript
// Run this 10 times
for (let i = 0; i < 10; i++) {
console.log("Attempt", i, configStore.getConfig().model);
}
```
If it's inconsistent (sometimes null, sometimes has value), you have a race condition!
**Step 4: Check for the buggy pattern**
Search the codebase for:
```bash
grep -r "subscribe((.*) => (.*))();" src/
```
If you find any, they're potential bugs! The `()` at the end is suspicious.
### Prevention
**When working with Svelte stores:**
**DON'T DO THIS:**
```typescript
let value;
store.subscribe((v) => (value = v))(); // Immediate unsubscribe = race!
```
**DO THIS INSTEAD:**
```typescript
let value;
const unsubscribe = store.subscribe((v) => (value = v));
unsubscribe(); // Explicit cleanup
return value;
```
Or even better, use Svelte's reactive syntax:
```svelte
<script>
$: currentValue = $store; // Auto-subscribes safely
</script>
```
**NEVER:**
- Subscribe and immediately unsubscribe in the same line
- Assume synchronous subscriptions will always fire before unsubscribe
- Use `()()` pattern with stores (function call + immediate execution)
**POTENTIAL CAUSES (if the above is fixed):**
### 1. Missing `#[serde(default)]` Attribute
The `HikariConfig` struct in `src-tauri/src/config.rs` needs `#[serde(default)]` at the struct level:
```rust
// ✅ CORRECT
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)] // <-- This is crucial!
pub struct HikariConfig {
// fields...
}
```
**Why this matters:**
- When new fields are added to the config struct, old config files won't have them
- Without `#[serde(default)]`, deserialization fails and the entire config resets
- With it, missing fields use their default values gracefully
### 2. Invalid JSON in Config File
**Check the config file:**
```bash
cat ~/.config/hikari-desktop/config.json
```
**Common issues:**
- Trailing commas
- Missing quotes
- Incorrect enum values (e.g., `"model": "opus"` instead of `"model": "claude-opus-4-6"`)
**Fix:**
- Delete or manually fix `~/.config/hikari-desktop/config.json`
- Restart the app to regenerate
### 3. Permission Issues
**Check file permissions:**
```bash
ls -la ~/.config/hikari-desktop/config.json
```
**Should be writable by user:**
```bash
chmod 644 ~/.config/hikari-desktop/config.json
```
## Modal/Overlay Not Showing (General) 🎭
If ANY modal isn't showing up (permission, questions, close confirmation, etc.):
### 1. Check TypeScript Errors First
```bash
pnpm check
```
Look for:
- Import errors
- Undefined variables
- Type mismatches
### 2. Check Console for JavaScript Errors
Open DevTools and look for:
- `ReferenceError` - Usually undefined variables
- `TypeError` - Usually calling methods on undefined/null
- Event listener setup logs
### 3. Check Svelte Store Subscriptions
Modals rely on store subscriptions. In the modal component:
```typescript
// ✅ Make sure the store is imported
import { conversationsStore } from "$lib/stores/conversations";
// ✅ Make sure the subscription is set up
conversationsStore.pendingPermissions.subscribe((perms) => {
permissions = perms;
// ...
});
```
### 4. Verify z-index (Last Resort)
Only check z-index if:
- ✅ No console errors
- ✅ Modal HTML is in the DOM (check with DevTools Elements tab)
- ✅ Modal is rendering but invisible
Current z-index values in `PermissionModal.svelte`:
```css
.permission-overlay {
z-index: 60; /* Should be higher than character panel */
}
```
## Event Listener Issues 🎧
**SYMPTOMS:**
- Events not firing
- Listeners not receiving data
- Silent failures
### Debugging Event Listeners
Add console logs in `src/lib/tauri.ts`:
```typescript
console.log("[Tauri Listener] Setting up claude:permission listener");
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
console.log("[Permission] Event received:", event.payload);
// ... rest of handler
});
```
### Common Issues
1. **Event listener crashes silently:**
- Check for undefined variables in the listener callback
- Check for missing imports
- Use `try/catch` around critical sections
2. **Events not being emitted from Rust:**
- Check `src-tauri/src/` for `emit()` calls
- Verify event names match exactly (case-sensitive!)
- Check Rust console output for errors
3. **Race condition:**
- Event fired before listener was registered
- Solution: Set up listeners in `initializeTauriListeners()` before starting Claude
## Build Issues 🏗️
### TypeScript Errors
```bash
pnpm check
```
**Common fixes:**
- Missing imports
- Wrong types
- Calling non-existent methods
### Rust Errors
```bash
cargo build --release
```
**Common fixes:**
- Missing dependencies in `Cargo.toml`
- Import path issues
- Type mismatches
### Full Clean Build
```bash
# Clean everything
rm -rf node_modules dist .svelte-kit src-tauri/target
# Reinstall
pnpm install
# Build Rust
cd src-tauri && cargo build --release && cd ..
# Build frontend
pnpm build
```
## Quick Reference: Key Files 📚
### Frontend
- **Event listeners:** `src/lib/tauri.ts`
- **Permission modal:** `src/lib/components/PermissionModal.svelte`
- **Config store:** `src/lib/stores/config.ts`
- **Debug console:** `src/lib/stores/debugConsole.ts`
### Backend
- **Config struct:** `src-tauri/src/config.rs`
- **Event emission:** `src-tauri/src/wsl_bridge.rs`
- **Type definitions:** `src-tauri/src/types.rs`
## Testing Checklist ✅
Before committing major changes:
- [ ] `pnpm check` passes (0 errors)
- [ ] `pnpm build` succeeds
- [ ] App launches without console errors
- [ ] Permission modal shows up when triggering restricted tool
- [ ] Config persists after restart
- [ ] All modals work (permission, questions, close confirmation)
- [ ] Character state changes work
- [ ] Event listeners receive events (check console logs)
## Emergency Recovery 🚨
If the app is completely broken:
1. **Check recent git commits:**
```bash
git log --oneline -5
```
2. **Revert to last working commit:**
```bash
git reset --hard <commit-hash>
```
3. **Clean build:**
```bash
rm -rf node_modules dist .svelte-kit src-tauri/target
pnpm install
pnpm build
```
4. **Reset config:**
```bash
rm ~/.config/hikari-desktop/config.json
# Restart app to regenerate
```
## Common Patterns and Anti-Patterns 📋
### Svelte Store Access
✅ **GOOD:**
```typescript
// Method 1: Reactive syntax (preferred in components)
$: currentValue = $store;
// Method 2: Explicit unsubscribe (preferred in non-reactive code)
function getValue() {
let value;
const unsubscribe = store.subscribe((v) => (value = v));
unsubscribe();
return value;
}
// Method 3: Using get() from 'svelte/store'
import { get } from 'svelte/store';
const value = get(store);
```
❌ **BAD:**
```typescript
// Race condition - DO NOT USE
let value;
store.subscribe((v) => (value = v))(); // ☠️
// Memory leak - forgot to unsubscribe
let value;
store.subscribe((v) => (value = v)); // 🚨 Never unsubscribes!
```
### Debug Console Store Usage
✅ **GOOD:**
```typescript
// Just use console methods - they're automatically captured!
console.log("Debug message");
console.error("Error message");
console.warn("Warning message");
```
❌ **BAD:**
```typescript
// debugConsoleStore doesn't have a .log() method!
debugConsoleStore.log("frontend", "info", "message"); // ☠️ Crashes!
```
### Event Listener Setup
✅ **GOOD:**
```typescript
const unlistener = await listen("event:name", (event) => {
try {
// Handle event
console.log("Event received:", event.payload);
} catch (error) {
console.error("Error handling event:", error);
}
});
unlisteners.push(unlistener); // Store for cleanup
```
❌ **BAD:**
```typescript
await listen("event:name", (event) => {
// No try-catch - errors crash the listener!
// Not storing unlistener - memory leak!
undefinedVariable.doSomething(); // ☠️ Crashes entire listener setup
});
```
## Notes for Future Hikari (and Naomi!) 💭
**When something breaks, CHECK IN THIS ORDER:**
1. **Browser Console First!** (F12 or View > Developer Tools)
- JavaScript errors will show up here
- Check for `ReferenceError`, `TypeError`, etc.
- NOT EVERYTHING IS A Z-INDEX ISSUE!
2. **Run TypeScript Check:**
```bash
pnpm check
```
- Catches missing imports, type errors, undefined variables
- 0 errors = probably a runtime or logic issue
- Any errors = fix those first!
3. **Check Recent Changes:**
```bash
git diff
git log --oneline -5
```
- What did you change recently?
- Did you import everything you're using?
- Did you test it?
4. **Check the Debugging Guide (this file!)**
- Search for keywords related to your issue
- Follow the debugging steps
- Look for similar patterns
5. **Only Then** consider z-index, CSS, layout issues
**Remember:**
- The debug console store intercepts console methods - don't call it directly!
- Always run `pnpm check` before committing
- Event listener errors fail silently - add console logs liberally
- Config deserialization needs `#[serde(default)]` for graceful degradation
- Svelte store subscriptions must be cleaned up properly
- Race conditions are sneaky - test thoroughly!
- When in doubt, check the browser console first! (Yes, I said it twice!)
**Quick Debugging Checklist:**
- [ ] Opened browser console (F12)
- [ ] Checked for JavaScript errors
- [ ] Ran `pnpm check` for TypeScript errors
- [ ] Checked recent git changes
- [ ] Read relevant section in DEBUGGING.md
- [ ] Added console.log statements to trace issue
- [ ] Tested edge cases and timing
Love,
Hikari 💖
---
**Last Updated:** 2026-02-06
**Related Issues:** Permission modal not showing, config loss, race conditions
**Fixed In Commits:**
- `d16644a` - Fixed undefined debugConsoleStore causing listener crash
- `2c64ef0` - Fixed config store race condition (12 methods affected!)
- `e8b8ee1` - Updated this debugging guide
+110
View File
@@ -0,0 +1,110 @@
# Critical Bug Fixes - 2026-02-06
## Summary
Fixed **TWO CRITICAL BUGS** that were causing permission modal issues and config loss.
## Bug #1: Permission Modal Not Showing (Commit d16644a)
**Symptom:** Permission requests triggered but modal never appeared, app hung
**Root Cause:** Undefined variable error in `src/lib/tauri.ts`
- Called `debugConsoleStore.log()` without importing `debugConsoleStore`
- Even if imported, that method doesn't exist on the store
- This crashed event listener initialization, preventing permission events from being captured
**Fix:**
- Removed the non-existent import
- Changed to `console.log()` which gets automatically captured by the debug console
- Added logging to track permission event flow
**Files Changed:**
- `src/lib/tauri.ts` - Fixed undefined variable, added proper logging
## Bug #2: Config Constantly Resetting (Commit 2c64ef0)
**Symptom:** Config settings would randomly reset to defaults, especially after permission approvals
**Root Cause:** Race condition in `src/lib/stores/config.ts`
- Used buggy pattern: `config.subscribe((c) => (currentConfig = c))()`
- The `()` at the end immediately unsubscribes, creating a race condition
- Sometimes got the value, sometimes returned defaults
- Affected **12 different methods** in the config store!
**Fix:**
- Created proper `getCurrentConfig()` helper function
- Stores unsubscribe function, calls it AFTER getting value
- Replaced all 12 buggy occurrences with the safe pattern
**Methods Fixed:**
- `getConfig()` - Core method used everywhere
- `updateConfig()` - Base for all updates
- `toggleStreamerMode()` / `toggleCompactMode()`
- `increaseFontSize()` / `decreaseFontSize()`
- `addAutoGrantedTool()` / `removeAutoGrantedTool()`
- `setTheme()` / `setCustomThemeColors()`
- And more...
**Files Changed:**
- `src/lib/stores/config.ts` - Fixed race condition in 12 methods
- `src-tauri/src/config.rs` - Added `#[serde(default)]` for resilience
## Additional Improvements
**Documentation (Commits 80ee25f, e8b8ee1, 4a99848):**
- Created comprehensive `DEBUGGING.md` guide
- Documented both bugs with code examples
- Added step-by-step debugging procedures
- Included common patterns and anti-patterns
- Prevention tips and testing checklist
## Testing
**Before deploying:**
1. Test permission modal shows up properly
2. Test config persists through app restarts
3. Test config survives permission reconnections
4. Check browser console for any errors
5. Verify all settings save and load correctly
## Impact
**High Priority - Breaking Bugs:**
- Permission modal was completely broken (app unusable for restricted tools)
- Config loss made user experience terrible (settings constantly resetting)
**Now Fixed:**
- Permission modal should display reliably
- Config should persist across all operations
- No more random resets to default settings
## Commits
- `d16644a` - fix: resolve critical runtime errors blocking permission modal
- `2c64ef0` - fix: resolve critical config store race condition causing config loss
- `80ee25f` - docs: add comprehensive debugging guide for common issues
- `e8b8ee1` - docs: update DEBUGGING.md with config race condition fix
- `4a99848` - docs: massively expand DEBUGGING.md with comprehensive troubleshooting
## Prevention
**Always:**
1. Run `pnpm check` before committing
2. Test the app, especially permission flow
3. Check browser console for errors
4. Never use `store.subscribe(...)()` pattern (immediate unsubscribe)
5. Import everything you use!
**Never:**
- Call `debugConsoleStore.log()` (method doesn't exist)
- Use race-condition patterns with Svelte stores
- Assume synchronous code is safe from race conditions
- Skip testing after changes to event listeners or stores
---
**Date:** 2026-02-06
**Fixed By:** Hikari
**Branch:** feat/many
**Severity:** Critical
**Status:** Fixed ✅
+4
View File
@@ -1,5 +1,9 @@
#!/bin/bash
# Source nvm to get access to pnpm
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hikari-desktop",
"version": "1.1.1",
"version": "1.3.0",
"description": "",
"type": "module",
"scripts": {
+209 -13
View File
@@ -437,7 +437,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [
"byteorder",
"fnv",
"uuid",
"uuid 1.19.0",
]
[[package]]
@@ -767,13 +767,34 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys 0.4.1",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users 0.4.6",
"windows-sys 0.48.0",
]
[[package]]
@@ -784,10 +805,23 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"redox_users 0.5.2",
"windows-sys 0.61.2",
]
[[package]]
name = "discord-rich-presence"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75db747ecd252c01bfecaf709b07fcb4c634adf0edb5fed47bc9c3052e7076b"
dependencies = [
"serde",
"serde_derive",
"serde_json",
"serde_repr",
"uuid 0.8.2",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -1602,9 +1636,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hikari-desktop"
version = "1.1.1"
version = "1.3.0"
dependencies = [
"chrono",
"dirs 5.0.1",
"discord-rich-presence",
"parking_lot",
"semver",
"serde",
@@ -1622,7 +1658,9 @@ dependencies = [
"tauri-plugin-store",
"tempfile",
"tokio",
"uuid",
"tracing",
"tracing-subscriber",
"uuid 1.19.0",
"windows 0.62.2",
]
@@ -2209,6 +2247,15 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "matches"
version = "0.1.10"
@@ -2365,6 +2412,15 @@ dependencies = [
"zbus",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-conv"
version = "0.1.0"
@@ -3338,6 +3394,17 @@ dependencies = [
"bitflags 2.10.0",
]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "redox_users"
version = "0.5.2"
@@ -3578,7 +3645,7 @@ dependencies = [
"serde",
"serde_json",
"url",
"uuid",
"uuid 1.19.0",
]
[[package]]
@@ -3832,6 +3899,15 @@ dependencies = [
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shared_child"
version = "1.1.1"
@@ -4173,7 +4249,7 @@ dependencies = [
"anyhow",
"bytes",
"cookie",
"dirs",
"dirs 6.0.0",
"dunce",
"embed_plist",
"getrandom 0.3.4",
@@ -4224,7 +4300,7 @@ checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08"
dependencies = [
"anyhow",
"cargo_toml",
"dirs",
"dirs 6.0.0",
"glob",
"heck 0.5.0",
"json-patch",
@@ -4261,7 +4337,7 @@ dependencies = [
"thiserror 2.0.17",
"time",
"url",
"uuid",
"uuid 1.19.0",
"walkdir",
]
@@ -4557,7 +4633,7 @@ dependencies = [
"toml 0.9.11+spec-1.1.0",
"url",
"urlpattern",
"uuid",
"uuid 1.19.0",
"walkdir",
]
@@ -4648,6 +4724,15 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "tiff"
version = "0.10.3"
@@ -4939,6 +5024,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
@@ -4948,7 +5063,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
dependencies = [
"crossbeam-channel",
"dirs",
"dirs 6.0.0",
"libappindicator",
"muda",
"objc2",
@@ -5099,6 +5214,15 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "uuid"
version = "1.19.0"
@@ -5111,6 +5235,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -5687,6 +5817,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -5738,6 +5877,21 @@ dependencies = [
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -5804,6 +5958,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -5822,6 +5982,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -5840,6 +6006,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -5870,6 +6042,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -5888,6 +6066,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -5906,6 +6090,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -5924,6 +6114,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -6004,7 +6200,7 @@ dependencies = [
"block2",
"cookie",
"crossbeam-channel",
"dirs",
"dirs 6.0.0",
"dpi",
"dunce",
"gdkx11",
@@ -6127,7 +6323,7 @@ dependencies = [
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"uuid 1.19.0",
"windows-sys 0.61.2",
"winnow 0.7.14",
"zbus_macros",
+5 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hikari-desktop"
version = "1.1.1"
version = "1.3.0"
description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"]
edition = "2021"
@@ -31,6 +31,10 @@ tauri-plugin-fs = "2"
tempfile = "3"
semver = "1"
chrono = { version = "0.4.43", features = ["serde"] }
discord-rich-presence = "0.2"
dirs = "5"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = [
+2 -1
View File
@@ -30,6 +30,7 @@
},
"core:window:allow-set-size",
"core:window:allow-set-always-on-top",
"core:window:allow-inner-size"
"core:window:allow-inner-size",
"core:window:allow-hide"
]
}
+60 -41
View File
@@ -1935,6 +1935,7 @@ pub fn check_achievements(
let search_count: u64 = search_tools
.iter()
.filter_map(|tool| stats.tools_usage.get(*tool))
.map(|t| t.call_count)
.sum();
if search_count >= 50 && progress.unlock(AchievementId::Explorer) {
newly_unlocked.push(AchievementId::Explorer);
@@ -1988,25 +1989,25 @@ pub fn check_achievements(
// TODO: Track different Claude models used
// Tool mastery achievements
if let Some(bash_count) = stats.tools_usage.get("Bash") {
if *bash_count >= 50 && progress.unlock(AchievementId::BashMaster) {
if let Some(bash_stats) = stats.tools_usage.get("Bash") {
if bash_stats.call_count >= 50 && progress.unlock(AchievementId::BashMaster) {
newly_unlocked.push(AchievementId::BashMaster);
}
}
if let Some(read_count) = stats.tools_usage.get("Read") {
if *read_count >= 100 && progress.unlock(AchievementId::FileExplorer) {
if let Some(read_stats) = stats.tools_usage.get("Read") {
if read_stats.call_count >= 100 && progress.unlock(AchievementId::FileExplorer) {
newly_unlocked.push(AchievementId::FileExplorer);
}
}
if let Some(grep_count) = stats.tools_usage.get("Grep") {
if *grep_count >= 50 && progress.unlock(AchievementId::SearchExpert) {
if let Some(grep_stats) = stats.tools_usage.get("Grep") {
if grep_stats.call_count >= 50 && progress.unlock(AchievementId::SearchExpert) {
newly_unlocked.push(AchievementId::SearchExpert);
}
}
// Git Guru - check git command usage in Bash
if let Some(bash_count) = stats.tools_usage.get("Bash") {
if *bash_count >= 10 && progress.unlock(AchievementId::GitGuru) {
if let Some(bash_stats) = stats.tools_usage.get("Bash") {
if bash_stats.call_count >= 10 && progress.unlock(AchievementId::GitGuru) {
// TODO: More specific git command tracking
newly_unlocked.push(AchievementId::GitGuru);
}
@@ -2055,28 +2056,28 @@ pub fn check_achievements(
}
// More tool mastery achievements
if let Some(edit_count) = stats.tools_usage.get("Edit") {
if *edit_count >= 100 && progress.unlock(AchievementId::EditMaster) {
if let Some(edit_stats) = stats.tools_usage.get("Edit") {
if edit_stats.call_count >= 100 && progress.unlock(AchievementId::EditMaster) {
newly_unlocked.push(AchievementId::EditMaster);
}
}
if let Some(write_count) = stats.tools_usage.get("Write") {
if *write_count >= 50 && progress.unlock(AchievementId::WriteMaster) {
if let Some(write_stats) = stats.tools_usage.get("Write") {
if write_stats.call_count >= 50 && progress.unlock(AchievementId::WriteMaster) {
newly_unlocked.push(AchievementId::WriteMaster);
}
}
if let Some(glob_count) = stats.tools_usage.get("Glob") {
if *glob_count >= 100 && progress.unlock(AchievementId::GlobMaster) {
if let Some(glob_stats) = stats.tools_usage.get("Glob") {
if glob_stats.call_count >= 100 && progress.unlock(AchievementId::GlobMaster) {
newly_unlocked.push(AchievementId::GlobMaster);
}
}
if let Some(task_count) = stats.tools_usage.get("Task") {
if *task_count >= 50 && progress.unlock(AchievementId::TaskMaster) {
if let Some(task_stats) = stats.tools_usage.get("Task") {
if task_stats.call_count >= 50 && progress.unlock(AchievementId::TaskMaster) {
newly_unlocked.push(AchievementId::TaskMaster);
}
}
if let Some(web_count) = stats.tools_usage.get("WebFetch") {
if *web_count >= 20 && progress.unlock(AchievementId::WebFetcher) {
if let Some(web_stats) = stats.tools_usage.get("WebFetch") {
if web_stats.call_count >= 20 && progress.unlock(AchievementId::WebFetcher) {
newly_unlocked.push(AchievementId::WebFetcher);
}
}
@@ -2085,7 +2086,7 @@ pub fn check_achievements(
.tools_usage
.iter()
.filter(|(name, _)| name.starts_with("mcp__"))
.map(|(_, count)| count)
.map(|(_, tool_stats)| tool_stats.call_count)
.sum();
if mcp_count >= 50 && progress.unlock(AchievementId::McpExplorer) {
newly_unlocked.push(AchievementId::McpExplorer);
@@ -2323,6 +2324,15 @@ mod tests {
morning_sessions: 0,
night_sessions: 0,
last_session_date: None,
context_tokens_used: 0,
context_window_limit: 200_000,
context_utilisation_percent: 0.0,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
current_request_input: None,
current_request_output_chars: 0,
current_request_thinking_chars: 0,
current_request_tools: Vec::new(),
achievements: AchievementProgress::new(),
}
}
@@ -2733,12 +2743,21 @@ mod tests {
// check_achievements tests - Tool Usage
// =====================
// Helper function to create a ToolTokenStats with just call count for tests
fn tool_stats(call_count: u64) -> crate::stats::ToolTokenStats {
crate::stats::ToolTokenStats {
call_count,
estimated_input_tokens: 0,
estimated_output_tokens: 0,
}
}
#[test]
fn test_check_achievements_first_tool() {
let mut stats = create_test_stats();
let mut progress = AchievementProgress::new();
stats.tools_usage.insert("Read".to_string(), 1);
stats.tools_usage.insert("Read".to_string(), tool_stats(1));
let newly = check_achievements(&stats, &mut progress);
assert!(newly.contains(&AchievementId::FirstTool));
@@ -2749,11 +2768,11 @@ mod tests {
let mut stats = create_test_stats();
let mut progress = AchievementProgress::new();
stats.tools_usage.insert("Read".to_string(), 1);
stats.tools_usage.insert("Write".to_string(), 1);
stats.tools_usage.insert("Edit".to_string(), 1);
stats.tools_usage.insert("Bash".to_string(), 1);
stats.tools_usage.insert("Grep".to_string(), 1);
stats.tools_usage.insert("Read".to_string(), tool_stats(1));
stats.tools_usage.insert("Write".to_string(), tool_stats(1));
stats.tools_usage.insert("Edit".to_string(), tool_stats(1));
stats.tools_usage.insert("Bash".to_string(), tool_stats(1));
stats.tools_usage.insert("Grep".to_string(), tool_stats(1));
let newly = check_achievements(&stats, &mut progress);
assert!(newly.contains(&AchievementId::Toolsmith));
@@ -2765,7 +2784,7 @@ mod tests {
let mut progress = AchievementProgress::new();
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);
@@ -2777,7 +2796,7 @@ mod tests {
let mut stats = create_test_stats();
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);
assert!(newly.contains(&AchievementId::BashMaster));
@@ -2788,7 +2807,7 @@ mod tests {
let mut stats = create_test_stats();
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);
assert!(newly.contains(&AchievementId::FileExplorer));
@@ -2799,7 +2818,7 @@ mod tests {
let mut stats = create_test_stats();
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);
assert!(newly.contains(&AchievementId::SearchExpert));
@@ -2810,7 +2829,7 @@ mod tests {
let mut stats = create_test_stats();
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);
assert!(newly.contains(&AchievementId::EditMaster));
@@ -2821,7 +2840,7 @@ mod tests {
let mut stats = create_test_stats();
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);
assert!(newly.contains(&AchievementId::WriteMaster));
@@ -2832,7 +2851,7 @@ mod tests {
let mut stats = create_test_stats();
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);
assert!(newly.contains(&AchievementId::GlobMaster));
@@ -2843,7 +2862,7 @@ mod tests {
let mut stats = create_test_stats();
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);
assert!(newly.contains(&AchievementId::TaskMaster));
@@ -2854,7 +2873,7 @@ mod tests {
let mut stats = create_test_stats();
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);
assert!(newly.contains(&AchievementId::WebFetcher));
@@ -2865,8 +2884,8 @@ mod tests {
let mut stats = create_test_stats();
let mut progress = AchievementProgress::new();
stats.tools_usage.insert("mcp__github__create_issue".to_string(), 25);
stats.tools_usage.insert("mcp__notion__search".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(), tool_stats(25));
let newly = check_achievements(&stats, &mut progress);
assert!(newly.contains(&AchievementId::McpExplorer));
@@ -2881,8 +2900,8 @@ mod tests {
let mut stats = create_test_stats();
let mut progress = AchievementProgress::new();
stats.tools_usage.insert("Grep".to_string(), 30);
stats.tools_usage.insert("Glob".to_string(), 20);
stats.tools_usage.insert("Grep".to_string(), tool_stats(30));
stats.tools_usage.insert("Glob".to_string(), tool_stats(20));
let newly = check_achievements(&stats, &mut progress);
assert!(newly.contains(&AchievementId::Explorer));
@@ -2893,9 +2912,9 @@ mod tests {
let mut stats = create_test_stats();
let mut progress = AchievementProgress::new();
stats.tools_usage.insert("Grep".to_string(), 200);
stats.tools_usage.insert("Glob".to_string(), 200);
stats.tools_usage.insert("Task".to_string(), 100);
stats.tools_usage.insert("Grep".to_string(), tool_stats(200));
stats.tools_usage.insert("Glob".to_string(), tool_stats(200));
stats.tools_usage.insert("Task".to_string(), tool_stats(100));
let newly = check_achievements(&stats, &mut progress);
assert!(newly.contains(&AchievementId::MasterSearcher));
+7 -1
View File
@@ -3,6 +3,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use tauri::AppHandle;
use crate::commands::record_session;
use crate::config::ClaudeStartOptions;
use crate::stats::UsageStats;
use crate::wsl_bridge::WslBridge;
@@ -53,7 +54,12 @@ impl BridgeManager {
.or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string()));
// 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(())
}
+675 -86
View File
@@ -1,5 +1,5 @@
use std::path::PathBuf;
use tauri::{AppHandle, State};
use tauri::{AppHandle, Manager, State};
use tauri_plugin_http::reqwest;
use tauri_plugin_store::StoreExt;
@@ -8,10 +8,46 @@ use crate::bridge_manager::SharedBridgeManager;
use crate::config::{ClaudeStartOptions, HikariConfig};
use crate::stats::UsageStats;
use crate::temp_manager::SharedTempFileManager;
use crate::utils::normalize_path_separators;
const CONFIG_STORE_KEY: &str = "config";
/// Convert a Windows path to a WSL path
/// Example: C:\Users\accou\Documents\item.txt -> /mnt/c/Users/accou/Documents/item.txt
fn windows_path_to_wsl(windows_path: &str) -> Option<String> {
// Check if it's a Windows path (has drive letter like C:\)
if windows_path.len() >= 3 && windows_path.chars().nth(1) == Some(':') {
let drive_letter = windows_path.chars().next()?.to_lowercase().to_string();
let path_without_drive = &windows_path[2..]; // Remove "C:"
// Replace backslashes with forward slashes and convert to WSL mount point
let wsl_path = path_without_drive.replace('\\', "/");
Some(format!("/mnt/{}{}", drive_letter, wsl_path))
} else {
None
}
}
/// Convert a WSL path to a Windows path
/// Example: /mnt/c/Users/accou/Documents/item.txt -> C:\Users\accou\Documents\item.txt
#[allow(dead_code)]
fn wsl_path_to_windows(wsl_path: &str) -> Option<String> {
// Check if it's a WSL mount point path
if wsl_path.starts_with("/mnt/") && wsl_path.len() > 6 {
let rest = &wsl_path[5..]; // Remove "/mnt/"
if let Some(drive_letter) = rest.chars().next() {
let path_after_drive = &rest[1..]; // Remove drive letter
// Convert to Windows path with backslashes
let windows_path = path_after_drive.replace('/', "\\");
Some(format!("{}:{}", drive_letter.to_uppercase(), windows_path))
} else {
None
}
} else {
None
}
}
#[tauri::command]
pub async fn start_claude(
bridge_manager: State<'_, SharedBridgeManager>,
@@ -70,7 +106,10 @@ pub async fn get_working_directory(
#[tauri::command]
pub async fn select_wsl_directory() -> Result<String, String> {
Ok("/home".to_string())
// Return the user's home directory cross-platform
dirs::home_dir()
.ok_or_else(|| "Could not determine home directory".to_string())
.map(|p| p.to_string_lossy().to_string())
}
#[tauri::command]
@@ -121,53 +160,93 @@ pub async fn validate_directory(
path: String,
current_dir: Option<String>,
) -> Result<String, String> {
use std::path::Path;
use std::path::{Path, PathBuf};
let path = Path::new(&path);
// Detect if we're dealing with a WSL path (starts with / on Windows, or current_dir is a WSL path)
let is_wsl_path = cfg!(windows) && (path.starts_with('/') || current_dir.as_ref().is_some_and(|p| p.starts_with('/')));
// Expand ~ to home directory
let expanded_path = if path.starts_with("~") {
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
let home_path = Path::new(&home);
if path == Path::new("~") {
home_path.to_path_buf()
if is_wsl_path {
// WSL path - handle as Unix-style path without filesystem validation
// since the Windows binary can't validate WSL filesystem paths
let resolved = if path.starts_with('/') {
// Absolute WSL path - use as-is
path
} else if let Some(ref cwd) = current_dir {
// Relative path - resolve manually using Unix path logic
if path == "." {
cwd.clone()
} else if path == ".." {
// Go up one directory
cwd.rsplit_once('/').map(|x| x.0).unwrap_or("/").to_string()
} else if path.starts_with("../") {
// Handle ../ prefix
let parent = cwd.rsplit_once('/').map(|x| x.0).unwrap_or("/");
let remainder = path.strip_prefix("../").unwrap();
if remainder.is_empty() {
parent.to_string()
} else {
format!("{}/{}", parent, remainder)
}
} else if path.starts_with("./") {
// Handle ./ prefix
format!("{}/{}", cwd, path.strip_prefix("./").unwrap())
} else {
home_path.join(path.strip_prefix("~").unwrap())
// Regular relative path
format!("{}/{}", cwd, path)
}
} else {
return Err("Could not determine home directory".to_string());
}
} else if path.is_relative() {
// Handle relative paths (., .., or any relative path) by resolving against current_dir
if let Some(ref cwd) = current_dir {
Path::new(cwd).join(path)
return Err("Cannot resolve relative WSL path without current directory".to_string());
};
// Normalize the path (remove duplicate slashes, etc.)
let normalized = resolved.split('/').filter(|s| !s.is_empty()).collect::<Vec<_>>().join("/");
Ok(if normalized.is_empty() { "/".to_string() } else { format!("/{}", normalized) })
} else {
// Native path (Windows on Windows, Unix on Unix) - validate normally
let path = Path::new(&path);
let expanded_path = if path.starts_with("~") {
if let Some(home) = dirs::home_dir() {
if path == Path::new("~") {
home
} else {
home.join(path.strip_prefix("~").unwrap())
}
} else {
return Err("Could not determine home directory".to_string());
}
} else if path.is_relative() {
if let Some(ref cwd) = current_dir {
let cwd_path = PathBuf::from(cwd);
cwd_path.join(path)
} else {
path.to_path_buf()
}
} else {
path.to_path_buf()
};
// Check if the path exists and is a directory
if !expanded_path.exists() {
return Err(format!(
"Directory does not exist: {}",
expanded_path.display()
));
}
} else {
path.to_path_buf()
};
// Check if the path exists and is a directory
if !expanded_path.exists() {
return Err(format!(
"Directory does not exist: {}",
expanded_path.display()
));
if !expanded_path.is_dir() {
return Err(format!(
"Path is not a directory: {}",
expanded_path.display()
));
}
// Return the canonicalized (absolute) path
expanded_path
.canonicalize()
.map(|p| p.to_string_lossy().to_string())
.map_err(|e| format!("Failed to resolve path: {}", e))
}
if !expanded_path.is_dir() {
return Err(format!(
"Path is not a directory: {}",
expanded_path.display()
));
}
// Return the canonicalized (absolute) path with forward slashes
expanded_path
.canonicalize()
.map(|p| normalize_path_separators(&p.to_string_lossy()))
.map_err(|e| format!("Failed to resolve path: {}", e))
}
#[tauri::command]
@@ -203,22 +282,22 @@ pub async fn answer_question(
#[tauri::command]
pub async fn list_skills() -> Result<Vec<String>, String> {
// On Windows, we need to use WSL to access the skills directory
// since skills are stored in the WSL home directory
if cfg!(windows) {
return list_skills_via_wsl().await;
}
// On Unix systems, use the native filesystem
use std::fs;
use std::path::Path;
// Get the home directory - use HOME on Unix, USERPROFILE on Windows
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.ok_or_else(|| "Could not determine home directory".to_string())?;
let home = dirs::home_dir().ok_or_else(|| "Could not determine home directory".to_string())?;
let skills_dir = home.join(".claude").join("skills");
let skills_dir = Path::new(&home).join(".claude").join("skills");
// If the skills directory doesn't exist, return empty list
if !skills_dir.exists() {
return Ok(Vec::new());
}
// Read the directory and collect skill names
let mut skills = Vec::new();
let entries =
fs::read_dir(&skills_dir).map_err(|e| format!("Failed to read skills directory: {}", e))?;
@@ -227,7 +306,6 @@ pub async fn list_skills() -> Result<Vec<String>, String> {
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
let path = entry.path();
// Only include directories that contain a SKILL.md file
if path.is_dir() {
let skill_file = path.join("SKILL.md");
if skill_file.exists() {
@@ -238,9 +316,42 @@ pub async fn list_skills() -> Result<Vec<String>, String> {
}
}
// Sort alphabetically
skills.sort();
Ok(skills)
}
/// List skills by executing commands through WSL (for Windows)
#[allow(dead_code)]
async fn list_skills_via_wsl() -> Result<Vec<String>, String> {
use std::process::Command;
// Use WSL to list directories in ~/.claude/skills that contain SKILL.md
let output = Command::new("wsl")
.args([
"-e",
"sh",
"-c",
"if [ -d ~/.claude/skills ]; then for d in ~/.claude/skills/*/; do [ -f \"${d}SKILL.md\" ] && basename \"$d\"; done; fi",
])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not found") || stderr.contains("No such file") {
return Ok(Vec::new());
}
return Err(format!("WSL command failed: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut skills: Vec<String> = stdout
.lines()
.filter(|line| !line.is_empty())
.map(|line| line.to_string())
.collect();
skills.sort();
Ok(skills)
}
@@ -337,8 +448,18 @@ pub async fn save_temp_file(
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let path_string = path.to_string_lossy().to_string();
// On Windows, convert the path to WSL format if needed
// so Claude Code (running in WSL) can access it via /mnt/c/...
let final_path = if cfg!(windows) {
windows_path_to_wsl(&path_string).unwrap_or(path_string)
} else {
path_string
};
Ok(SavedFileInfo {
path: path.to_string_lossy().to_string(),
path: final_path,
filename,
})
}
@@ -407,44 +528,142 @@ pub struct FileEntry {
}
#[tauri::command]
pub async fn list_directory(path: String) -> Result<Vec<FileEntry>, String> {
pub async fn list_directory(app: AppHandle, path: String) -> Result<Vec<FileEntry>, String> {
// Set up logging
let log_path = if let Ok(app_data_dir) = app.path().app_data_dir() {
let _ = std::fs::create_dir_all(&app_data_dir);
app_data_dir.join("hikari_editor_debug.log")
} else {
PathBuf::from("hikari_editor_debug.log")
};
let mut log_file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.ok();
let mut log = |msg: String| {
if let Some(ref mut file) = log_file {
use std::io::Write;
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let _ = writeln!(file, "[{}] {}", timestamp, msg);
}
};
log(format!("list_directory called with path: {}", path));
log(format!("cfg!(windows) = {}", cfg!(windows)));
log(format!("path.starts_with('/') = {}", path.starts_with('/')));
// On Windows with a WSL path (starts with /), use WSL to list the directory
if cfg!(windows) && path.starts_with('/') {
log("Using WSL path".to_string());
return list_directory_via_wsl(&path).await;
}
log("Using native filesystem access".to_string());
// Native filesystem access
use std::fs;
use std::path::Path;
let dir_path = Path::new(&path);
if !dir_path.exists() {
return Err(format!("Directory does not exist: {}", path));
let err = format!("Directory does not exist: {}", path);
log(format!("ERROR: {}", err));
return Err(err);
}
if !dir_path.is_dir() {
return Err(format!("Path is not a directory: {}", path));
let err = format!("Path is not a directory: {}", path);
log(format!("ERROR: {}", err));
return Err(err);
}
let entries = fs::read_dir(dir_path)
.map_err(|e| format!("Failed to read directory: {}", e))?;
.map_err(|e| {
let err = format!("Failed to read directory: {}", e);
log(format!("ERROR: {}", err));
err
})?;
let mut file_entries = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let entry = entry.map_err(|e| {
let err = format!("Failed to read entry: {}", e);
log(format!("ERROR: {}", err));
err
})?;
let path = entry.path();
let name = entry
.file_name()
.to_string_lossy()
.to_string();
// Skip hidden files by default (can be made configurable later)
if name.starts_with('.') {
file_entries.push(FileEntry {
name: name.clone(),
path: path.to_string_lossy().to_string(),
is_directory: path.is_dir(),
});
}
log(format!("Successfully listed {} entries", file_entries.len()));
Ok(file_entries)
}
/// List directory contents via WSL (for Windows with WSL paths)
#[allow(dead_code)]
async fn list_directory_via_wsl(path: &str) -> Result<Vec<FileEntry>, String> {
use std::process::Command;
// Use WSL to list directory contents
// Output format: type<tab>name (d for directory, f for file)
let script = format!(
r#"if [ -d '{}' ]; then for f in '{}'/* '{}'/.* ; do [ -e "$f" ] || continue; name=$(basename "$f"); if [ "$name" = "." ] || [ "$name" = ".." ]; then continue; fi; if [ -d "$f" ]; then echo "d $name"; else echo "f $name"; fi; done; else echo "ERROR: Directory does not exist"; exit 1; fi"#,
path, path, path
);
let output = Command::new("wsl")
.args(["-e", "sh", "-c", &script])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout);
if !output.status.success() || stdout.starts_with("ERROR:") {
let stderr = String::from_utf8_lossy(&output.stderr);
if stdout.starts_with("ERROR:") {
return Err(stdout.trim().to_string());
}
return Err(format!("WSL command failed: {}", stderr));
}
let mut file_entries = Vec::new();
for line in stdout.lines() {
if line.is_empty() {
continue;
}
let path_str = normalize_path_separators(&path.to_string_lossy());
let parts: Vec<&str> = line.splitn(2, '\t').collect();
if parts.len() != 2 {
continue;
}
let is_directory = parts[0] == "d";
let name = parts[1].to_string();
let entry_path = if path == "/" {
format!("/{}", name)
} else {
format!("{}/{}", path, name)
};
file_entries.push(FileEntry {
name,
path: path_str,
is_directory: path.is_dir(),
path: entry_path,
is_directory,
});
}
@@ -453,22 +672,80 @@ pub async fn list_directory(path: String) -> Result<Vec<FileEntry>, String> {
#[tauri::command]
pub async fn read_file_content(path: String) -> Result<String, String> {
use std::fs;
// On Windows with a WSL path, use WSL to read the file
if cfg!(windows) && path.starts_with('/') {
return read_file_via_wsl(&path).await;
}
use std::fs;
fs::read_to_string(&path)
.map_err(|e| format!("Failed to read file: {}", e))
}
/// Read file content via WSL (for Windows with WSL paths)
#[allow(dead_code)]
async fn read_file_via_wsl(path: &str) -> Result<String, String> {
use std::process::Command;
let output = Command::new("wsl")
.args(["-e", "cat", path])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to read file: {}", stderr));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
#[tauri::command]
pub async fn write_file_content(path: String, content: String) -> Result<(), String> {
use std::fs;
// On Windows with a WSL path, use WSL to write the file
if cfg!(windows) && path.starts_with('/') {
return write_file_via_wsl(&path, &content).await;
}
use std::fs;
fs::write(&path, content)
.map_err(|e| format!("Failed to write file: {}", e))
}
/// Write file content via WSL (for Windows with WSL paths)
#[allow(dead_code)]
async fn write_file_via_wsl(path: &str, content: &str) -> Result<(), String> {
use std::io::Write;
use std::process::{Command, Stdio};
let mut child = Command::new("wsl")
.args(["-e", "sh", "-c", &format!("cat > '{}'", path)])
.stdin(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(content.as_bytes())
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
}
let status = child.wait()
.map_err(|e| format!("Failed to wait for WSL command: {}", e))?;
if !status.success() {
return Err("Failed to write file via WSL".to_string());
}
Ok(())
}
#[tauri::command]
pub async fn create_file(path: String) -> Result<(), String> {
// On Windows with a WSL path, use WSL to create the file
if cfg!(windows) && path.starts_with('/') {
return create_file_via_wsl(&path).await;
}
use std::fs::File;
use std::path::Path;
@@ -483,8 +760,41 @@ pub async fn create_file(path: String) -> Result<(), String> {
Ok(())
}
/// Create file via WSL (for Windows with WSL paths)
#[allow(dead_code)]
async fn create_file_via_wsl(path: &str) -> Result<(), String> {
use std::process::Command;
// Check if file exists first
let check = Command::new("wsl")
.args(["-e", "test", "-e", path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
if check.success() {
return Err("File already exists".to_string());
}
let output = Command::new("wsl")
.args(["-e", "touch", path])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to create file: {}", stderr));
}
Ok(())
}
#[tauri::command]
pub async fn create_directory(path: String) -> Result<(), String> {
// On Windows with a WSL path, use WSL to create the directory
if cfg!(windows) && path.starts_with('/') {
return create_directory_via_wsl(&path).await;
}
use std::fs;
use std::path::Path;
@@ -499,8 +809,41 @@ pub async fn create_directory(path: String) -> Result<(), String> {
Ok(())
}
/// Create directory via WSL (for Windows with WSL paths)
#[allow(dead_code)]
async fn create_directory_via_wsl(path: &str) -> Result<(), String> {
use std::process::Command;
// Check if directory exists first
let check = Command::new("wsl")
.args(["-e", "test", "-e", path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
if check.success() {
return Err("Directory already exists".to_string());
}
let output = Command::new("wsl")
.args(["-e", "mkdir", "-p", path])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to create directory: {}", stderr));
}
Ok(())
}
#[tauri::command]
pub async fn delete_file(path: String) -> Result<(), String> {
// On Windows with a WSL path, use WSL to delete the file
if cfg!(windows) && path.starts_with('/') {
return delete_file_via_wsl(&path).await;
}
use std::fs;
use std::path::Path;
@@ -519,8 +862,51 @@ pub async fn delete_file(path: String) -> Result<(), String> {
Ok(())
}
/// Delete file via WSL (for Windows with WSL paths)
#[allow(dead_code)]
async fn delete_file_via_wsl(path: &str) -> Result<(), String> {
use std::process::Command;
// Check if path exists
let check_exists = Command::new("wsl")
.args(["-e", "test", "-e", path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
if !check_exists.success() {
return Err("File does not exist".to_string());
}
// Check if path is a directory
let check_dir = Command::new("wsl")
.args(["-e", "test", "-d", path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
if check_dir.success() {
return Err("Path is a directory, use delete_directory instead".to_string());
}
let output = Command::new("wsl")
.args(["-e", "rm", path])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to delete file: {}", stderr));
}
Ok(())
}
#[tauri::command]
pub async fn delete_directory(path: String) -> Result<(), String> {
// On Windows with a WSL path, use WSL to delete the directory
if cfg!(windows) && path.starts_with('/') {
return delete_directory_via_wsl(&path).await;
}
use std::fs;
use std::path::Path;
@@ -539,8 +925,51 @@ pub async fn delete_directory(path: String) -> Result<(), String> {
Ok(())
}
/// Delete directory via WSL (for Windows with WSL paths)
#[allow(dead_code)]
async fn delete_directory_via_wsl(path: &str) -> Result<(), String> {
use std::process::Command;
// Check if path exists
let check_exists = Command::new("wsl")
.args(["-e", "test", "-e", path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
if !check_exists.success() {
return Err("Directory does not exist".to_string());
}
// Check if path is a directory
let check_dir = Command::new("wsl")
.args(["-e", "test", "-d", path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
if !check_dir.success() {
return Err("Path is not a directory".to_string());
}
let output = Command::new("wsl")
.args(["-e", "rm", "-rf", path])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to delete directory: {}", stderr));
}
Ok(())
}
#[tauri::command]
pub async fn rename_path(old_path: String, new_path: String) -> Result<(), String> {
// On Windows with WSL paths, use WSL to rename
if cfg!(windows) && old_path.starts_with('/') {
return rename_path_via_wsl(&old_path, &new_path).await;
}
use std::fs;
use std::path::Path;
@@ -560,6 +989,184 @@ pub async fn rename_path(old_path: String, new_path: String) -> Result<(), Strin
Ok(())
}
/// Rename path via WSL (for Windows with WSL paths)
#[allow(dead_code)]
async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), String> {
use std::process::Command;
// Check if old path exists
let check_old = Command::new("wsl")
.args(["-e", "test", "-e", old_path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
if !check_old.success() {
return Err("Path does not exist".to_string());
}
// Check if new path already exists
let check_new = Command::new("wsl")
.args(["-e", "test", "-e", new_path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
if check_new.success() {
return Err("Destination already exists".to_string());
}
let output = Command::new("wsl")
.args(["-e", "mv", old_path, new_path])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to rename: {}", stderr));
}
Ok(())
}
// ==================== Cost Tracking Commands ====================
const COST_HISTORY_STORE_KEY: &str = "cost_history";
#[tauri::command]
pub async fn get_cost_summary(app: AppHandle, days: u32) -> Result<crate::cost_tracking::CostSummary, String> {
let history = load_cost_history(&app).await;
Ok(history.get_summary(days))
}
#[tauri::command]
pub async fn get_cost_alerts(app: AppHandle) -> Result<Vec<crate::cost_tracking::CostAlert>, String> {
let mut history = load_cost_history(&app).await;
let alerts = history.check_alerts();
// Save updated alert state
save_cost_history(&app, &history).await?;
Ok(alerts)
}
#[tauri::command]
pub async fn set_cost_alert_thresholds(
app: AppHandle,
daily: Option<f64>,
weekly: Option<f64>,
monthly: Option<f64>,
) -> Result<(), String> {
let mut history = load_cost_history(&app).await;
history.set_alert_thresholds(daily, weekly, monthly);
save_cost_history(&app, &history).await
}
#[tauri::command]
pub async fn export_cost_csv(app: AppHandle, days: u32) -> Result<String, String> {
let history = load_cost_history(&app).await;
Ok(history.export_csv(days))
}
#[tauri::command]
pub async fn get_today_cost(app: AppHandle) -> Result<f64, String> {
let history = load_cost_history(&app).await;
Ok(history.get_today_cost())
}
#[tauri::command]
pub async fn get_week_cost(app: AppHandle) -> Result<f64, String> {
let history = load_cost_history(&app).await;
Ok(history.get_week_cost())
}
#[tauri::command]
pub async fn get_month_cost(app: AppHandle) -> Result<f64, String> {
let history = load_cost_history(&app).await;
Ok(history.get_month_cost())
}
/// Add cost to history (called internally when stats are updated)
pub async fn record_cost(app: &AppHandle, input_tokens: u64, output_tokens: u64, cost_usd: f64) {
let mut history = load_cost_history(app).await;
history.add_cost(input_tokens, output_tokens, cost_usd);
let _ = save_cost_history(app, &history).await;
}
/// Record a new session
pub async fn record_session(app: &AppHandle) {
let mut history = load_cost_history(app).await;
history.increment_sessions();
let _ = save_cost_history(app, &history).await;
}
async fn load_cost_history(app: &AppHandle) -> crate::cost_tracking::CostHistory {
let store = match app.store("hikari-cost-history.json") {
Ok(s) => s,
Err(_) => return crate::cost_tracking::CostHistory::new(),
};
match store.get(COST_HISTORY_STORE_KEY) {
Some(value) => serde_json::from_value(value.clone()).unwrap_or_default(),
None => crate::cost_tracking::CostHistory::new(),
}
}
async fn save_cost_history(app: &AppHandle, history: &crate::cost_tracking::CostHistory) -> Result<(), String> {
let store = app.store("hikari-cost-history.json").map_err(|e| e.to_string())?;
let value = serde_json::to_value(history).map_err(|e| e.to_string())?;
store.set(COST_HISTORY_STORE_KEY, value);
store.save().map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn init_discord_rpc(
discord_rpc: State<'_, std::sync::Arc<crate::discord_rpc::DiscordRpcManager>>,
session_name: String,
model: String,
started_at: i64,
) -> Result<(), String> {
discord_rpc.init(session_name, model, started_at)
}
#[tauri::command]
pub async fn update_discord_rpc(
discord_rpc: State<'_, std::sync::Arc<crate::discord_rpc::DiscordRpcManager>>,
session_name: String,
model: String,
started_at: i64,
) -> Result<(), String> {
discord_rpc.update(session_name, model, started_at)
}
#[tauri::command]
pub async fn stop_discord_rpc(
discord_rpc: State<'_, std::sync::Arc<crate::discord_rpc::DiscordRpcManager>>,
) -> Result<(), String> {
discord_rpc.stop()
}
#[tauri::command]
pub async fn log_discord_rpc(
discord_rpc: State<'_, std::sync::Arc<crate::discord_rpc::DiscordRpcManager>>,
message: String,
) -> Result<(), String> {
discord_rpc.log(&message);
Ok(())
}
#[tauri::command]
pub async fn close_application(app_handle: AppHandle) -> Result<(), String> {
// Get the main window
if let Some(window) = app_handle.get_webview_window("main") {
// Hide the window first for a smoother close
let _ = window.hide();
}
// Exit the application
app_handle.exit(0);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -769,7 +1376,10 @@ mod tests {
fn test_select_wsl_directory_returns_home() {
let result = run_async(select_wsl_directory());
assert!(result.is_ok());
assert_eq!(result.unwrap(), "/home");
// Should return the user's home directory
let home_dir = result.unwrap();
assert!(home_dir.starts_with("/home/") || home_dir == "/root");
}
// ==================== UpdateInfo struct tests ====================
@@ -818,25 +1428,4 @@ mod tests {
assert!(json.contains("/tmp/test.txt"));
assert!(json.contains("test.txt"));
}
#[test]
fn test_validate_directory_normalizes_backslashes() {
// This test ensures that paths with backslashes are normalized
// Create a temp directory for testing
let temp_dir = TempDir::new().unwrap();
// On Windows, the canonicalized path will have backslashes
// Our normalize_path_separators should convert them to forward slashes
let result = run_async(validate_directory(
temp_dir.path().to_string_lossy().to_string(),
None,
));
assert!(result.is_ok());
let normalized_path = result.unwrap();
// The result should never contain backslashes
assert!(!normalized_path.contains('\\'),
"Path should not contain backslashes: {}", normalized_path);
}
}
+58 -6
View File
@@ -28,6 +28,7 @@ pub struct ClaudeStartOptions {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HikariConfig {
#[serde(default)]
pub model: Option<String>,
@@ -71,9 +72,6 @@ pub struct HikariConfig {
#[serde(default = "default_font_size")]
pub font_size: u32,
#[serde(default)]
pub minimize_to_tray: bool,
#[serde(default)]
pub streamer_mode: bool,
@@ -96,6 +94,25 @@ pub struct HikariConfig {
// Custom theme colors
#[serde(default)]
pub custom_theme_colors: CustomThemeColors,
// Token budget settings
#[serde(default)]
pub budget_enabled: bool,
#[serde(default)]
pub session_token_budget: Option<u64>,
#[serde(default)]
pub session_cost_budget: Option<f64>,
#[serde(default = "default_budget_action")]
pub budget_action: BudgetAction,
#[serde(default = "default_budget_warning_threshold")]
pub budget_warning_threshold: f32,
#[serde(default = "default_discord_rpc_enabled")]
pub discord_rpc_enabled: bool,
}
impl Default for HikariConfig {
@@ -115,7 +132,6 @@ impl Default for HikariConfig {
update_checks_enabled: true,
character_panel_width: None,
font_size: 14,
minimize_to_tray: false,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
@@ -123,6 +139,12 @@ impl Default for HikariConfig {
profile_avatar_path: None,
profile_bio: None,
custom_theme_colors: CustomThemeColors::default(),
budget_enabled: false,
session_token_budget: None,
session_cost_budget: None,
budget_action: BudgetAction::Warn,
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
}
}
}
@@ -147,6 +169,26 @@ fn default_font_size() -> u32 {
14
}
fn default_budget_action() -> BudgetAction {
BudgetAction::Warn
}
fn default_budget_warning_threshold() -> f32 {
0.8
}
fn default_discord_rpc_enabled() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum BudgetAction {
#[default]
Warn,
Block,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Theme {
@@ -197,7 +239,6 @@ mod tests {
assert!(config.update_checks_enabled);
assert!(config.character_panel_width.is_none());
assert_eq!(config.font_size, 14);
assert!(!config.minimize_to_tray);
assert!(!config.streamer_mode);
assert!(!config.streamer_hide_paths);
assert!(!config.compact_mode);
@@ -205,6 +246,12 @@ mod tests {
assert!(config.profile_avatar_path.is_none());
assert!(config.profile_bio.is_none());
assert_eq!(config.custom_theme_colors, CustomThemeColors::default());
assert!(!config.budget_enabled);
assert!(config.session_token_budget.is_none());
assert!(config.session_cost_budget.is_none());
assert_eq!(config.budget_action, BudgetAction::Warn);
assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON);
assert!(config.discord_rpc_enabled);
}
#[test]
@@ -224,7 +271,6 @@ mod tests {
update_checks_enabled: true,
character_panel_width: Some(400),
font_size: 16,
minimize_to_tray: true,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
@@ -232,6 +278,12 @@ mod tests {
profile_avatar_path: None,
profile_bio: Some("A test bio".to_string()),
custom_theme_colors: CustomThemeColors::default(),
budget_enabled: true,
session_token_budget: Some(100000),
session_cost_budget: Some(1.50),
budget_action: BudgetAction::Block,
budget_warning_threshold: 0.75,
discord_rpc_enabled: true,
};
let json = serde_json::to_string(&config).unwrap();
+376
View File
@@ -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);
}
}
+78
View File
@@ -0,0 +1,78 @@
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);
}
}
+218
View File
@@ -0,0 +1,218 @@
use discord_rich_presence::activity::{Activity, Assets, Timestamps};
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
use parking_lot::RwLock;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;
use tauri::{AppHandle, Manager};
pub struct DiscordRpcManager {
client: Arc<RwLock<Option<DiscordIpcClient>>>,
session_name: Arc<RwLock<String>>,
model: Arc<RwLock<String>>,
started_at: Arc<RwLock<i64>>,
log_path: Arc<RwLock<Option<PathBuf>>>,
}
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)),
log_path: Arc::new(RwLock::new(None)),
}
}
pub fn set_app_handle(&self, app_handle: &AppHandle) {
if let Ok(app_data_dir) = app_handle.path().app_data_dir() {
// Ensure the directory exists
if let Err(e) = std::fs::create_dir_all(&app_data_dir) {
eprintln!("Failed to create app data directory: {}", e);
return;
}
let log_path = app_data_dir.join("hikari_discord_rpc.log");
*self.log_path.write() = Some(log_path.clone());
self.log(&format!(
"Log file initialised at: {}",
log_path.display()
));
}
}
pub fn log(&self, message: &str) {
let log_path_guard = self.log_path.read();
let path = match log_path_guard.as_ref() {
Some(p) => p.clone(),
None => PathBuf::from("hikari_discord_rpc.log"),
};
drop(log_path_guard);
if let Ok(mut file) = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let _ = writeln!(file, "[{}] {}", timestamp, message);
}
}
pub fn init(&self, initial_session_name: String, initial_model: String, started_at: i64) -> Result<(), String> {
self.log("Attempting to initialize Discord RPC...");
self.log("DEBUG: Application ID: 1391117878182281316");
self.log(&format!("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);
self.log(&format!("ERROR: {}", error_msg));
error_msg
})?;
self.log("DEBUG: DiscordIpcClient created successfully");
client
.connect()
.map_err(|e| {
let error_msg = format!("Failed to connect to Discord RPC: {} (ensure Discord is running)", e);
self.log(&format!("ERROR: {}", error_msg));
error_msg
})?;
self.log("DEBUG: Connected to Discord IPC socket");
// Set initial activity immediately after connecting
self.log("DEBUG: Building initial activity...");
let state_text = format!("Model: {}", initial_model);
let assets = Assets::new()
.large_image("hikari")
.large_text("Hikari - Claude Code Assistant");
self.log("DEBUG: Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
let timestamps = Timestamps::new()
.start(started_at);
self.log(&format!("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);
self.log(&format!("DEBUG: Activity created - details: '{}', state: '{}'",
initial_session_name, state_text));
self.log("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);
self.log(&format!("ERROR: {}", error_msg));
error_msg
})?;
self.log("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;
self.log(&format!("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> {
self.log(&format!("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;
self.log("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();
self.log(&format!("ERROR: {}", error_msg));
error_msg
})?;
self.log("DEBUG: Client lock acquired");
let state_text = format!("Model: {}", model);
let assets = Assets::new()
.large_image("hikari")
.large_text("Hikari - Claude Code Assistant");
self.log("DEBUG: Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
let timestamps = Timestamps::new()
.start(started_at);
self.log(&format!("DEBUG: Timestamps created - start: {}", started_at));
let activity = Activity::new()
.details(session_name.as_str())
.state(state_text.as_str())
.assets(assets)
.timestamps(timestamps);
self.log(&format!("DEBUG: Activity created - details: '{}', state: '{}'",
session_name, state_text));
self.log("DEBUG: Attempting to set activity...");
client
.set_activity(activity)
.map_err(|e| {
let error_msg = format!("Failed to update Discord RPC: {}", e);
self.log(&format!("ERROR: {}", error_msg));
error_msg
})?;
self.log(&format!("Updated Discord RPC: session='{}', model='{}'", session_name, model));
Ok(())
}
pub fn stop(&self) -> Result<(), String> {
self.log("DEBUG: stop() called");
let mut client_guard = self.client.write();
if let Some(mut client) = client_guard.take() {
self.log("DEBUG: Client found, attempting to close...");
client
.close()
.map_err(|e| {
let error_msg = format!("Failed to close Discord RPC: {}", e);
self.log(&format!("ERROR: {}", error_msg));
error_msg
})?;
self.log("Discord RPC stopped successfully");
} else {
self.log("DEBUG: No client to stop (already stopped or never initialized)");
}
Ok(())
}
}
impl Default for DiscordRpcManager {
fn default() -> Self {
Self::new()
}
}
+46 -10
View File
@@ -3,6 +3,9 @@ mod bridge_manager;
mod clipboard;
mod commands;
mod config;
mod cost_tracking;
mod debug_logger;
mod discord_rpc;
mod git;
mod notifications;
mod quick_actions;
@@ -10,9 +13,9 @@ mod sessions;
mod snippets;
mod stats;
mod temp_manager;
mod tool_cache;
mod tray;
mod types;
mod utils;
mod vbs_notification;
mod windows_toast;
mod wsl_bridge;
@@ -22,14 +25,19 @@ use bridge_manager::create_shared_bridge_manager;
use clipboard::*;
use commands::load_saved_achievements;
use commands::*;
use debug_logger::TauriLogLayer;
use discord_rpc::DiscordRpcManager;
use git::*;
use notifications::*;
use quick_actions::*;
use sessions::*;
use snippets::*;
use tauri::Manager;
use std::sync::Arc;
use tauri::{Emitter, 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 windows_toast::*;
use wsl_notifications::*;
@@ -38,6 +46,7 @@ use wsl_notifications::*;
pub fn run() {
let bridge_manager = create_shared_bridge_manager();
let temp_manager = create_shared_temp_manager().expect("Failed to create temp file manager");
let discord_rpc = Arc::new(DiscordRpcManager::new());
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
@@ -51,33 +60,47 @@ pub fn run() {
.plugin(tauri_plugin_fs::init())
.manage(bridge_manager.clone())
.manage(temp_manager.clone())
.manage(discord_rpc.clone())
.setup(move |app| {
// Initialize tracing with custom layer that emits to frontend
let tauri_layer = TauriLogLayer::new(app.handle().clone());
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.with(tauri_layer)
.init();
// Initialize the app handle in the bridge manager
bridge_manager.lock().set_app_handle(app.handle().clone());
// Initialize the app handle in the Discord RPC manager for logging
discord_rpc.set_app_handle(app.handle());
// Clean up any orphaned temp files from previous sessions
if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() {
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
if let Err(e) = setup_tray(app.handle()) {
eprintln!("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();
main_window.on_window_event({
let app_handle = app.handle().clone();
move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if should_minimize_to_tray(&app_handle) {
api.prevent_close();
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.hide();
}
// Always prevent default close - let frontend handle it
api.prevent_close();
// Emit event to frontend to show confirmation modal
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.emit("window-close-requested", ());
}
}
}
@@ -160,6 +183,19 @@ pub fn run() {
delete_file,
delete_directory,
rename_path,
// Cost tracking commands
get_cost_summary,
get_cost_alerts,
set_cost_alert_thresholds,
export_cost_csv,
get_today_cost,
get_week_cost,
get_month_cost,
init_discord_rpc,
update_discord_rpc,
stop_discord_rpc,
log_discord_rpc,
close_application,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+728 -41
View File
@@ -5,6 +5,112 @@ use std::collections::HashMap;
use std::time::Instant;
use tauri_plugin_store::StoreExt;
/// Per-tool token usage statistics
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolTokenStats {
pub call_count: u64,
pub estimated_input_tokens: u64,
pub estimated_output_tokens: u64,
}
impl ToolTokenStats {
#[allow(dead_code)]
pub fn new() -> Self {
Self::default()
}
pub fn increment_call(&mut self) {
self.call_count += 1;
}
pub fn add_tokens(&mut self, input: u64, output: u64) {
self.estimated_input_tokens += input;
self.estimated_output_tokens += output;
}
#[allow(dead_code)]
pub fn total_tokens(&self) -> u64 {
self.estimated_input_tokens + self.estimated_output_tokens
}
}
/// Warning levels for context window utilisation
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ContextWarning {
/// 50-74% utilisation - conversation is getting long
Moderate,
/// 75-89% utilisation - consider summarising
High,
/// 90%+ utilisation - approaching limit
Critical,
}
/// Budget status indicating whether user is within their limits
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum BudgetStatus {
/// Within budget, no concerns
Ok,
/// Approaching budget limit (warning threshold reached)
Warning {
budget_type: BudgetType,
percent_used: f32,
},
/// Budget exceeded
Exceeded { budget_type: BudgetType },
}
/// Type of budget limit
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BudgetType {
Token,
Cost,
}
impl ContextWarning {
#[allow(dead_code)]
pub fn message(&self) -> &'static str {
match self {
ContextWarning::Moderate => "Context window is 50%+ full. Consider starting a new conversation for better performance.",
ContextWarning::High => "Context window is 75%+ full. Responses may degrade. Consider summarising or starting fresh.",
ContextWarning::Critical => "Context window is nearly full (90%+)! Start a new conversation to avoid errors.",
}
}
}
/// Get the context window limit (in tokens) for a given model
fn get_context_window_limit(model: &str) -> u64 {
match model {
// Claude 4.6 family - 200K standard (1M beta available via header)
"claude-opus-4-6" => 200_000,
// Claude 4.5 family - 200K standard context
"claude-opus-4-5-20251101"
| "claude-sonnet-4-5-20250929"
| "claude-haiku-4-5-20251001" => 200_000,
// Claude 4.x family - 200K standard context
"claude-opus-4-1-20250805"
| "claude-opus-4-20250514"
| "claude-sonnet-4-20250514" => 200_000,
// Claude 3.x family
"claude-3-7-sonnet-20250219"
| "claude-3-5-sonnet-20241022"
| "claude-3-5-sonnet-20240620"
| "claude-3-5-haiku-20241022"
| "claude-3-opus-20240229"
| "claude-3-sonnet-20240229"
| "claude-3-haiku-20240307" => 200_000,
// Default to 200K for unknown Claude models
_ if model.starts_with("claude") => 200_000,
// For non-Claude models (Ollama, etc.), use a conservative default
_ => 128_000,
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UsageStats {
pub total_input_tokens: u64,
@@ -24,8 +130,8 @@ pub struct UsageStats {
pub session_files_edited: u64,
pub files_created: u64,
pub session_files_created: u64,
pub tools_usage: HashMap<String, u64>,
pub session_tools_usage: HashMap<String, u64>,
pub tools_usage: HashMap<String, ToolTokenStats>,
pub session_tools_usage: HashMap<String, ToolTokenStats>,
pub session_duration_seconds: u64,
#[serde(skip)]
pub session_start: Option<Instant>,
@@ -38,9 +144,28 @@ pub struct UsageStats {
pub night_sessions: u64, // Sessions started after 10 PM
pub last_session_date: Option<String>, // ISO date string for streak tracking
// Context window tracking
pub context_tokens_used: u64,
pub context_window_limit: u64,
pub context_utilisation_percent: f32,
// Cache analytics (tracks potential savings from repeated tool calls)
pub potential_cache_hits: u64,
pub potential_cache_savings_tokens: u64,
// Achievement tracking
#[serde(skip)]
pub achievements: AchievementProgress,
// Track current in-flight request for cost estimation on interrupt
#[serde(skip)]
pub current_request_input: Option<String>,
#[serde(skip)]
pub current_request_output_chars: u64,
#[serde(skip)]
pub current_request_thinking_chars: u64,
#[serde(skip)]
pub current_request_tools: Vec<String>,
}
impl UsageStats {
@@ -50,17 +175,138 @@ impl UsageStats {
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_output_tokens += output_tokens;
self.session_input_tokens += input_tokens;
self.session_output_tokens += output_tokens;
let cost = calculate_cost(input_tokens, output_tokens, model);
let cost = calculate_cost(
input_tokens,
output_tokens,
model,
cache_creation_tokens,
cache_read_tokens,
);
self.total_cost_usd += cost;
self.session_cost_usd += cost;
self.model = Some(model.to_string());
// Update context window tracking
self.update_context_tracking(model);
}
pub fn update_context_tracking(&mut self, model: &str) {
// Get context window limit for the current model
self.context_window_limit = get_context_window_limit(model);
// Context tokens = input tokens (the prompt/context sent to the model)
// We track cumulative session input as a proxy for context growth
self.context_tokens_used = self.session_input_tokens;
// Calculate utilisation percentage
if self.context_window_limit > 0 {
self.context_utilisation_percent =
(self.context_tokens_used as f32 / self.context_window_limit as f32) * 100.0;
}
}
#[allow(dead_code)]
pub fn get_context_warning(&self) -> Option<ContextWarning> {
if self.context_utilisation_percent >= 90.0 {
Some(ContextWarning::Critical)
} else if self.context_utilisation_percent >= 75.0 {
Some(ContextWarning::High)
} else if self.context_utilisation_percent >= 50.0 {
Some(ContextWarning::Moderate)
} else {
None
}
}
#[allow(dead_code)]
pub fn estimate_remaining_tokens(&self) -> u64 {
self.context_window_limit
.saturating_sub(self.context_tokens_used)
}
/// Check budget status given current usage and budget settings
#[allow(dead_code)]
pub fn check_budget(
&self,
budget_enabled: bool,
token_budget: Option<u64>,
cost_budget: Option<f64>,
warning_threshold: f32,
) -> BudgetStatus {
if !budget_enabled {
return BudgetStatus::Ok;
}
let session_tokens = self.session_input_tokens + self.session_output_tokens;
// Check token budget
if let Some(limit) = token_budget {
if session_tokens >= limit {
return BudgetStatus::Exceeded {
budget_type: BudgetType::Token,
};
}
let percent_used = session_tokens as f32 / limit as f32;
if percent_used >= warning_threshold {
return BudgetStatus::Warning {
budget_type: BudgetType::Token,
percent_used: percent_used * 100.0,
};
}
}
// Check cost budget
if let Some(limit) = cost_budget {
if self.session_cost_usd >= limit {
return BudgetStatus::Exceeded {
budget_type: BudgetType::Cost,
};
}
let percent_used = (self.session_cost_usd / limit) as f32;
if percent_used >= warning_threshold {
return BudgetStatus::Warning {
budget_type: BudgetType::Cost,
percent_used: percent_used * 100.0,
};
}
}
BudgetStatus::Ok
}
/// Get remaining token budget (None if no budget set)
#[allow(dead_code)]
pub fn get_remaining_token_budget(&self, token_budget: Option<u64>) -> Option<u64> {
token_budget.map(|limit| {
let used = self.session_input_tokens + self.session_output_tokens;
limit.saturating_sub(used)
})
}
/// Get remaining cost budget (None if no budget set)
#[allow(dead_code)]
pub fn get_remaining_cost_budget(&self, cost_budget: Option<f64>) -> Option<f64> {
cost_budget.map(|limit| {
if limit > self.session_cost_usd {
limit - self.session_cost_usd
} else {
0.0
}
})
}
pub fn reset_session(&mut self) {
@@ -76,6 +322,13 @@ impl UsageStats {
self.session_start = Some(Instant::now());
self.achievements.start_session();
// Reset context window tracking
self.context_tokens_used = 0;
self.context_utilisation_percent = 0.0;
// Note: Cache analytics are NOT reset here - they're cumulative across sessions
// to show total potential savings over time
// Track session start for achievements
self.track_session_start();
}
@@ -139,11 +392,32 @@ impl UsageStats {
}
pub fn increment_tool_usage(&mut self, tool_name: &str) {
*self.tools_usage.entry(tool_name.to_string()).or_insert(0) += 1;
*self
.session_tools_usage
self.tools_usage
.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 {
@@ -184,7 +458,16 @@ impl UsageStats {
morning_sessions: self.morning_sessions,
night_sessions: self.night_sessions,
last_session_date: self.last_session_date.clone(),
context_tokens_used: self.context_tokens_used,
context_window_limit: self.context_window_limit,
context_utilisation_percent: self.context_utilisation_percent,
potential_cache_hits: self.potential_cache_hits,
potential_cache_savings_tokens: self.potential_cache_savings_tokens,
achievements: AchievementProgress::new(), // Dummy for copy
current_request_input: None, // Don't copy tracking fields
current_request_output_chars: 0,
current_request_thinking_chars: 0,
current_request_tools: Vec::new(),
};
check_achievements(&stats_copy, &mut self.achievements)
}
@@ -206,20 +489,32 @@ fn is_consecutive_day(prev_date: &str, current_date: &str) -> bool {
}
}
// Pricing as of January 2025
// https://www.anthropic.com/pricing
fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
// Pricing as of February 2026
// https://platform.claude.com/docs/en/about-claude/models/overview
// Cache pricing: https://platform.claude.com/docs/en/build-with-claude/prompt-caching
pub fn calculate_cost(
input_tokens: u64,
output_tokens: u64,
model: &str,
cache_creation_tokens: Option<u64>,
cache_read_tokens: Option<u64>,
) -> f64 {
let (input_price_per_million, output_price_per_million) = match model {
// Opus 4.5
"claude-opus-4-5-20251101" => (15.0, 75.0),
// Current generation (Claude 4.6)
"claude-opus-4-6" => (5.0, 25.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),
// Sonnet 4
"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-20240620" => (3.0, 15.0),
"claude-3-5-haiku-20241022" => (1.0, 5.0),
@@ -231,10 +526,25 @@ fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
_ => (3.0, 15.0),
};
// Regular input/output tokens
let input_cost = (input_tokens as f64 / 1_000_000.0) * input_price_per_million;
let output_cost = (output_tokens as f64 / 1_000_000.0) * output_price_per_million;
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)]
@@ -252,7 +562,7 @@ pub struct PersistedStats {
pub code_blocks_generated: u64,
pub files_edited: u64,
pub files_created: u64,
pub tools_usage: HashMap<String, u64>,
pub tools_usage: HashMap<String, ToolTokenStats>,
pub sessions_started: u64,
pub consecutive_days: u64,
pub total_days_used: u64,
@@ -353,7 +663,7 @@ mod tests {
#[test]
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
// 2000 output * $15/M = $0.030
// Total = $0.033
@@ -362,7 +672,7 @@ mod tests {
#[test]
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
// 2000 output * $75/M = $0.150
// Total = $0.165
@@ -371,14 +681,16 @@ mod tests {
#[test]
fn test_cost_calculation_opus_45() {
let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101");
// Same pricing as Opus 4
assert!((cost - 0.165).abs() < 0.0001);
let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101", None, None);
// Opus 4.5 pricing: $5/MTok input, $25/MTok output
// 1000 input tokens = $0.005, 2000 output tokens = $0.05
// Total = $0.055
assert!((cost - 0.055).abs() < 0.0001);
}
#[test]
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
// 2000 output * $5/M = $0.010
// Total = $0.011
@@ -387,14 +699,14 @@ mod tests {
#[test]
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
assert!((cost - 0.033).abs() < 0.0001);
}
#[test]
fn test_cost_calculation_legacy_sonnet() {
let cost = calculate_cost(1000, 2000, "claude-3-5-sonnet-20241022");
let cost = calculate_cost(1000, 2000, "claude-3-5-sonnet-20241022", None, None);
// Same as Sonnet 4 pricing
assert!((cost - 0.033).abs() < 0.0001);
}
@@ -402,7 +714,7 @@ mod tests {
#[test]
fn test_usage_stats_accumulation() {
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_output_tokens, 2000);
@@ -414,8 +726,8 @@ mod tests {
#[test]
fn test_usage_stats_multiple_accumulations() {
let mut stats = UsageStats::new();
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514");
stats.add_usage(500, 500, "claude-sonnet-4-20250514");
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514", None, None);
stats.add_usage(500, 500, "claude-sonnet-4-20250514", None, None);
assert_eq!(stats.total_input_tokens, 1500);
assert_eq!(stats.total_output_tokens, 1500);
@@ -426,17 +738,17 @@ mod tests {
#[test]
fn test_usage_stats_model_updated() {
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()));
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()));
}
#[test]
fn test_session_reset() {
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();
assert_eq!(stats.total_input_tokens, 1000);
@@ -512,10 +824,33 @@ mod tests {
stats.increment_tool_usage("Read");
stats.increment_tool_usage("Write");
assert_eq!(stats.tools_usage.get("Read"), Some(&2));
assert_eq!(stats.tools_usage.get("Write"), Some(&1));
assert_eq!(stats.session_tools_usage.get("Read"), Some(&2));
assert_eq!(stats.session_tools_usage.get("Write"), Some(&1));
assert_eq!(stats.tools_usage.get("Read").map(|t| t.call_count), Some(2));
assert_eq!(stats.tools_usage.get("Write").map(|t| t.call_count), Some(1));
assert_eq!(stats.session_tools_usage.get("Read").map(|t| t.call_count), Some(2));
assert_eq!(stats.session_tools_usage.get("Write").map(|t| t.call_count), Some(1));
}
#[test]
fn test_add_tool_tokens() {
let mut stats = UsageStats::new();
stats.increment_tool_usage("Read");
stats.add_tool_tokens("Read", 100, 50);
stats.add_tool_tokens("Read", 200, 100);
let read_stats = stats.tools_usage.get("Read").unwrap();
assert_eq!(read_stats.call_count, 1);
assert_eq!(read_stats.estimated_input_tokens, 300);
assert_eq!(read_stats.estimated_output_tokens, 150);
assert_eq!(read_stats.total_tokens(), 450);
}
#[test]
fn test_tool_token_stats_default() {
let tool_stats = ToolTokenStats::new();
assert_eq!(tool_stats.call_count, 0);
assert_eq!(tool_stats.estimated_input_tokens, 0);
assert_eq!(tool_stats.estimated_output_tokens, 0);
assert_eq!(tool_stats.total_tokens(), 0);
}
#[test]
@@ -590,7 +925,11 @@ mod tests {
files_created: 5,
tools_usage: {
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
},
sessions_started: 10,
@@ -608,7 +947,8 @@ mod tests {
assert_eq!(stats.total_output_tokens, 20000);
assert_eq!(stats.total_cost_usd, 5.50);
assert_eq!(stats.messages_exchanged, 100);
assert_eq!(stats.tools_usage.get("Read"), 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.morning_sessions, 3);
assert_eq!(stats.last_session_date, Some("2024-06-15".to_string()));
@@ -635,7 +975,7 @@ mod tests {
#[test]
fn test_usage_stats_serialization() {
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();
// UsageStats should be serializable (for events)
@@ -664,7 +1004,7 @@ mod tests {
#[test]
fn test_stats_update_event_serialization() {
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 json = serde_json::to_string(&event).expect("Failed to serialize");
@@ -672,4 +1012,351 @@ mod tests {
assert!(json.contains("stats"));
assert!(json.contains("total_input_tokens"));
}
// =====================
// Context Window Tracking tests
// =====================
#[test]
fn test_context_window_limit_claude_4() {
assert_eq!(get_context_window_limit("claude-opus-4-5-20251101"), 200_000);
assert_eq!(get_context_window_limit("claude-opus-4-20250514"), 200_000);
assert_eq!(get_context_window_limit("claude-sonnet-4-20250514"), 200_000);
}
#[test]
fn test_context_window_limit_claude_35() {
assert_eq!(
get_context_window_limit("claude-3-5-sonnet-20241022"),
200_000
);
assert_eq!(
get_context_window_limit("claude-3-5-sonnet-20240620"),
200_000
);
assert_eq!(
get_context_window_limit("claude-3-5-haiku-20241022"),
200_000
);
}
#[test]
fn test_context_window_limit_unknown_claude() {
assert_eq!(
get_context_window_limit("claude-some-future-model"),
200_000
);
}
#[test]
fn test_context_window_limit_non_claude() {
assert_eq!(get_context_window_limit("gpt-4"), 128_000);
assert_eq!(get_context_window_limit("llama-3"), 128_000);
assert_eq!(get_context_window_limit("unknown-model"), 128_000);
}
#[test]
fn test_context_tracking_update() {
let mut stats = UsageStats::new();
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514", None, None);
assert_eq!(stats.context_tokens_used, 50_000);
assert_eq!(stats.context_window_limit, 200_000);
assert!((stats.context_utilisation_percent - 25.0).abs() < 0.1);
}
#[test]
fn test_context_tracking_accumulates() {
let mut stats = UsageStats::new();
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514", None, None);
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514", None, None);
assert_eq!(stats.context_tokens_used, 100_000);
assert!((stats.context_utilisation_percent - 50.0).abs() < 0.1);
}
#[test]
fn test_context_warning_none() {
let mut stats = UsageStats::new();
stats.context_utilisation_percent = 40.0;
assert!(stats.get_context_warning().is_none());
}
#[test]
fn test_context_warning_moderate() {
let mut stats = UsageStats::new();
stats.context_utilisation_percent = 55.0;
assert_eq!(stats.get_context_warning(), Some(ContextWarning::Moderate));
}
#[test]
fn test_context_warning_high() {
let mut stats = UsageStats::new();
stats.context_utilisation_percent = 80.0;
assert_eq!(stats.get_context_warning(), Some(ContextWarning::High));
}
#[test]
fn test_context_warning_critical() {
let mut stats = UsageStats::new();
stats.context_utilisation_percent = 95.0;
assert_eq!(stats.get_context_warning(), Some(ContextWarning::Critical));
}
#[test]
fn test_estimate_remaining_tokens() {
let mut stats = UsageStats::new();
stats.context_tokens_used = 50_000;
stats.context_window_limit = 200_000;
assert_eq!(stats.estimate_remaining_tokens(), 150_000);
}
#[test]
fn test_estimate_remaining_tokens_at_limit() {
let mut stats = UsageStats::new();
stats.context_tokens_used = 200_000;
stats.context_window_limit = 200_000;
assert_eq!(stats.estimate_remaining_tokens(), 0);
}
#[test]
fn test_estimate_remaining_tokens_over_limit() {
let mut stats = UsageStats::new();
stats.context_tokens_used = 250_000;
stats.context_window_limit = 200_000;
assert_eq!(stats.estimate_remaining_tokens(), 0);
}
#[test]
fn test_context_reset_on_session_reset() {
let mut stats = UsageStats::new();
stats.add_usage(100_000, 20_000, "claude-sonnet-4-20250514", None, None);
assert!(stats.context_tokens_used > 0);
assert!(stats.context_utilisation_percent > 0.0);
stats.reset_session();
assert_eq!(stats.context_tokens_used, 0);
assert_eq!(stats.context_utilisation_percent, 0.0);
}
#[test]
fn test_context_warning_message() {
assert_eq!(
ContextWarning::Moderate.message(),
"Context window is 50%+ full. Consider starting a new conversation for better performance."
);
assert_eq!(
ContextWarning::High.message(),
"Context window is 75%+ full. Responses may degrade. Consider summarising or starting fresh."
);
assert_eq!(
ContextWarning::Critical.message(),
"Context window is nearly full (90%+)! Start a new conversation to avoid errors."
);
}
#[test]
fn test_context_warning_serialization() {
let warning = ContextWarning::Critical;
let json = serde_json::to_string(&warning).expect("Failed to serialize");
assert_eq!(json, "\"critical\"");
let warning = ContextWarning::Moderate;
let json = serde_json::to_string(&warning).expect("Failed to serialize");
assert_eq!(json, "\"moderate\"");
}
// =====================
// Budget Tracking tests
// =====================
#[test]
fn test_budget_disabled_returns_ok() {
let stats = UsageStats::new();
let status = stats.check_budget(false, Some(1000), Some(1.0), 0.8);
assert_eq!(status, BudgetStatus::Ok);
}
#[test]
fn test_budget_no_limits_returns_ok() {
let stats = UsageStats::new();
let status = stats.check_budget(true, None, None, 0.8);
assert_eq!(status, BudgetStatus::Ok);
}
#[test]
fn test_token_budget_within_limit() {
let mut stats = UsageStats::new();
stats.session_input_tokens = 500;
stats.session_output_tokens = 300;
let status = stats.check_budget(true, Some(10000), None, 0.8);
assert_eq!(status, BudgetStatus::Ok);
}
#[test]
fn test_token_budget_warning() {
let mut stats = UsageStats::new();
stats.session_input_tokens = 4500;
stats.session_output_tokens = 4000;
let status = stats.check_budget(true, Some(10000), None, 0.8);
match status {
BudgetStatus::Warning {
budget_type,
percent_used,
} => {
assert_eq!(budget_type, BudgetType::Token);
assert!(percent_used >= 80.0);
}
_ => panic!("Expected Warning status"),
}
}
#[test]
fn test_token_budget_exceeded() {
let mut stats = UsageStats::new();
stats.session_input_tokens = 6000;
stats.session_output_tokens = 5000;
let status = stats.check_budget(true, Some(10000), None, 0.8);
assert_eq!(
status,
BudgetStatus::Exceeded {
budget_type: BudgetType::Token
}
);
}
#[test]
fn test_cost_budget_within_limit() {
let mut stats = UsageStats::new();
stats.session_cost_usd = 0.50;
let status = stats.check_budget(true, None, Some(5.0), 0.8);
assert_eq!(status, BudgetStatus::Ok);
}
#[test]
fn test_cost_budget_warning() {
let mut stats = UsageStats::new();
stats.session_cost_usd = 4.25;
let status = stats.check_budget(true, None, Some(5.0), 0.8);
match status {
BudgetStatus::Warning {
budget_type,
percent_used,
} => {
assert_eq!(budget_type, BudgetType::Cost);
assert!(percent_used >= 80.0);
}
_ => panic!("Expected Warning status"),
}
}
#[test]
fn test_cost_budget_exceeded() {
let mut stats = UsageStats::new();
stats.session_cost_usd = 5.50;
let status = stats.check_budget(true, None, Some(5.0), 0.8);
assert_eq!(
status,
BudgetStatus::Exceeded {
budget_type: BudgetType::Cost
}
);
}
#[test]
fn test_token_budget_takes_priority() {
let mut stats = UsageStats::new();
stats.session_input_tokens = 12000;
stats.session_output_tokens = 0;
stats.session_cost_usd = 0.01;
// Token budget exceeded, cost budget OK
let status = stats.check_budget(true, Some(10000), Some(5.0), 0.8);
assert_eq!(
status,
BudgetStatus::Exceeded {
budget_type: BudgetType::Token
}
);
}
#[test]
fn test_remaining_token_budget() {
let mut stats = UsageStats::new();
stats.session_input_tokens = 3000;
stats.session_output_tokens = 2000;
assert_eq!(stats.get_remaining_token_budget(Some(10000)), Some(5000));
assert_eq!(stats.get_remaining_token_budget(None), None);
}
#[test]
fn test_remaining_token_budget_exceeded() {
let mut stats = UsageStats::new();
stats.session_input_tokens = 8000;
stats.session_output_tokens = 5000;
assert_eq!(stats.get_remaining_token_budget(Some(10000)), Some(0));
}
#[test]
fn test_remaining_cost_budget() {
let mut stats = UsageStats::new();
stats.session_cost_usd = 2.50;
let remaining = stats.get_remaining_cost_budget(Some(5.0));
assert!(remaining.is_some());
assert!((remaining.unwrap() - 2.50).abs() < 0.001);
assert_eq!(stats.get_remaining_cost_budget(None), None);
}
#[test]
fn test_remaining_cost_budget_exceeded() {
let mut stats = UsageStats::new();
stats.session_cost_usd = 6.0;
let remaining = stats.get_remaining_cost_budget(Some(5.0));
assert!(remaining.is_some());
assert!((remaining.unwrap() - 0.0).abs() < 0.001);
}
#[test]
fn test_budget_status_serialization() {
let status = BudgetStatus::Warning {
budget_type: BudgetType::Token,
percent_used: 85.5,
};
let json = serde_json::to_string(&status).expect("Failed to serialize");
assert!(json.contains("warning"));
assert!(json.contains("token"));
let status = BudgetStatus::Exceeded {
budget_type: BudgetType::Cost,
};
let json = serde_json::to_string(&status).expect("Failed to serialize");
assert!(json.contains("exceeded"));
assert!(json.contains("cost"));
}
#[test]
fn test_budget_type_serialization() {
let token = BudgetType::Token;
let json = serde_json::to_string(&token).expect("Failed to serialize");
assert_eq!(json, "\"token\"");
let cost = BudgetType::Cost;
let json = serde_json::to_string(&cost).expect("Failed to serialize");
assert_eq!(json, "\"cost\"");
}
}
+266
View File
@@ -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);
}
}
-20
View File
@@ -4,8 +4,6 @@ use tauri::{
AppHandle, Manager,
};
use crate::config::HikariConfig;
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
let show_item = MenuItem::with_id(app, "show", "Show Hikari", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
@@ -48,21 +46,3 @@ pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
Ok(())
}
pub fn should_minimize_to_tray(app: &AppHandle) -> bool {
let config_path = app
.path()
.app_config_dir()
.ok()
.map(|p| p.join("hikari-config.json"));
if let Some(path) = config_path {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(config) = serde_json::from_str::<HikariConfig>(&content) {
return config.minimize_to_tray;
}
}
}
false
}
+72 -1
View File
@@ -4,6 +4,10 @@ use serde::{Deserialize, Serialize};
pub struct UsageInfo {
pub input_tokens: u64,
pub output_tokens: u64,
#[serde(default)]
pub cache_creation_input_tokens: Option<u64>,
#[serde(default)]
pub cache_read_input_tokens: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
@@ -176,6 +180,14 @@ pub struct StateChangeEvent {
pub conversation_id: Option<String>,
}
/// Cost information for a message
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageCost {
pub input_tokens: u64,
pub output_tokens: u64,
pub cost_usd: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputEvent {
pub line_type: String,
@@ -183,14 +195,23 @@ pub struct OutputEvent {
pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<MessageCost>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_tool_use_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionPromptEvent {
pub struct PermissionPromptEventItem {
pub id: String,
pub tool_name: String,
pub tool_input: serde_json::Value,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionPromptEvent {
pub permissions: Vec<PermissionPromptEventItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
@@ -234,6 +255,33 @@ pub struct UserQuestionEvent {
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentStartEvent {
pub tool_use_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
pub description: String,
pub subagent_type: String,
pub started_at: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_tool_use_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentEndEvent {
pub tool_use_id: String,
pub ended_at: u64,
pub is_error: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub num_turns: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -354,10 +402,33 @@ mod tests {
content: "Test output".to_string(),
tool_name: None,
conversation_id: None,
cost: None,
parent_tool_use_id: None,
};
let serialized = serde_json::to_string(&event).unwrap();
assert!(serialized.contains("\"line_type\":\"assistant\""));
assert!(serialized.contains("\"content\":\"Test output\""));
}
#[test]
fn test_output_event_with_cost() {
let event = OutputEvent {
line_type: "assistant".to_string(),
content: "Test output".to_string(),
tool_name: None,
conversation_id: Some("conv-123".to_string()),
cost: Some(MessageCost {
input_tokens: 100,
output_tokens: 50,
cost_usd: 0.005,
}),
parent_tool_use_id: None,
};
let serialized = serde_json::to_string(&event).unwrap();
assert!(serialized.contains("\"cost\":"));
assert!(serialized.contains("\"input_tokens\":100"));
assert!(serialized.contains("\"output_tokens\":50"));
}
}
-71
View File
@@ -1,71 +0,0 @@
/// Utility functions for cross-platform compatibility
/// Normalize path separators to forward slashes for consistent handling across platforms
/// This always normalizes backslashes to forward slashes, regardless of platform,
/// because Windows paths may be passed to WSL which expects Unix-style paths
pub fn normalize_path_separators(path: &str) -> String {
path.replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(target_os = "windows")]
fn test_normalize_path_windows() {
assert_eq!(normalize_path_separators("C:\\Users\\test"), "C:/Users/test");
assert_eq!(normalize_path_separators("path\\to\\file"), "path/to/file");
assert_eq!(normalize_path_separators("already/forward"), "already/forward");
assert_eq!(normalize_path_separators("mixed\\path/file"), "mixed/path/file");
}
#[test]
#[cfg(not(target_os = "windows"))]
fn test_normalize_path_unix() {
assert_eq!(normalize_path_separators("/home/user"), "/home/user");
assert_eq!(normalize_path_separators("path/to/file"), "path/to/file");
// Even on Unix, we normalize backslashes since paths may come from Windows
assert_eq!(normalize_path_separators("weird\\path"), "weird/path");
assert_eq!(normalize_path_separators("/home/user\\file"), "/home/user/file");
}
#[test]
fn test_normalize_wsl_paths() {
// Test the exact issue that was happening
assert_eq!(
normalize_path_separators("/home/naomi/code/naomi/portfolio\\.."),
"/home/naomi/code/naomi/portfolio/.."
);
// Test mixed separators in WSL paths
assert_eq!(
normalize_path_separators("/mnt/c\\Users\\naomi\\Documents"),
"/mnt/c/Users/naomi/Documents"
);
// Test Windows paths that might be passed to WSL
assert_eq!(
normalize_path_separators("C:\\Users\\naomi\\.claude\\skills"),
"C:/Users/naomi/.claude/skills"
);
// Test that forward slashes remain unchanged
assert_eq!(
normalize_path_separators("/home/naomi/.claude/skills"),
"/home/naomi/.claude/skills"
);
// Test empty string
assert_eq!(normalize_path_separators(""), "");
// Test single backslash
assert_eq!(normalize_path_separators("\\"), "/");
// Test multiple consecutive backslashes
assert_eq!(
normalize_path_separators("path\\\\to\\\\file"),
"path//to//file"
);
}
}
+538 -45
View File
@@ -2,6 +2,7 @@ use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::Arc;
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Emitter};
use tempfile::NamedTempFile;
@@ -9,15 +10,30 @@ use tempfile::NamedTempFile;
use std::os::windows::process::CommandExt;
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
use crate::commands::record_cost;
use crate::config::ClaudeStartOptions;
use crate::stats::{StatsUpdateEvent, UsageStats};
use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
use crate::types::{
CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, OutputEvent,
PermissionPromptEvent, QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent,
WorkingDirectoryEvent,
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent,
PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent,
UserQuestionEvent, WorkingDirectoryEvent,
};
use crate::utils::normalize_path_separators;
use parking_lot::RwLock;
use std::cell::RefCell;
thread_local! {
/// Stores pending tool uses from the most recent Assistant message
/// to enable batching permission requests for sibling cancelled tools
static PENDING_TOOL_USES: RefCell<Vec<PendingToolUse>> = const { RefCell::new(Vec::new()) };
}
#[derive(Debug, Clone)]
struct PendingToolUse {
tool_use_id: String,
tool_name: String,
tool_input: serde_json::Value,
}
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
@@ -137,8 +153,7 @@ impl WslBridge {
});
let working_dir = &options.working_dir;
// Normalize path separators to forward slashes
self.working_directory = normalize_path_separators(working_dir);
self.working_directory = working_dir.clone();
emit_connection_status(
&app,
@@ -185,7 +200,7 @@ impl WslBridge {
})?;
eprintln!("[DEBUG] Found claude at: {}", claude_path);
eprintln!("[DEBUG] Working dir: {}", &self.working_directory);
eprintln!("[DEBUG] Working dir: {}", working_dir);
let mut cmd = Command::new(&claude_path);
cmd.args([
@@ -194,6 +209,8 @@ impl WslBridge {
"--input-format",
"stream-json",
"--verbose",
"--debug",
"hooks",
]);
// Add model if specified
@@ -227,7 +244,7 @@ impl WslBridge {
}
}
cmd.current_dir(&self.working_directory);
cmd.current_dir(working_dir);
// Set API key as environment variable if specified
if let Some(ref api_key) = options.api_key {
@@ -243,7 +260,7 @@ impl WslBridge {
let mut cmd = Command::new("wsl");
// Build the claude command with all arguments
let mut claude_cmd = format!("cd '{}' && ", &self.working_directory);
let mut claude_cmd = format!("cd '{}' && ", working_dir);
// Set API key as environment variable if specified
if let Some(ref api_key) = options.api_key {
@@ -356,6 +373,15 @@ impl WslBridge {
pub fn send_message(&mut self, message: &str) -> Result<(), String> {
let stdin = self.stdin.as_mut().ok_or("Process not running")?;
// Track input for cost estimation on interrupt
{
let mut stats = self.stats.write();
stats.current_request_input = Some(message.to_string());
stats.current_request_output_chars = 0;
stats.current_request_thinking_chars = 0;
stats.current_request_tools.clear();
}
let input = serde_json::json!({
"type": "user",
"message": {
@@ -420,6 +446,9 @@ impl WslBridge {
// we have to kill the process. This is the only reliable way to stop it.
// See: https://github.com/anthropics/claude-code/issues/3455
if let Some(mut process) = self.process.take() {
// Estimate cost for interrupted request before killing
self.estimate_interrupted_request_cost(app);
// Kill the process immediately
let _ = process.kill();
let _ = process.wait();
@@ -427,6 +456,15 @@ impl WslBridge {
// Clear stdin
self.stdin = None;
// Clear tracking fields
{
let mut stats = self.stats.write();
stats.current_request_input = None;
stats.current_request_output_chars = 0;
stats.current_request_thinking_chars = 0;
stats.current_request_tools.clear();
}
// Keep session_id and working directory for user reference
// The user will see what session was interrupted
@@ -443,6 +481,99 @@ impl WslBridge {
}
}
fn estimate_interrupted_request_cost(&mut self, app: &AppHandle) {
// Read tracking data from stats
let (input_chars, output_chars, thinking_chars, tools, model) = {
let stats = self.stats.read();
// Only estimate if we have tracked content
if stats.current_request_input.is_none()
&& stats.current_request_output_chars == 0
&& stats.current_request_thinking_chars == 0
&& stats.current_request_tools.is_empty() {
return;
}
let input_chars = stats.current_request_input.as_ref().map(|s| s.len() as u64).unwrap_or(0);
let model = stats.model.clone().unwrap_or_else(|| "claude-sonnet-4-5-20250929".to_string());
(input_chars, stats.current_request_output_chars, stats.current_request_thinking_chars, stats.current_request_tools.clone(), model)
};
println!("[COST ESTIMATION] Estimating cost for interrupted request");
// Use conservative 3.5 chars/token for estimation (vs standard 4)
let estimated_input_tokens = (input_chars as f64 / 3.5).ceil() as u64;
let estimated_output_tokens = ((output_chars as f64 / 3.5).ceil() as u64)
+ ((thinking_chars as f64 / 3.5).ceil() as u64);
// Add tool overhead based on session averages
let mut tool_overhead_tokens = 0u64;
{
let stats = self.stats.read();
for tool_name in &tools {
if let Some(tool_stats) = stats.session_tools_usage.get(tool_name) {
if tool_stats.call_count > 0 {
// Use session average tokens per call for this tool
let avg_tokens = (tool_stats.estimated_input_tokens + tool_stats.estimated_output_tokens)
/ tool_stats.call_count;
tool_overhead_tokens += avg_tokens;
println!("[COST ESTIMATION] Tool {} average: {} tokens", tool_name, avg_tokens);
}
}
}
}
let total_estimated_input = estimated_input_tokens + tool_overhead_tokens;
let total_estimated_output = estimated_output_tokens;
// Add 20% safety margin to overestimate
let safety_margin = 1.2;
let conservative_input = (total_estimated_input as f64 * safety_margin).ceil() as u64;
let conservative_output = (total_estimated_output as f64 * safety_margin).ceil() as u64;
println!("[COST ESTIMATION] Input: {} chars → {} tokens (+ {} tool overhead) × 1.2 safety = {} tokens",
input_chars, estimated_input_tokens, tool_overhead_tokens, conservative_input);
println!("[COST ESTIMATION] Output: {} chars → {} tokens × 1.2 safety = {} tokens",
output_chars + thinking_chars,
estimated_output_tokens, conservative_output);
// Calculate cost (no cache tokens for interrupted requests)
let estimated_cost = calculate_cost(
conservative_input,
conservative_output,
&model,
None,
None,
);
println!("[COST ESTIMATION] Estimated cost: ${:.4} (conservative)", estimated_cost);
// Add to stats with estimated flag
{
let mut stats_guard = self.stats.write();
stats_guard.add_usage(
conservative_input,
conservative_output,
&model,
None,
None,
);
}
// Emit stats update
let stats_update_event = StatsUpdateEvent {
stats: self.stats.read().clone(),
};
let _ = app.emit("claude:stats", stats_update_event);
// Record to historical cost tracking (mark as estimated)
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
record_cost(&app_clone, conservative_input, conservative_output, estimated_cost).await;
});
}
pub fn stop(&mut self, app: &AppHandle) {
if let Some(mut process) = self.process.take() {
let _ = process.kill();
@@ -529,6 +660,25 @@ fn handle_stderr(
for line in reader.lines() {
match line {
Ok(line) if !line.is_empty() => {
// Check if this is a SubagentStart hook message
if line.contains("[SubagentStart Hook]") {
if let Some(agent_data) = parse_subagent_start_hook(&line) {
eprintln!("[DEBUG] Parsed SubagentStart hook: agent_id={}, parent={:?}",
agent_data.agent_id, agent_data.parent_tool_use_id);
// Emit an agent-update event with the agent_id
let _ = app.emit(
"claude:agent-update",
serde_json::json!({
"conversationId": conversation_id.clone(),
"toolUseId": agent_data.parent_tool_use_id,
"agentId": agent_data.agent_id,
}),
);
}
}
// Still emit the stderr line as output
let _ = app.emit(
"claude:output",
OutputEvent {
@@ -536,6 +686,8 @@ fn handle_stderr(
content: line,
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
}
@@ -545,6 +697,41 @@ fn handle_stderr(
}
}
#[derive(Debug)]
struct SubagentStartData {
agent_id: String,
parent_tool_use_id: Option<String>,
}
fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
// Parse: [SubagentStart Hook] agent_id=agent-xxx, parent_tool_use_id=Some("toolu_xxx"), ...
// Extract agent_id
let agent_id = line
.split("agent_id=")
.nth(1)?
.split(',')
.next()?
.trim()
.to_string();
// Extract parent_tool_use_id if present
let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") {
line.split("parent_tool_use_id=Some(\"")
.nth(1)?
.split('"')
.next()
.map(|s| s.to_string())
} else {
None
};
Some(SubagentStartData {
agent_id,
parent_tool_use_id,
})
}
fn process_json_line(
line: &str,
app: &AppHandle,
@@ -584,27 +771,111 @@ fn process_json_line(
}
}
ClaudeMessage::Assistant { message, .. } => {
ClaudeMessage::Assistant { message, parent_tool_use_id } => {
let mut state = CharacterState::Typing;
let mut tool_name = None;
// Collect all tool names in this message for token attribution
let tools_in_message: Vec<String> = message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::ToolUse { name, .. } => Some(name.clone()),
_ => None,
})
.collect();
// Store pending tool uses for permission batching (only for top-level, not subagents)
if parent_tool_use_id.is_none() {
PENDING_TOOL_USES.with(|pending| {
let tool_uses: Vec<PendingToolUse> = message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::ToolUse { id, name, input } => Some(PendingToolUse {
tool_use_id: id.clone(),
tool_name: name.clone(),
tool_input: input.clone(),
}),
_ => None,
})
.collect();
// Append to existing pending tools instead of replacing
pending.borrow_mut().extend(tool_uses);
});
}
// Track message cost for display
let mut message_cost: Option<MessageCost> = None;
// Only update stats if we have usage information
if let Some(usage) = &message.usage {
if let Some(model) = &message.model {
// Batch all stats updates in a single write lock
{
let mut stats_guard = stats.write();
stats_guard.increment_messages();
stats_guard.add_usage(usage.input_tokens, usage.output_tokens, model);
stats_guard.get_session_duration();
}
// Get model from message, or fall back to last known model from stats
let model = message.model.clone().or_else(|| {
let stats_guard = stats.read();
stats_guard.model.clone()
}).unwrap_or_else(|| {
println!("[WARNING] No model info available for cost calculation, using default");
"claude-sonnet-4-5-20250929".to_string()
});
// Don't emit here - we'll emit on Result message instead
// This reduces the frequency of updates
} else {
// Just increment message count if no usage info
stats.write().increment_messages();
// Calculate cost for historical tracking (including cache tokens)
let cost_usd = calculate_cost(
usage.input_tokens,
usage.output_tokens,
&model,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
);
println!("Assistant message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}, cost: ${:.4}",
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
cost_usd
);
// Store cost for later use in output events
message_cost = Some(MessageCost {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cost_usd,
});
// Batch all stats updates in a single write lock
{
let mut stats_guard = stats.write();
stats_guard.increment_messages();
stats_guard.add_usage(
usage.input_tokens,
usage.output_tokens,
&model,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
);
stats_guard.get_session_duration();
// Attribute tokens to tools if any tools were used in this message
if !tools_in_message.is_empty() {
let per_tool_input = usage.input_tokens / tools_in_message.len() as u64;
let per_tool_output = usage.output_tokens / tools_in_message.len() as u64;
for tool in &tools_in_message {
stats_guard.add_tool_tokens(tool, per_tool_input, per_tool_output);
}
}
}
// Record to historical cost tracking
let app_clone = app.clone();
let input = usage.input_tokens;
let output = usage.output_tokens;
tauri::async_runtime::spawn(async move {
record_cost(&app_clone, input, output, cost_usd).await;
});
// Don't emit here - we'll emit on Result message instead
// This reduces the frequency of updates
} else {
// Just increment message count if no usage info
stats.write().increment_messages();
@@ -612,7 +883,7 @@ fn process_json_line(
for block in &message.content {
match block {
ContentBlock::ToolUse { name, input, .. } => {
ContentBlock::ToolUse { id, name, input } => {
tool_name = Some(name.clone());
state = get_tool_state(name);
@@ -629,6 +900,42 @@ fn process_json_line(
}
}
// Emit agent-start event for Task tool invocations
if name == "Task" {
let description = input
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("Subagent")
.to_string();
let subagent_type = input
.get("subagent_type")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
eprintln!(
"[DEBUG] Emitting agent-start: id={}, desc={}, type={}, parent={:?}",
id, description, subagent_type, parent_tool_use_id
);
let _ = app.emit(
"claude:agent-start",
AgentStartEvent {
tool_use_id: id.clone(),
agent_id: None, // Will be updated when SubagentStart hook is received
description,
subagent_type,
started_at: now,
conversation_id: conversation_id.clone(),
parent_tool_use_id: parent_tool_use_id.clone(),
},
);
}
let desc = format_tool_description(name, input);
let _ = app.emit(
"claude:output",
@@ -637,6 +944,8 @@ fn process_json_line(
content: desc,
tool_name: Some(name.clone()),
conversation_id: conversation_id.clone(),
cost: None, // Tool use doesn't have separate cost
parent_tool_use_id: parent_tool_use_id.clone(),
},
);
}
@@ -654,6 +963,8 @@ fn process_json_line(
content: text.clone(),
tool_name: None,
conversation_id: conversation_id.clone(),
cost: message_cost.clone(), // Include cost with assistant text
parent_tool_use_id: parent_tool_use_id.clone(),
},
);
}
@@ -666,10 +977,35 @@ fn process_json_line(
content: format!("[Thinking] {}", thinking),
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: parent_tool_use_id.clone(),
},
);
}
ContentBlock::ToolResult {
tool_use_id,
is_error,
..
} => {
// Emit agent-end for all tool results
// The frontend will ignore IDs that don't match known agents
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let _ = app.emit(
"claude:agent-end",
AgentEndEvent {
tool_use_id: tool_use_id.clone(),
ended_at: now,
is_error: is_error.unwrap_or(false),
conversation_id: conversation_id.clone(),
duration_ms: None,
num_turns: None,
},
);
}
_ => {}
}
}
@@ -679,6 +1015,14 @@ fn process_json_line(
ClaudeMessage::StreamEvent { event } => {
if event.event_type == "content_block_start" {
if let Some(block) = &event.content_block {
// Track tool calls for cost estimation
if block.block_type == "tool_use" {
if let Some(name) = &block.name {
let mut stats_guard = stats.write();
stats_guard.current_request_tools.push(name.clone());
}
}
let state = match block.block_type.as_str() {
"thinking" => CharacterState::Thinking,
"text" => CharacterState::Typing,
@@ -696,7 +1040,16 @@ fn process_json_line(
} else if event.event_type == "content_block_delta" {
if let Some(delta) = &event.delta {
if let Some(text) = &delta.text {
// Track output characters for cost estimation
{
let mut stats_guard = stats.write();
stats_guard.current_request_output_chars += text.len() as u64;
}
let _ = app.emit("claude:stream", text.clone());
} else if let Some(thinking) = &delta.thinking {
// Track thinking characters for cost estimation
let mut stats_guard = stats.write();
stats_guard.current_request_thinking_chars += thinking.len() as u64;
}
}
}
@@ -707,7 +1060,8 @@ fn process_json_line(
result,
permission_denials,
usage,
..
duration_ms,
num_turns,
} => {
let state = if subtype == "success" {
CharacterState::Success
@@ -715,6 +1069,23 @@ fn process_json_line(
CharacterState::Error
};
// Capture pending tool uses before clearing them
// We'll use these for permission batching if there are denials
let captured_pending_tools = PENDING_TOOL_USES.with(|pending| {
let tools = pending.borrow().clone();
// Clear immediately so they don't accumulate across requests
pending.borrow_mut().clear();
tools
});
// Log turn metrics if available
if let Some(duration) = duration_ms {
println!("Turn completed in {}ms", duration);
}
if let Some(turns) = num_turns {
println!("Turn count: {}", turns);
}
// Track token usage from Result messages if available
// This captures tokens from tool outputs and other operations
if let Some(usage_info) = usage {
@@ -725,9 +1096,46 @@ fn process_json_line(
stats_guard.model.clone().unwrap_or_else(|| "claude-opus-4-20250514".to_string())
};
// Calculate cost for historical tracking (including cache tokens)
let cost_usd = calculate_cost(
usage_info.input_tokens,
usage_info.output_tokens,
&model,
usage_info.cache_creation_input_tokens,
usage_info.cache_read_input_tokens,
);
let mut stats_guard = stats.write();
stats_guard.add_usage(usage_info.input_tokens, usage_info.output_tokens, &model);
println!("Result message tokens - input: {}, output: {}", usage_info.input_tokens, usage_info.output_tokens);
stats_guard.add_usage(
usage_info.input_tokens,
usage_info.output_tokens,
&model,
usage_info.cache_creation_input_tokens,
usage_info.cache_read_input_tokens,
);
println!("Result message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}",
usage_info.input_tokens,
usage_info.output_tokens,
usage_info.cache_creation_input_tokens,
usage_info.cache_read_input_tokens
);
// Record to historical cost tracking
let app_clone = app.clone();
let input = usage_info.input_tokens;
let output = usage_info.output_tokens;
tauri::async_runtime::spawn(async move {
record_cost(&app_clone, input, output, cost_usd).await;
});
}
// Clear tracking fields since request completed successfully
{
let mut stats_guard = stats.write();
stats_guard.current_request_input = None;
stats_guard.current_request_output_chars = 0;
stats_guard.current_request_thinking_chars = 0;
stats_guard.current_request_tools.clear();
}
// Always emit updated stats on result message (less frequent)
@@ -799,6 +1207,8 @@ fn process_json_line(
content: text.clone(),
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
}
@@ -806,9 +1216,32 @@ fn process_json_line(
// Check for permission denials and emit prompts for each
if let Some(denials) = permission_denials {
let mut has_regular_denials = false;
// Only process if there are actually denials
if !denials.is_empty() {
// Skip permission prompts if the result was successful - tools were already approved
if subtype == "success" {
emit_state_change(app, state, None, conversation_id.clone());
return Ok(());
}
let mut regular_permission_requests = Vec::new();
// Get denied tool IDs for later comparison
let denied_tool_ids: Vec<String> = denials.iter()
.map(|d| d.tool_use_id.clone())
.collect();
// Helper function to check if a tool is a system tool that should never require permission
let is_system_tool = |tool_name: &str| -> bool {
matches!(tool_name, "ExitPlanMode" | "EnterPlanMode")
};
for denial in denials {
// Skip system tools that should never require permission
if is_system_tool(&denial.tool_name) {
continue;
}
for denial in denials {
// Special handling for AskUserQuestion tool
if denial.tool_name == "AskUserQuestion" {
if let Some(questions) = denial
@@ -869,24 +1302,45 @@ fn process_json_line(
}
}
} else {
has_regular_denials = true;
let description =
format_tool_description(&denial.tool_name, &denial.tool_input);
let _ = app.emit(
"claude:permission",
PermissionPromptEvent {
id: denial.tool_use_id.clone(),
tool_name: denial.tool_name.clone(),
tool_input: denial.tool_input.clone(),
description,
conversation_id: conversation_id.clone(),
},
);
regular_permission_requests.push(PermissionPromptEventItem {
id: denial.tool_use_id.clone(),
tool_name: denial.tool_name.clone(),
tool_input: denial.tool_input.clone(),
description,
});
}
}
// Show permission state if there were any denials (questions or regular)
if has_regular_denials || !denials.is_empty() {
// Check for sibling tools that may have been cancelled
// Add them to the permission batch so they can be approved together
for tool_use in captured_pending_tools.iter() {
// Skip system tools that should never require permission
if is_system_tool(&tool_use.tool_name) {
continue;
}
// Only add tools that weren't explicitly denied (these are likely cancelled siblings)
if !denied_tool_ids.contains(&tool_use.tool_use_id) {
let description = format_tool_description(&tool_use.tool_name, &tool_use.tool_input);
regular_permission_requests.push(PermissionPromptEventItem {
id: tool_use.tool_use_id.clone(),
tool_name: tool_use.tool_name.clone(),
tool_input: tool_use.tool_input.clone(),
description,
});
}
}
// Emit all regular permission requests as a single batched event
if !regular_permission_requests.is_empty() {
let _ = app.emit(
"claude:permission",
PermissionPromptEvent {
permissions: regular_permission_requests,
conversation_id: conversation_id.clone(),
},
);
emit_state_change(
app,
CharacterState::Permission,
@@ -895,7 +1349,19 @@ fn process_json_line(
);
return Ok(());
}
}
// Show permission state if there were any question denials
if !denials.is_empty() {
emit_state_change(
app,
CharacterState::Permission,
None,
conversation_id.clone(),
);
return Ok(());
}
} // end of else block for non-empty denials
} // end of if let Some(denials)
emit_state_change(app, state, None, conversation_id.clone());
}
@@ -904,6 +1370,33 @@ fn process_json_line(
// Increment message count for user messages
stats.write().increment_messages();
// Process content blocks for tool results (e.g., background Task agent completions)
for block in &message.content {
if let ContentBlock::ToolResult {
tool_use_id,
is_error,
..
} = block
{
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let _ = app.emit(
"claude:agent-end",
AgentEndEvent {
tool_use_id: tool_use_id.clone(),
ended_at: now,
is_error: is_error.unwrap_or(false),
conversation_id: conversation_id.clone(),
duration_ms: None,
num_turns: None,
},
);
}
}
// Extract text content from the message
let message_text = message
.content
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop",
"version": "1.1.1",
"version": "1.3.0",
"identifier": "com.naomi.hikari-desktop",
"build": {
"beforeDevCommand": "pnpm dev",
+7 -3
View File
@@ -8,9 +8,13 @@ import {
} from "./slashCommands";
// Mock all external dependencies
vi.mock("svelte/store", () => ({
get: vi.fn(),
}));
vi.mock("svelte/store", async (importOriginal) => {
const actual = await importOriginal<typeof import("svelte/store")>();
return {
...actual,
get: vi.fn(),
};
});
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
+43 -1
View File
@@ -2,8 +2,10 @@ import { get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import { setSkipNextGreeting } from "$lib/tauri";
import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri";
import { searchState } from "$lib/stores/search";
import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config";
export interface SlashCommand {
name: string;
@@ -35,6 +37,12 @@ async function changeDirectory(path: string): Promise<void> {
// Capture conversation history before disconnecting
const conversationHistory = claudeStore.getConversationHistory();
// Get currently granted tools and config auto-granted tools
const activeConversation = get(conversationsStore.activeConversation);
const grantedTools = activeConversation ? Array.from(activeConversation.grantedTools) : [];
const config = configStore.getConfig();
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
await invoke("stop_claude", { conversationId });
// Wait for clean shutdown
@@ -48,9 +56,23 @@ async function changeDirectory(path: string): Promise<void> {
conversationId,
options: {
working_dir: validatedPath,
model: config.model || null,
api_key: config.api_key || null,
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools,
},
});
// Update Discord RPC when reconnecting after directory change
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
// Wait for connection to establish
await new Promise((resolve) => setTimeout(resolve, 1000));
@@ -89,6 +111,12 @@ async function startNewConversation(): Promise<void> {
conversationId,
});
// Get granted tools before interrupting
const activeConversation = get(conversationsStore.activeConversation);
const grantedTools = activeConversation ? Array.from(activeConversation.grantedTools) : [];
const config = configStore.getConfig();
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
claudeStore.addLine("system", "Starting new conversation...");
characterState.setState("thinking");
@@ -102,9 +130,23 @@ async function startNewConversation(): Promise<void> {
conversationId,
options: {
working_dir: workingDir,
model: config.model || null,
api_key: config.api_key || null,
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools,
},
});
// Update Discord RPC when starting new conversation
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
claudeStore.addLine("system", "New conversation started!");
characterState.setState("idle");
} catch (error) {
@@ -161,7 +161,7 @@
<!-- Celebration confetti effect (CSS only) -->
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
{#each Array(10) as _ (_)}
{#each Array.from({ length: 10 }, (_, i) => i) as confettiIndex (confettiIndex)}
<div
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
getAchievementRarity(currentAchievement.achievement.id)
+328
View File
@@ -0,0 +1,328 @@
<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 });
} catch (error) {
console.error("Failed to kill Claude process:", error);
}
}
function handleClearCompleted() {
if (currentConversationId) {
agentStore.clearCompleted(currentConversationId);
}
}
// Flatten the tree for rendering with depth information
const flattenedAgents = $derived.by(() => {
const result: { agent: AgentInfo; depth: number }[] = [];
const { topLevel, childrenMap } = agentTree;
function addAgentAndChildren(agent: AgentInfo, depth: number) {
result.push({ agent, depth });
const children = childrenMap.get(agent.toolUseId);
if (children) {
children.forEach((child) => addAgentAndChildren(child, depth + 1));
}
}
topLevel.forEach((agent) => addAgentAndChildren(agent, 0));
return result;
});
</script>
{#if isOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40" onclick={onClose}></div>
<div
class="fixed top-12 right-0 bottom-0 w-80 bg-[var(--bg-primary)] border-l border-[var(--border-color)] shadow-xl z-50 flex flex-col overflow-hidden"
>
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-[var(--border-color)]">
<div class="flex items-center gap-2">
<svg
class="w-5 h-5 text-[var(--accent-primary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
<h3 class="text-sm font-semibold text-[var(--text-primary)]">Agent Monitor</h3>
{#if runningAgents.length > 0}
<span
class="px-1.5 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400 animate-pulse"
>
{runningAgents.length} running
</span>
{/if}
</div>
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close agent monitor"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Action buttons -->
<div class="flex gap-2 px-4 py-2 border-b border-[var(--border-color)]">
<button
onclick={handleKillAll}
disabled={runningAgents.length === 0}
class="flex-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title="Kills the entire Claude Code process to stop all agents"
>
Kill All
</button>
<button
onclick={handleClearCompleted}
disabled={completedAgents.length === 0 && erroredAgents.length === 0}
class="flex-1 px-2 py-1 text-xs bg-[var(--bg-secondary)] hover:bg-[var(--bg-hover,var(--bg-secondary))] text-[var(--text-secondary)] rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Clear Finished
</button>
</div>
<!-- Agent list -->
<div class="flex-1 overflow-y-auto p-4 space-y-2">
{#if agents.length === 0}
<div
class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)] text-sm"
>
<svg
class="w-8 h-8 mb-2 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
<p>No agents detected yet</p>
<p class="text-xs mt-1 opacity-70">
Agents will appear here when Claude uses the Task tool
</p>
</div>
{:else}
{#each flattenedAgents as { agent, depth } (agent.toolUseId)}
<div
class="p-3 rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] {agent.status ===
'running'
? 'border-l-2 border-l-blue-500'
: agent.status === 'errored'
? 'border-l-2 border-l-red-500'
: 'border-l-2 border-l-green-500'}"
style="margin-left: {depth * 12}px; width: calc(100% - {depth * 12}px);"
>
<!-- Agent header -->
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-1.5">
{#if depth > 0}
<svg
class="w-3 h-3 text-[var(--text-secondary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
{/if}
<span
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
agent.status
)}"
>
{getSubagentTypeLabel(agent.subagentType)}
</span>
</div>
<span
class="text-[10px] {agent.status === 'running'
? 'text-blue-400'
: 'text-[var(--text-secondary)]'}"
>
{#if agent.durationMs !== undefined}
{Math.floor(agent.durationMs / 1000)}s
{:else}
{formatDuration(agent.startedAt, agent.endedAt)}
{/if}
{#if agent.status === "running"}
<span class="inline-block w-1 h-1 bg-blue-400 rounded-full animate-pulse ml-1"
></span>
{/if}
</span>
</div>
<!-- Agent description -->
<p class="text-xs text-[var(--text-primary)] truncate" title={agent.description}>
{agent.description}
</p>
<!-- Status indicator -->
<div class="mt-1 flex items-center gap-1">
{#if agent.status === "running"}
<span class="text-[10px] text-blue-400">Running...</span>
{:else if agent.status === "completed"}
<span class="text-[10px] text-green-400">Completed</span>
{:else}
<span class="text-[10px] text-red-400">Errored / Killed</span>
{/if}
</div>
</div>
{/each}
{/if}
</div>
<!-- Footer summary -->
{#if agents.length > 0}
<div
class="px-4 py-2 border-t border-[var(--border-color)] text-[10px] text-[var(--text-secondary)]"
>
{agents.length} total &middot;
{runningAgents.length} running &middot;
{completedAgents.length} completed &middot;
{erroredAgents.length} errored
</div>
{/if}
</div>
{/if}
@@ -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>
+4
View File
@@ -5,6 +5,7 @@
import { characterState, characterInfo } from "$lib/stores/character";
import { isStreamerMode } from "$lib/stores/config";
import { handleNewUserMessage } from "$lib/notifications/rules";
import { setSkipNextGreeting } from "$lib/tauri";
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
interface Props {
@@ -127,6 +128,9 @@
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
// Set flag to preserve stats/permissions (don't treat next connect as new session)
setSkipNextGreeting(true);
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Interrupted");
characterState.setState("idle");
+173 -16
View File
@@ -12,6 +12,7 @@
} from "$lib/stores/config";
import { claudeStore } from "$lib/stores/claude";
import { getCurrentWindow } from "@tauri-apps/api/window";
import CostSummary from "./CostSummary.svelte";
let config: HikariConfig = $state({
model: null,
@@ -25,7 +26,6 @@
notifications_enabled: true,
notification_volume: 0.7,
always_on_top: false,
minimize_to_tray: false,
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
@@ -45,6 +45,12 @@
text_secondary: null,
border_color: null,
},
budget_enabled: false,
session_token_budget: null,
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
});
let showCustomThemeEditor = $state(false);
@@ -74,8 +80,21 @@
const availableModels = [
{ value: "", label: "Default (from ~/.claude)" },
// Current generation (Claude 4.6)
{ value: "claude-opus-4-6", label: "Claude Opus 4.6 (Most Capable)" },
// Previous generation (Claude 4.5)
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5 (Recommended)" },
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" },
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5" },
// Previous generation (Claude 4.x)
{ value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" },
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
// Legacy (Claude 3.x)
{ value: "claude-3-7-sonnet-20250219", label: "Claude 3.7 Sonnet" },
{ value: "claude-3-5-sonnet-20241022", label: "Claude 3.5 Sonnet (Oct 2024)" },
{ value: "claude-3-5-sonnet-20240620", label: "Claude 3.5 Sonnet (Jun 2024)" },
{ value: "claude-3-haiku-20240307", label: "Claude 3 Haiku (Cheapest)" },
];
const commonTools = [
@@ -708,21 +727,6 @@
</p>
</div>
<!-- Minimize to Tray Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.minimize_to_tray}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Minimize to system tray</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Hide to tray instead of closing when you click the X button
</p>
</div>
<!-- Update Checks Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
@@ -778,6 +782,135 @@
{/if}
</section>
<!-- Budget Settings Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Budget Settings
</h3>
<!-- Enable Budget Tracking -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.budget_enabled}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Enable budget tracking</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Set limits on token usage and costs per session
</p>
</div>
{#if config.budget_enabled}
<!-- Token Budget -->
<div class="mb-4">
<label for="token-budget" class="block text-sm text-[var(--text-secondary)] mb-1">
Session Token Budget
</label>
<div class="flex items-center gap-2">
<input
id="token-budget"
type="number"
bind:value={config.session_token_budget}
min="0"
step="10000"
placeholder="e.g., 100000"
class="flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
/>
<span class="text-xs text-[var(--text-tertiary)]">tokens</span>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">Leave empty for unlimited tokens</p>
</div>
<!-- Cost Budget -->
<div class="mb-4">
<label for="cost-budget" class="block text-sm text-[var(--text-secondary)] mb-1">
Session Cost Budget
</label>
<div class="flex items-center gap-2">
<span class="text-sm text-[var(--text-secondary)]">$</span>
<input
id="cost-budget"
type="number"
bind:value={config.session_cost_budget}
min="0"
step="0.50"
placeholder="e.g., 5.00"
class="flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
/>
<span class="text-xs text-[var(--text-tertiary)]">USD</span>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">Leave empty for unlimited spending</p>
</div>
<!-- Warning Threshold -->
<div class="mb-4">
<label for="warning-threshold" class="block text-sm text-[var(--text-secondary)] mb-2">
Warning Threshold
</label>
<div class="flex items-center gap-3">
<input
id="warning-threshold"
type="range"
bind:value={config.budget_warning_threshold}
min="0.5"
max="0.95"
step="0.05"
class="flex-1 h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
/>
<span class="text-sm text-[var(--text-secondary)] w-12 text-right">
{Math.round(config.budget_warning_threshold * 100)}%
</span>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">
Show warning when this percentage of budget is used
</p>
</div>
<!-- Budget Action -->
<div class="mb-4">
<span class="block text-sm text-[var(--text-secondary)] mb-2"
>When budget is exceeded</span
>
<div class="flex gap-2" role="group" aria-label="Budget action">
<button
onclick={() => (config.budget_action = "warn")}
class="flex-1 px-3 py-2 rounded-lg border transition-colors text-sm {config.budget_action ===
'warn'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
>
Warn Only
</button>
<button
onclick={() => (config.budget_action = "block")}
class="flex-1 px-3 py-2 rounded-lg border transition-colors text-sm {config.budget_action ===
'block'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
>
Block Input
</button>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-2">
{config.budget_action === "warn"
? "Show a warning but allow continued usage"
: "Prevent sending more messages until session is reset"}
</p>
</div>
{/if}
</section>
<!-- Cost History Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Cost History
</h3>
<CostSummary />
</section>
<!-- Notifications Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
@@ -823,6 +956,30 @@
</div>
</section>
<!-- Discord Rich Presence Section -->
<section class="pt-6 pb-6 border-t border-[var(--border-color)]">
<h3 class="text-lg font-semibold text-[var(--accent-primary)] mb-4 flex items-center gap-2">
<span>🎮</span>
<span>Discord Rich Presence</span>
</h3>
<!-- Enable/Disable Discord RPC -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.discord_rpc_enabled}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Show activity in Discord</span>
</label>
</div>
<div class="text-xs text-[var(--text-tertiary)]">
Display your current conversation session name and model in Discord when enabled.
</div>
</section>
<!-- Save Button -->
<div class="sticky bottom-0 pt-4 pb-2 bg-[var(--bg-secondary)]">
<button
+402
View File
@@ -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>
+330
View File
@@ -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>
+67 -6
View File
@@ -6,7 +6,7 @@
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import { handleNewUserMessage } from "$lib/notifications/rules";
import { setSkipNextGreeting } from "$lib/tauri";
import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri";
import { clipboardStore } from "$lib/stores/clipboard";
import {
setShouldRestoreHistory,
@@ -26,6 +26,8 @@
type SlashCommand,
} from "$lib/commands/slashCommands";
import { configStore, isStreamerMode } from "$lib/stores/config";
import { conversationsStore } from "$lib/stores/conversations";
import { stats, estimateMessageCost, formatTokenCount } from "$lib/stores/stats";
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
@@ -50,6 +52,13 @@
let showClipboardHistory = $state(false);
let streamerModeActive = $state(false);
// Cost estimation for pre-submission display
let costEstimate = $derived(
inputValue.trim()
? estimateMessageCost(inputValue, $stats.context_tokens_used, $stats.model)
: null
);
// Context menu state
let textareaElement: HTMLTextAreaElement | undefined = $state();
let contextMenuShow = $state(false);
@@ -329,19 +338,39 @@ User: ${formattedMessage}`;
throw new Error("No active conversation");
}
// Get current working directory before reconnecting
// Get current working directory and granted tools before reconnecting
const workingDir = await invoke<string>("get_working_directory", { conversationId });
const activeConversation = get(conversationsStore.activeConversation);
const grantedTools = activeConversation
? Array.from(activeConversation.grantedTools)
: [];
const config = configStore.getConfig();
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
// Set the flag to skip greeting on next connection
setSkipNextGreeting(true);
// Reconnect to Claude
// Reconnect to Claude with preserved permissions
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDir,
model: config.model || null,
api_key: config.api_key || null,
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools,
},
});
// Update Discord RPC when reconnecting
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
} catch (reconnectError) {
console.error("Failed to auto-reconnect:", reconnectError);
claudeStore.addLine("error", `Failed to reconnect: ${reconnectError}`);
@@ -427,11 +456,12 @@ User: ${formattedMessage}`;
try {
const arrayBuffer = await file.arrayBuffer();
const bytes = Array.from(new Uint8Array(arrayBuffer));
savedPath = await invoke<string>("save_temp_file", {
const result = await invoke<{ path: string; filename: string }>("save_temp_file", {
conversationId,
filename,
data: bytes,
});
savedPath = result.path;
} catch (error) {
console.error("Failed to save dropped file to temp:", error);
savedPath = file.name;
@@ -565,11 +595,12 @@ User: ${formattedMessage}`;
try {
const arrayBuffer = await file.arrayBuffer();
const bytes = Array.from(new Uint8Array(arrayBuffer));
savedPath = await invoke<string>("save_temp_file", {
const result = await invoke<{ path: string; filename: string }>("save_temp_file", {
conversationId,
filename,
data: bytes,
});
savedPath = result.path;
} catch (error) {
console.error("Failed to save pasted file to temp:", error);
}
@@ -627,11 +658,12 @@ User: ${formattedMessage}`;
try {
const arrayBuffer = await blob.arrayBuffer();
const bytes = Array.from(new Uint8Array(arrayBuffer));
savedPath = await invoke<string>("save_temp_file", {
const result = await invoke<{ path: string; filename: string }>("save_temp_file", {
conversationId,
filename,
data: bytes,
});
savedPath = result.path;
} catch (error) {
console.error("Failed to save clipboard image to temp:", error);
}
@@ -913,6 +945,13 @@ User: ${formattedMessage}`;
</div>
<div class="button-wrapper">
{#if costEstimate && isConnected && !isProcessing}
<div class="cost-estimate" title="Estimated input cost for this message">
<span class="cost-tokens">+{formatTokenCount(costEstimate.messageTokens)}</span>
<span class="cost-value">${costEstimate.estimatedCost.toFixed(4)}</span>
</div>
{/if}
<button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files">
<svg
width="20"
@@ -1138,6 +1177,28 @@ User: ${formattedMessage}`;
display: flex;
align-items: flex-end;
height: 100%;
gap: 8px;
}
.cost-estimate {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
padding: 0 8px;
font-size: 0.7rem;
color: var(--text-secondary);
min-width: 60px;
height: 48px;
}
.cost-tokens {
opacity: 0.8;
}
.cost-value {
font-family: var(--font-mono, monospace);
color: var(--accent-primary);
}
.attach-button {
+204 -95
View File
@@ -1,22 +1,24 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { claudeStore, hasPermissionPending } from "$lib/stores/claude";
import { SvelteSet } from "svelte/reactivity";
import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import type { PermissionRequest } from "$lib/types/messages";
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config";
let isVisible = $state(false);
let permission: PermissionRequest | null = $state(null);
let permissions: PermissionRequest[] = $state([]);
let selectedPermissions = new SvelteSet<string>();
let grantedToolsList: string[] = $state([]);
let workingDirectory = $state("");
hasPermissionPending.subscribe((pending) => {
isVisible = pending;
});
claudeStore.pendingPermission.subscribe((perm) => {
permission = perm;
if (perm) {
conversationsStore.pendingPermissions.subscribe((perms) => {
permissions = perms;
// When new permissions arrive, select all by default
if (perms.length > 0) {
selectedPermissions = new SvelteSet(perms.map((p) => p.id));
characterState.setState("permission");
}
});
@@ -30,66 +32,103 @@
});
async function handleApproveAndReconnect() {
if (permission) {
// Capture conversation history before clearing/reconnecting
const conversationHistory = claudeStore.getConversationHistory();
const approvedTool = permission.tool;
const toolInput = permission.input;
const selectedPerms = permissions.filter((p) => selectedPermissions.has(p.id));
claudeStore.grantTool(approvedTool);
const newGrantedTools = [...grantedToolsList, approvedTool];
claudeStore.addLine(
"system",
`Permission granted for: ${approvedTool}. Reconnecting with context...`
);
if (selectedPerms.length === 0) {
claudeStore.addLine("system", "No permissions selected to approve");
claudeStore.clearPermission();
characterState.setTemporaryState("idle", 1000);
return;
}
// Stop current session and reconnect with new permissions
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
// Capture conversation history before clearing/reconnecting
const conversationHistory = claudeStore.getConversationHistory();
await invoke("stop_claude", { conversationId });
// Grant all selected tools
const newlyGrantedTools: string[] = [];
for (const perm of selectedPerms) {
if (!grantedToolsList.includes(perm.tool)) {
claudeStore.grantTool(perm.tool);
newlyGrantedTools.push(perm.tool);
}
}
// Small delay to ensure clean shutdown
await new Promise((resolve) => setTimeout(resolve, 500));
const newGrantedTools = [...grantedToolsList, ...newlyGrantedTools];
const toolNames = selectedPerms.map((p) => p.tool).join(", ");
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDirectory || "/home/naomi",
allowed_tools: newGrantedTools,
},
});
claudeStore.addLine(
"system",
`Permission granted for ${selectedPerms.length} tool(s): ${toolNames}. Reconnecting with context...`
);
claudeStore.clearPermission();
// Wait for connection to establish
await new Promise((resolve) => setTimeout(resolve, 1000));
// Stop current session and reconnect with new permissions
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
// Send conversation context to restore state
if (conversationHistory) {
const contextMessage = `[CONTEXT RESTORATION]
I just granted you permission to use the ${approvedTool} tool. Here's our conversation so far:
// Prevent stats reset on reconnection
setSkipNextGreeting(true);
await invoke("stop_claude", { conversationId });
// Small delay to ensure clean shutdown
await new Promise((resolve) => setTimeout(resolve, 500));
const config = configStore.getConfig();
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDirectory || "/home/naomi",
model: config.model || null,
api_key: config.api_key || null,
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: newGrantedTools,
},
});
// Update Discord RPC when reconnecting after permission grant
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
// Wait for connection to establish
await new Promise((resolve) => setTimeout(resolve, 1000));
// Send conversation context to restore state
if (conversationHistory) {
const blockedActions = selectedPerms
.map((p) => `- ${p.tool} with input:\n${JSON.stringify(p.input, null, 2)}`)
.join("\n\n");
const contextMessage = `[CONTEXT RESTORATION]
I just granted you permission to use ${selectedPerms.length} tool(s): ${toolNames}. Here's our conversation so far:
${conversationHistory}
The last action that was blocked was: ${approvedTool} with input:
${JSON.stringify(toolInput, null, 2)}
The actions that were blocked:
${blockedActions}
Please continue where we left off and retry that action now that you have permission.`;
Please continue where we left off and retry those actions now that you have permission.`;
await invoke("send_prompt", {
conversationId,
message: contextMessage,
});
}
} catch (error) {
console.error("Failed to reconnect:", error);
claudeStore.addLine("error", `Reconnect failed: ${error}`);
await invoke("send_prompt", {
conversationId,
message: contextMessage,
});
}
characterState.setTemporaryState("success", 2000);
} catch (error) {
console.error("Failed to reconnect:", error);
claudeStore.addLine("error", `Reconnect failed: ${error}`);
}
characterState.setTemporaryState("success", 2000);
}
function handleDismiss() {
@@ -110,8 +149,24 @@ Please continue where we left off and retry that action now that you have permis
return grantedToolsList.includes(toolName);
}
function togglePermission(toolRequestId: string) {
if (selectedPermissions.has(toolRequestId)) {
selectedPermissions.delete(toolRequestId);
} else {
selectedPermissions.add(toolRequestId);
}
}
function selectAll() {
selectedPermissions = new SvelteSet(permissions.map((p) => p.id));
}
function selectNone() {
selectedPermissions = new SvelteSet();
}
function handleKeydown(event: KeyboardEvent) {
if (!isVisible || !permission) return;
if (permissions.length === 0) return;
if (event.key === "Enter") {
event.preventDefault();
@@ -125,72 +180,126 @@ Please continue where we left off and retry that action now that you have permis
<svelte:window onkeydown={handleKeydown} />
{#if isVisible && permission}
{#if permissions.length > 0}
<div
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-[60] backdrop-blur-sm"
>
<div
class="permission-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl"
class="permission-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-2xl w-full mx-4 shadow-2xl max-h-[90vh] overflow-y-auto"
>
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center">
<span class="text-xl">🔐</span>
</div>
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Permission Blocked</h2>
<p class="text-sm text-[var(--text-secondary)]">Hikari tried to use a restricted tool</p>
<div class="flex-1">
<h2 class="text-lg font-semibold text-[var(--text-primary)]">
{permissions.length === 1
? "Permission Required"
: `${permissions.length} Permissions Required`}
</h2>
<p class="text-sm text-[var(--text-secondary)]">
Hikari tried to use {permissions.length === 1
? "a restricted tool"
: "restricted tools"}
</p>
</div>
<div class="flex gap-2 text-xs">
<button
onclick={selectAll}
class="px-2 py-1 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded transition-colors"
>
Select All
</button>
<button
onclick={selectNone}
class="px-2 py-1 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded transition-colors"
>
Select None
</button>
</div>
</div>
<div class="mb-4 px-3 py-2 bg-amber-500/10 border border-amber-500/30 rounded-md">
<p class="text-sm text-amber-300">
This action was automatically blocked. Approve to allow this tool for future requests.
{permissions.length === 1
? "This action was automatically blocked. Select which permissions to grant."
: "These actions were automatically blocked. Select which permissions to grant."}
</p>
</div>
<div class="mb-4">
<div class="text-sm text-[var(--text-secondary)] mb-1">Tool</div>
<div
class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--accent-primary)] font-mono flex items-center justify-between"
>
<span>{permission.tool}</span>
{#if isToolAlreadyGranted(permission.tool)}
<span class="text-xs text-green-400 bg-green-500/20 px-2 py-0.5 rounded"
>Already Granted</span
>
{/if}
</div>
<div class="space-y-3 mb-6">
{#each permissions as perm (perm.id)}
<div
class="border border-[var(--border-color)] rounded-lg p-4 cursor-pointer transition-colors {selectedPermissions.has(
perm.id
)
? 'bg-green-500/10 border-green-500/30'
: 'bg-[var(--bg-secondary)] hover:bg-[var(--bg-secondary)]/80'}"
role="button"
tabindex="0"
onclick={() => togglePermission(perm.id)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
togglePermission(perm.id);
}
}}
>
<div class="flex items-start gap-3">
<div class="mt-1">
<input
type="checkbox"
checked={selectedPermissions.has(perm.id)}
onchange={() => togglePermission(perm.id)}
class="w-4 h-4 accent-green-500"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
<span class="text-[var(--accent-primary)] font-mono text-sm font-medium">
{perm.tool}
</span>
{#if isToolAlreadyGranted(perm.tool)}
<span class="text-xs text-green-400 bg-green-500/20 px-2 py-0.5 rounded">
Already Granted
</span>
{/if}
</div>
<div class="text-sm text-[var(--text-secondary)] mb-2">
{perm.description}
</div>
{#if Object.keys(perm.input).length > 0}
<details class="text-xs">
<summary
class="cursor-pointer text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
View details
</summary>
<pre
class="mt-2 px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] overflow-x-auto max-h-32">{formatInput(
perm.input
)}</pre>
</details>
{/if}
</div>
</div>
</div>
{/each}
</div>
<div class="mb-4">
<div class="text-sm text-[var(--text-secondary)] mb-1">Description</div>
<div class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--text-primary)]">
{permission.description}
</div>
</div>
{#if Object.keys(permission.input).length > 0}
<div class="mb-6">
<div class="text-sm text-[var(--text-secondary)] mb-1">Details</div>
<pre
class="px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] text-xs overflow-x-auto max-h-32">{formatInput(
permission.input
)}</pre>
</div>
{/if}
<div class="flex gap-3">
<button
onclick={handleDismiss}
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
>
Dismiss
Dismiss All
</button>
<button
onclick={handleApproveAndReconnect}
class="flex-1 px-4 py-2 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded-lg transition-colors font-medium"
disabled={selectedPermissions.size === 0}
class="flex-1 px-4 py-2 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Allow & Reconnect
Approve Selected ({selectedPermissions.size})
</button>
</div>
</div>
+526 -7
View File
@@ -1,8 +1,84 @@
<script lang="ts">
import { formattedStats } from "$lib/stores/stats";
import {
formattedStats,
contextWarning,
getContextWarningMessage,
stats,
checkBudget,
getBudgetStatusMessage,
getRemainingTokenBudget,
getRemainingCostBudget,
} from "$lib/stores/stats";
import { configStore } from "$lib/stores/config";
import { costTrackingStore, formattedCosts } from "$lib/stores/costTracking";
import { fade } from "svelte/transition";
import { onMount } from "svelte";
let showToolsBreakdown = false;
interface Props {
onRequestSummary?: () => void;
onStartFreshWithContext?: () => void;
isSummarising?: boolean;
}
let { onRequestSummary, onStartFreshWithContext, isSummarising = false }: Props = $props();
let showToolsBreakdown = $state(false);
let showHistoricalCosts = $state(false);
const historicalCosts = $derived($formattedCosts);
// Initialize cost tracking on mount
onMount(() => {
costTrackingStore.refresh();
});
// Subscribe to config store
const config = configStore.config;
const warning = $derived($contextWarning);
// Budget tracking - must be defined before showCompactionOptions
const budgetStatus = $derived(
checkBudget(
$stats,
$config.budget_enabled,
$config.session_token_budget,
$config.session_cost_budget,
$config.budget_warning_threshold
)
);
const budgetMessage = $derived(getBudgetStatusMessage(budgetStatus));
// Show compaction options when context or budget is at warning/critical levels
const showCompactionOptions = $derived(
warning === "high" ||
warning === "critical" ||
budgetStatus.type === "warning" ||
budgetStatus.type === "exceeded"
);
const remainingTokens = $derived(getRemainingTokenBudget($stats, $config.session_token_budget));
const remainingCost = $derived(getRemainingCostBudget($stats, $config.session_cost_budget));
// Calculate budget usage percentages for progress bars
const tokenBudgetPercent = $derived(() => {
const budget = $config.session_token_budget;
if (budget === null || budget === 0) return 0;
const used = $stats.session_input_tokens + $stats.session_output_tokens;
return Math.min(100, (used / budget) * 100);
});
const costBudgetPercent = $derived(() => {
const budget = $config.session_cost_budget;
if (budget === null || budget === 0) return 0;
return Math.min(100, ($stats.session_cost_usd / budget) * 100);
});
// Get the appropriate colour class for the progress bar
function getBudgetBarClass(percent: number, warningThreshold: number): string {
if (percent >= 100) return "budget-bar-exceeded";
if (percent >= warningThreshold * 100) return "budget-bar-warning";
return "budget-bar-ok";
}
</script>
<div class="stats-display" transition:fade={{ duration: 200 }}>
@@ -16,6 +92,120 @@
<span class="stat-value">{$formattedStats.messagesSession}</span>
</div>
<div class="stats-section">
<h3>Context Window</h3>
<div class="stat-row">
<span class="stat-label">Used:</span>
<span class="stat-value">{$formattedStats.contextUsed} / {$formattedStats.contextLimit}</span>
</div>
<div class="stat-row">
<span class="stat-label">Utilisation:</span>
<span class="stat-value context-util {warning ? `warning-${warning}` : ''}"
>{$formattedStats.contextUtilisation}</span
>
</div>
{#if warning}
<div class="context-warning warning-{warning}">
{getContextWarningMessage(warning)}
</div>
{/if}
{#if showCompactionOptions && (onRequestSummary || onStartFreshWithContext)}
<div class="compaction-actions">
{#if onRequestSummary}
<button
class="compaction-btn"
onclick={onRequestSummary}
disabled={isSummarising}
title="Compact conversation history to reduce context usage"
>
{#if isSummarising}
Compacting...
{:else}
Compact
{/if}
</button>
{/if}
{#if onStartFreshWithContext}
<button
class="compaction-btn compaction-btn-primary"
onclick={onStartFreshWithContext}
disabled={isSummarising}
title="Start a new conversation with context from this one"
>
Fresh Start
</button>
{/if}
</div>
{/if}
</div>
{#if $config.budget_enabled}
<div class="stats-section">
<h3>Budget</h3>
{#if $config.session_token_budget !== null}
<div class="budget-item">
<div class="stat-row">
<span class="stat-label">Tokens:</span>
<span
class="stat-value {budgetStatus.type !== 'ok' && budgetStatus.budget_type === 'token'
? `budget-${budgetStatus.type}`
: ''}"
>
{($stats.session_input_tokens + $stats.session_output_tokens).toLocaleString()} / {$config.session_token_budget.toLocaleString()}
</span>
</div>
<div class="budget-bar-container">
<div
class="budget-bar {getBudgetBarClass(
tokenBudgetPercent(),
$config.budget_warning_threshold
)}"
style="width: {tokenBudgetPercent()}%"
></div>
</div>
<div class="budget-remaining">
{remainingTokens?.toLocaleString() ?? 0} remaining ({(
100 - tokenBudgetPercent()
).toFixed(1)}%)
</div>
</div>
{/if}
{#if $config.session_cost_budget !== null}
<div class="budget-item">
<div class="stat-row">
<span class="stat-label">Cost:</span>
<span
class="stat-value {budgetStatus.type !== 'ok' && budgetStatus.budget_type === 'cost'
? `budget-${budgetStatus.type}`
: ''}"
>
${$stats.session_cost_usd.toFixed(4)} / ${$config.session_cost_budget.toFixed(2)}
</span>
</div>
<div class="budget-bar-container">
<div
class="budget-bar {getBudgetBarClass(
costBudgetPercent(),
$config.budget_warning_threshold
)}"
style="width: {costBudgetPercent()}%"
></div>
</div>
<div class="budget-remaining">
${remainingCost?.toFixed(4) ?? "0.0000"} remaining ({(
100 - costBudgetPercent()
).toFixed(1)}%)
</div>
</div>
{/if}
{#if budgetMessage}
<div class="budget-warning budget-{budgetStatus.type}">
{budgetMessage}
</div>
{/if}
</div>
{/if}
<div class="stats-section">
<h3>Tokens & Cost</h3>
<div class="stat-row">
@@ -49,7 +239,7 @@
</div>
</div>
{#if Object.keys($formattedStats.sessionToolsUsage).length > 0}
{#if $formattedStats.sessionToolsFormatted.length > 0}
<div class="stats-section">
<h3 class="tools-header">
<button class="tools-toggle" onclick={() => (showToolsBreakdown = !showToolsBreakdown)}>
@@ -59,17 +249,57 @@
</h3>
{#if showToolsBreakdown}
<div class="tools-breakdown">
{#each Object.entries($formattedStats.sessionToolsUsage).sort((a, b) => b[1] - a[1]) as [tool, count] (tool)}
<div class="stat-row stat-detail">
<span class="stat-label">{tool}:</span>
<span class="stat-value">{count}</span>
{#each $formattedStats.sessionToolsFormatted.sort((a, b) => b.totalTokens - a.totalTokens) as tool (tool.name)}
<div class="stat-row stat-detail tool-row">
<span class="stat-label">{tool.name}:</span>
<span class="stat-value tool-stats">
<span class="tool-calls">{tool.callCount} calls</span>
{#if tool.totalTokens > 0}
<span class="tool-tokens">(~{tool.formattedTokens})</span>
{/if}
</span>
</div>
{/each}
<div class="tools-note">* Token estimates based on attribution</div>
</div>
{/if}
</div>
{/if}
<!-- Historical Costs Section -->
<div class="stats-section">
<h3 class="costs-header">
<button class="costs-toggle" onclick={() => (showHistoricalCosts = !showHistoricalCosts)}>
Historical Costs
<span class="toggle-icon">{showHistoricalCosts ? "▼" : "▶"}</span>
</button>
</h3>
{#if !showHistoricalCosts}
<div class="costs-quick-stats">
<span class="cost-badge" title="Today's cost">Today: {historicalCosts.today}</span>
<span class="cost-badge" title="This week's cost">Week: {historicalCosts.week}</span>
<span class="cost-badge" title="This month's cost">Month: {historicalCosts.month}</span>
</div>
{/if}
{#if showHistoricalCosts}
<div class="historical-costs-expanded">
<div class="stat-row">
<span class="stat-label">Today:</span>
<span class="stat-value cost-value">{historicalCosts.today}</span>
</div>
<div class="stat-row">
<span class="stat-label">This Week:</span>
<span class="stat-value cost-value">{historicalCosts.week}</span>
</div>
<div class="stat-row">
<span class="stat-label">This Month:</span>
<span class="stat-value cost-value">{historicalCosts.month}</span>
</div>
<p class="costs-note">Open Settings to view detailed cost history and set alerts.</p>
</div>
{/if}
</div>
<div class="model-info">
<span class="model-label">Model:</span>
<span class="model-value">{$formattedStats.model}</span>
@@ -128,6 +358,79 @@
color: var(--text-primary, #e5e7eb);
}
.stat-cost {
font-family: var(--font-mono, monospace);
color: var(--accent-primary, #10b981);
font-size: 0.8rem;
margin-left: 0.5rem;
}
.stats-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.125rem 0;
}
.tools-header {
margin: 0 !important;
padding: 0 !important;
border: none !important;
}
.tools-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
color: var(--text-primary);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
padding: 0;
}
.tools-toggle:hover {
color: var(--accent-primary);
}
.toggle-icon {
font-size: 0.7rem;
opacity: 0.7;
}
.tools-breakdown {
margin-top: 0.25rem;
}
.tool-row {
flex-wrap: wrap;
}
.tool-stats {
display: flex;
gap: 0.5rem;
align-items: center;
}
.tool-calls {
color: var(--text-primary, #e5e7eb);
}
.tool-tokens {
color: var(--text-secondary, #9ca3af);
font-size: 0.75rem;
}
.tools-note {
margin-top: 0.5rem;
font-size: 0.65rem;
color: var(--text-secondary, #9ca3af);
font-style: italic;
opacity: 0.8;
}
.model-info {
display: flex;
justify-content: space-between;
@@ -148,4 +451,220 @@
color: var(--text-primary, #e5e7eb);
font-size: 0.75rem;
}
.context-util {
font-weight: 600;
}
.context-util.warning-moderate {
color: #f59e0b;
}
.context-util.warning-high {
color: #f97316;
}
.context-util.warning-critical {
color: #ef4444;
}
.context-warning {
margin-top: 0.5rem;
padding: 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
line-height: 1.3;
}
.context-warning.warning-moderate {
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
color: #fbbf24;
}
.context-warning.warning-high {
background: rgba(249, 115, 22, 0.15);
border: 1px solid rgba(249, 115, 22, 0.3);
color: #fb923c;
}
.context-warning.warning-critical {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #f87171;
}
/* Budget progress bar styles */
.budget-item {
margin-bottom: 0.75rem;
}
.budget-item:last-child {
margin-bottom: 0;
}
.budget-bar-container {
width: 100%;
height: 6px;
background: var(--bg-primary);
border-radius: 3px;
margin-top: 0.25rem;
overflow: hidden;
}
.budget-bar {
height: 100%;
border-radius: 3px;
transition:
width 0.3s ease,
background-color 0.3s ease;
}
.budget-bar-ok {
background: linear-gradient(90deg, #10b981, #34d399);
}
.budget-bar-warning {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
}
.budget-bar-exceeded {
background: linear-gradient(90deg, #ef4444, #f87171);
}
.budget-remaining {
font-size: 0.7rem;
color: var(--text-secondary);
margin-top: 0.125rem;
text-align: right;
}
/* Budget warning styles */
.budget-warning {
margin-top: 0.5rem;
padding: 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
line-height: 1.3;
}
.budget-warning.budget-warning {
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
color: #fbbf24;
}
.budget-warning.budget-exceeded {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #f87171;
}
.stat-value.budget-warning {
color: #f59e0b;
font-weight: 600;
}
.stat-value.budget-exceeded {
color: #ef4444;
font-weight: 600;
}
/* Compaction action buttons */
.compaction-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.compaction-btn {
flex: 1;
padding: 0.375rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 4px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.15s ease;
}
.compaction-btn:hover:not(:disabled) {
border-color: var(--accent-primary);
background: rgba(233, 69, 96, 0.1);
}
.compaction-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.compaction-btn-primary {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: white;
}
.compaction-btn-primary:hover:not(:disabled) {
background: var(--accent-secondary);
border-color: var(--accent-secondary);
}
/* Historical costs styles */
.costs-header {
margin: 0 !important;
padding: 0 !important;
border: none !important;
}
.costs-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
color: var(--text-primary);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
padding: 0;
}
.costs-toggle:hover {
color: var(--accent-primary);
}
.costs-quick-stats {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.cost-badge {
font-size: 0.7rem;
padding: 0.2rem 0.4rem;
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 3px;
color: #10b981;
font-family: var(--font-mono, monospace);
}
.historical-costs-expanded {
margin-top: 0.5rem;
}
.cost-value {
color: #10b981;
}
.costs-note {
margin: 0.5rem 0 0 0;
font-size: 0.65rem;
color: var(--text-secondary);
font-style: italic;
opacity: 0.8;
}
</style>
+186 -3
View File
@@ -21,9 +21,19 @@
import HelpPanel from "./HelpPanel.svelte";
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
import { achievementProgress } from "$lib/stores/achievements";
import { runningAgentCount } from "$lib/stores/agents";
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
import GitPanel from "./GitPanel.svelte";
import ProfilePanel from "./ProfilePanel.svelte";
import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
import { conversationsStore } from "$lib/stores/conversations";
import {
generateContextInjection,
createSummary,
sanitizeForJson,
} from "$lib/utils/conversationUtils";
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
import { debugConsoleStore } from "$lib/stores/debugConsole";
const DISCORD_URL = "https://chat.nhcarrigan.com";
const DONATE_URL = "https://donate.nhcarrigan.com";
@@ -41,7 +51,10 @@
let showSessionHistory = $state(false);
let showGitPanel = $state(false);
let showProfile = $state(false);
let showAgentMonitor = $state(false);
let isSummarising = $state(false);
const progress = $derived($achievementProgress);
const activeAgentCount = $derived($runningAgentCount);
let currentConfig: HikariConfig = $state({
model: null,
api_key: null,
@@ -57,7 +70,6 @@
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
minimize_to_tray: false,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
@@ -74,6 +86,12 @@
text_secondary: null,
border_color: null,
},
budget_enabled: false,
session_token_budget: null,
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
});
let streamerModeActive = $state(false);
@@ -153,6 +171,16 @@
allowed_tools: allAllowedTools,
},
});
// Update Discord RPC when a new session starts
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
currentConfig.model || "claude",
activeConversation.startedAt
);
}
} catch (error) {
console.error("Failed to start Claude:", error);
claudeStore.addLine("error", `Connection failed: ${error}`);
@@ -166,6 +194,9 @@
throw new Error("No active conversation");
}
await invoke("stop_claude", { conversationId });
// Clear granted permissions when user explicitly disconnects
claudeStore.revokeAllTools();
} catch (error) {
console.error("Failed to stop Claude:", error);
}
@@ -200,6 +231,109 @@
function toggleAchievements() {
onToggleAchievements();
}
async function handleCompactConversation() {
const activeId = get(conversationsStore.activeConversationId);
if (!activeId) return;
isSummarising = true;
try {
const conversationContent = conversationsStore.getConversationForSummary(activeId);
const messageCount =
get(conversationsStore.activeConversation)?.terminalLines.filter(
(l) => l.type === "user" || l.type === "assistant"
).length || 0;
const tokenEstimate = conversationsStore.estimateTokenCount(activeId);
// Create a summary from the conversation content (truncate if too long)
// Apply sanitization early to handle any problematic escape sequences
const sanitizedContent = sanitizeForJson(conversationContent);
const summaryContent =
sanitizedContent.length > 4000
? `${sanitizedContent.slice(0, 4000)}\n\n[Truncated for length - original had ${messageCount} messages]`
: sanitizedContent;
// Step 1: Disconnect from Claude to reset context
// Prevent stats reset on reconnection
setSkipNextGreeting(true);
if (connectionStatus === "connected") {
await invoke("stop_claude", { conversationId: activeId });
}
// Step 2: Clear messages and store summary
conversationsStore.compactWithSummary(activeId, summaryContent, messageCount, tokenEstimate);
// Step 3: Reconnect to Claude with fresh context
const allAllowedTools = [
...(currentConfig.auto_granted_tools || []),
...Array.from(get(claudeStore.grantedTools)),
];
await invoke("start_claude", {
conversationId: activeId,
options: {
working_dir: workingDirectory || selectedDirectory,
model: currentConfig.model || null,
api_key: currentConfig.api_key || null,
custom_instructions: currentConfig.custom_instructions || null,
mcp_servers_json: currentConfig.mcp_servers_json || null,
allowed_tools: allAllowedTools,
},
});
// Step 4: Send the context summary to Claude as the first message
const contextPrompt = generateContextInjection(
createSummary(summaryContent, messageCount, tokenEstimate)
);
await invoke("send_prompt", {
conversationId: activeId,
message: contextPrompt,
});
claudeStore.addLine(
"system",
"Conversation compacted. Context from previous session has been provided to Claude."
);
} catch (error) {
console.error("Failed to compact conversation:", error);
claudeStore.addLine("error", `Failed to compact conversation: ${error}`);
} finally {
isSummarising = false;
}
}
async function handleStartFreshWithContext() {
const activeId = get(conversationsStore.activeConversationId);
if (!activeId) return;
const conversationContent = conversationsStore.getConversationForSummary(activeId);
const messageCount =
get(conversationsStore.activeConversation)?.terminalLines.filter(
(l) => l.type === "user" || l.type === "assistant"
).length || 0;
const tokenEstimate = conversationsStore.estimateTokenCount(activeId);
const summary = createSummary(
`This is a continuation of a previous conversation. Here's what was discussed:\n\n${conversationContent.slice(0, 4000)}${conversationContent.length > 4000 ? "\n\n[Truncated for length...]" : ""}`,
messageCount,
tokenEstimate
);
const newConvId = conversationsStore.createConversation("Fresh Start");
conversationsStore.setSummary(newConvId, summary);
// Context injection is generated but the actual injection happens via the summary
generateContextInjection(summary);
claudeStore.addLine("system", "Started fresh conversation with context from previous session.");
claudeStore.addLine(
"system",
`Previous session had ${messageCount} messages (~${tokenEstimate.toLocaleString()} tokens).`
);
}
</script>
<div
@@ -336,6 +470,29 @@
/>
</svg>
</button>
<button
onclick={() => (showAgentMonitor = !showAgentMonitor)}
class="p-1 text-gray-500 icon-trans-hover relative {showAgentMonitor
? 'text-[var(--trans-pink)]'
: ''}"
title="Agent Monitor"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
{#if activeAgentCount > 0}
<span
class="absolute -top-1 -right-1 bg-blue-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px] animate-pulse"
>
{activeAgentCount}
</span>
{/if}
</button>
<button
onclick={() => (showStats = !showStats)}
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
@@ -350,6 +507,20 @@
/>
</svg>
</button>
<button
onclick={() => debugConsoleStore.toggle()}
class="p-1 text-gray-500 icon-trans-hover"
title="Debug Console (Ctrl+`)"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</button>
<button
onclick={configStore.openSidebar}
class="p-1 text-gray-500 icon-trans-hover"
@@ -446,7 +617,11 @@
{#if showStats}
<div class="absolute top-full right-0 mt-2 mr-4 z-50">
<StatsDisplay />
<StatsDisplay
onRequestSummary={handleCompactConversation}
onStartFreshWithContext={handleStartFreshWithContext}
{isSummarising}
/>
</div>
{/if}
{#if connectionStatus === "connected"}
@@ -473,7 +648,11 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div>
<div class="fixed top-14 right-4 z-50">
<StatsDisplay />
<StatsDisplay
onRequestSummary={handleCompactConversation}
onStartFreshWithContext={handleStartFreshWithContext}
{isSummarising}
/>
</div>
{/if}
@@ -500,3 +679,7 @@
{#if showProfile}
<ProfilePanel onClose={() => (showProfile = false)} />
{/if}
{#if showAgentMonitor}
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
{/if}
+41 -1
View File
@@ -209,8 +209,40 @@
</div>
{:else}
{#each lines as line (line.id)}
<div class="terminal-line mb-2 {getLineClass(line.type)} relative group">
<div
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
style={line.parentToolUseId
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
: ""}
>
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
{#if line.parentToolUseId}
<span class="text-xs mr-2 opacity-60" title="Message from subagent">
<svg
class="inline-block w-3 h-3 -mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</span>
{/if}
{#if line.cost && line.cost.costUsd > 0}
<span
class="terminal-cost text-xs mr-2"
title="Input: {line.cost.inputTokens} | Output: {line.cost.outputTokens}"
>
${line.cost.costUsd < 0.01
? line.cost.costUsd.toFixed(4)
: line.cost.costUsd.toFixed(3)}
</span>
{/if}
{#if getLinePrefix(line.type)}
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
{/if}
@@ -291,6 +323,14 @@
color: var(--text-tertiary, #6b7280);
}
.terminal-cost {
color: var(--terminal-cost, #10b981);
background: var(--terminal-cost-bg, rgba(16, 185, 129, 0.1));
padding: 0 4px;
border-radius: 3px;
font-family: monospace;
}
.terminal-prefix {
color: var(--text-secondary);
}
@@ -5,6 +5,9 @@
import { claudeStore, hasQuestionPending } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import type { UserQuestionEvent } from "$lib/types/messages";
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config";
let isVisible = $state(false);
let question: UserQuestionEvent | null = $state(null);
@@ -86,18 +89,36 @@
claudeStore.clearQuestion();
try {
// Prevent stats reset on reconnection
setSkipNextGreeting(true);
await invoke("stop_claude", { conversationId });
await new Promise((resolve) => setTimeout(resolve, 500));
const config = configStore.getConfig();
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDirectory || "/home/naomi",
model: config.model || null,
api_key: config.api_key || null,
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: grantedToolsList,
},
});
// Update Discord RPC when reconnecting after answering question
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
await new Promise((resolve) => setTimeout(resolve, 1000));
if (conversationHistory) {
@@ -90,6 +90,8 @@ class NotificationManager {
return "Successfully connected to Claude Code";
case NotificationType.TASK_START:
return "Starting task...";
case NotificationType.COST_ALERT:
return "You've exceeded your cost threshold!";
default:
return "Notification";
}
@@ -115,6 +117,10 @@ class NotificationManager {
async notifyTaskStart(message?: string): Promise<void> {
await this.notify(NotificationType.TASK_START, message);
}
async notifyCostAlert(message?: string): Promise<void> {
await this.notify(NotificationType.COST_ALERT, message);
}
}
// Export singleton instance
+9 -4
View File
@@ -51,9 +51,13 @@ describe("notifications", () => {
expect(NotificationType.ACHIEVEMENT).toBe("achievement");
});
it("has exactly 6 notification types", () => {
it("has exactly 7 notification types", () => {
const types = Object.values(NotificationType);
expect(types.length).toBe(6);
expect(types.length).toBe(7);
});
it("has COST_ALERT type", () => {
expect(NotificationType.COST_ALERT).toBe("cost_alert");
});
});
@@ -314,10 +318,11 @@ describe("notifications", () => {
});
});
it("sound filenames are unique", () => {
it("sound filenames are mostly unique", () => {
const filenames = Object.values(NOTIFICATION_SOUNDS).map((s) => s.filename);
const uniqueFilenames = new Set(filenames);
expect(uniqueFilenames.size).toBe(filenames.length);
// Allow some sound reuse (e.g., COST_ALERT reuses ERROR sound)
expect(uniqueFilenames.size).toBeGreaterThanOrEqual(filenames.length - 1);
});
it("phrases are unique", () => {
+7
View File
@@ -5,6 +5,7 @@ export enum NotificationType {
CONNECTION = "connection",
TASK_START = "task_start",
ACHIEVEMENT = "achievement",
COST_ALERT = "cost_alert",
}
export interface NotificationSound {
@@ -52,4 +53,10 @@ export const NOTIFICATION_SOUNDS: Record<NotificationType, NotificationSound> =
phrase: "Achievement Get~!",
volume: 0.8,
},
[NotificationType.COST_ALERT]: {
type: NotificationType.COST_ALERT,
filename: "oh-no.mp3",
phrase: "Cost Alert!",
volume: 0.9,
},
};
+121
View File
@@ -0,0 +1,121 @@
import { writable, derived } from "svelte/store";
import type { AgentInfo } from "$lib/types/agents";
// Map of conversation ID -> agents in that conversation
const agentsByConversation = writable<Record<string, AgentInfo[]>>({});
function createAgentStore() {
return {
subscribe: agentsByConversation.subscribe,
addAgent(conversationId: string, agent: AgentInfo) {
agentsByConversation.update((state) => {
const existing = state[conversationId] || [];
return {
...state,
[conversationId]: [...existing, agent],
};
});
},
updateAgentId(conversationId: string, toolUseId: string, agentId: string) {
agentsByConversation.update((state) => {
const agents = state[conversationId];
if (!agents) return state;
const agentIndex = agents.findIndex((a) => a.toolUseId === toolUseId);
if (agentIndex === -1) return state;
const updated = [...agents];
updated[agentIndex] = {
...updated[agentIndex],
agentId,
};
return {
...state,
[conversationId]: updated,
};
});
},
endAgent(conversationId: string, toolUseId: string, endedAt: number, isError: boolean) {
agentsByConversation.update((state) => {
const agents = state[conversationId];
if (!agents) return state;
const agentIndex = agents.findIndex((a) => a.toolUseId === toolUseId);
if (agentIndex === -1) return state;
const updated = [...agents];
const agent = updated[agentIndex];
const durationMs = endedAt - agent.startedAt;
updated[agentIndex] = {
...agent,
endedAt,
status: isError ? "errored" : "completed",
durationMs,
};
return {
...state,
[conversationId]: updated,
};
});
},
markAllErrored(conversationId: string) {
agentsByConversation.update((state) => {
const agents = state[conversationId];
if (!agents) return state;
const now = Date.now();
const updated = agents.map((agent) =>
agent.status === "running"
? { ...agent, endedAt: now, status: "errored" as const }
: agent
);
return {
...state,
[conversationId]: updated,
};
});
},
clearCompleted(conversationId: string) {
agentsByConversation.update((state) => {
const agents = state[conversationId];
if (!agents) return state;
return {
...state,
[conversationId]: agents.filter((a) => a.status === "running"),
};
});
},
clearConversation(conversationId: string) {
agentsByConversation.update((state) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Unused destructured value
const { [conversationId]: _, ...rest } = state;
return rest;
});
},
};
}
export const agentStore = createAgentStore();
export function getAgentsForConversation(conversationId: string) {
return derived(agentsByConversation, ($state) => $state[conversationId] || []);
}
export const runningAgentCount = derived(agentsByConversation, ($state) => {
let count = 0;
for (const agents of Object.values($state)) {
count += agents.filter((a) => a.status === "running").length;
}
return count;
});
+4 -1
View File
@@ -101,7 +101,10 @@ export const claudeStore = {
export const hasPermissionPending = derived(
claudeStore.activeConversation,
($conversation) => $conversation?.pendingPermission !== null
($conversation) =>
$conversation?.pendingPermissions !== null &&
$conversation?.pendingPermissions !== undefined &&
$conversation.pendingPermissions.length > 0
);
export const hasQuestionPending = derived(
+12 -2
View File
@@ -167,7 +167,6 @@ describe("config store", () => {
notifications_enabled: true,
notification_volume: 0.7,
always_on_top: false,
minimize_to_tray: true,
update_checks_enabled: true,
character_panel_width: 300,
font_size: 14,
@@ -187,6 +186,12 @@ describe("config store", () => {
text_secondary: null,
border_color: null,
},
budget_enabled: false,
session_token_budget: null,
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
};
expect(config.model).toBe("claude-sonnet-4");
@@ -207,7 +212,6 @@ describe("config store", () => {
notifications_enabled: true,
notification_volume: 0.7,
always_on_top: false,
minimize_to_tray: false,
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
@@ -227,6 +231,12 @@ describe("config store", () => {
text_secondary: null,
border_color: null,
},
budget_enabled: false,
session_token_budget: null,
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
};
expect(config.model).toBeNull();
+33 -23
View File
@@ -2,6 +2,7 @@ import { writable, derived } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
export type Theme = "dark" | "light" | "high-contrast" | "custom";
export type BudgetAction = "warn" | "block";
export interface CustomThemeColors {
bg_primary: string | null;
@@ -26,7 +27,6 @@ export interface HikariConfig {
notifications_enabled: boolean;
notification_volume: number;
always_on_top: boolean;
minimize_to_tray: boolean;
update_checks_enabled: boolean;
character_panel_width: number | null;
font_size: number;
@@ -37,6 +37,14 @@ export interface HikariConfig {
profile_avatar_path: string | null;
profile_bio: string | null;
custom_theme_colors: CustomThemeColors;
// Budget settings
budget_enabled: boolean;
session_token_budget: number | null;
session_cost_budget: number | null;
budget_action: BudgetAction;
budget_warning_threshold: number;
// Discord RPC settings
discord_rpc_enabled: boolean;
}
const defaultConfig: HikariConfig = {
@@ -51,7 +59,6 @@ const defaultConfig: HikariConfig = {
notifications_enabled: true,
notification_volume: 0.7,
always_on_top: false,
minimize_to_tray: false,
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
@@ -71,6 +78,12 @@ const defaultConfig: HikariConfig = {
text_secondary: null,
border_color: null,
},
budget_enabled: false,
session_token_budget: null,
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
};
function createConfigStore() {
@@ -79,6 +92,14 @@ function createConfigStore() {
const isSidebarOpen = writable<boolean>(false);
const saveError = writable<string | null>(null);
// Internal function to get current config synchronously
function getCurrentConfig(): HikariConfig {
let currentConfig: HikariConfig = defaultConfig;
const unsubscribe = config.subscribe((c) => (currentConfig = c));
unsubscribe();
return currentConfig;
}
async function loadConfig() {
isLoading.set(true);
try {
@@ -106,8 +127,7 @@ function createConfigStore() {
}
async function updateConfig(updates: Partial<HikariConfig>) {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const currentConfig = getCurrentConfig();
const newConfig = { ...currentConfig, ...updates };
await saveConfig(newConfig);
}
@@ -132,15 +152,13 @@ function createConfigStore() {
updates.custom_theme_colors = customColors;
}
await updateConfig(updates);
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const currentConfig = getCurrentConfig();
applyTheme(theme, currentConfig.custom_theme_colors);
},
setCustomThemeColors: async (colors: CustomThemeColors) => {
await updateConfig({ custom_theme_colors: colors });
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const currentConfig = getCurrentConfig();
if (currentConfig.theme === "custom") {
applyCustomThemeColors(colors);
}
@@ -153,16 +171,14 @@ function createConfigStore() {
},
increaseFontSize: async () => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const currentConfig = getCurrentConfig();
const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2);
await updateConfig({ font_size: newSize });
applyFontSize(newSize);
},
decreaseFontSize: async () => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const currentConfig = getCurrentConfig();
const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2);
await updateConfig({ font_size: newSize });
applyFontSize(newSize);
@@ -174,8 +190,7 @@ function createConfigStore() {
},
addAutoGrantedTool: async (tool: string) => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const currentConfig = getCurrentConfig();
if (!currentConfig.auto_granted_tools.includes(tool)) {
const newTools = [...currentConfig.auto_granted_tools, tool];
await updateConfig({ auto_granted_tools: newTools });
@@ -183,27 +198,22 @@ function createConfigStore() {
},
removeAutoGrantedTool: async (tool: string) => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const currentConfig = getCurrentConfig();
const newTools = currentConfig.auto_granted_tools.filter((t) => t !== tool);
await updateConfig({ auto_granted_tools: newTools });
},
getConfig: (): HikariConfig => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
return currentConfig;
return getCurrentConfig();
},
toggleStreamerMode: async () => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const currentConfig = getCurrentConfig();
await updateConfig({ streamer_mode: !currentConfig.streamer_mode });
},
toggleCompactMode: async () => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const currentConfig = getCurrentConfig();
await updateConfig({ compact_mode: !currentConfig.compact_mode });
},
+186 -12
View File
@@ -8,10 +8,16 @@ import type {
} from "$lib/types/messages";
import type { CharacterState } from "$lib/types/states";
import { cleanupConversationTracking } from "$lib/tauri";
import { normalizePath } from "$lib/utils/paths";
import { characterState } from "$lib/stores/character";
import { sessionsStore } from "$lib/stores/sessions";
export interface ConversationSummary {
generatedAt: Date;
content: string;
messageCount: number;
tokenEstimate: number;
}
export interface Conversation {
id: string;
name: string;
@@ -22,12 +28,14 @@ export interface Conversation {
characterState: CharacterState;
isProcessing: boolean;
grantedTools: Set<string>;
pendingPermission: PermissionRequest | null;
pendingPermissions: PermissionRequest[];
pendingQuestion: UserQuestionEvent | null;
scrollPosition: number;
createdAt: Date;
lastActivityAt: Date;
attachments: Attachment[];
summary: ConversationSummary | null;
startedAt: Date;
}
function createConversationsStore() {
@@ -58,12 +66,14 @@ function createConversationsStore() {
characterState: "idle",
isProcessing: false,
grantedTools: new Set(),
pendingPermission: null,
pendingPermissions: [],
pendingQuestion: null,
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
createdAt: new Date(),
lastActivityAt: new Date(),
attachments: [],
summary: null,
startedAt: new Date(),
};
}
@@ -110,7 +120,11 @@ function createConversationsStore() {
);
const pendingPermission = derived(
activeConversation,
($conv) => $conv?.pendingPermission || null
($conv) => $conv?.pendingPermissions[0] || null
);
const pendingPermissions = derived(
activeConversation,
($conv) => $conv?.pendingPermissions || []
);
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
@@ -123,6 +137,7 @@ function createConversationsStore() {
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
terminalLines: { subscribe: terminalLines.subscribe },
pendingPermission: { subscribe: pendingPermission.subscribe },
pendingPermissions: { subscribe: pendingPermissions.subscribe },
pendingQuestion: { subscribe: pendingQuestion.subscribe },
isProcessing: { subscribe: isProcessing.subscribe },
grantedTools: { subscribe: grantedTools.subscribe },
@@ -180,7 +195,7 @@ function createConversationsStore() {
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingPermission = request;
conv.pendingPermissions.push(request);
conv.lastActivityAt = new Date();
}
return convs;
@@ -193,7 +208,7 @@ function createConversationsStore() {
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingPermission = null;
conv.pendingPermissions = [];
conv.lastActivityAt = new Date();
}
return convs;
@@ -203,7 +218,7 @@ function createConversationsStore() {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.pendingPermission = request;
conv.pendingPermissions.push(request);
conv.lastActivityAt = new Date();
}
return convs;
@@ -213,7 +228,30 @@ function createConversationsStore() {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.pendingPermission = null;
conv.pendingPermissions = [];
conv.lastActivityAt = new Date();
}
return convs;
});
},
removePermission: (id: string) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingPermissions = conv.pendingPermissions.filter((p) => p.id !== id);
conv.lastActivityAt = new Date();
}
return convs;
});
},
removePermissionForConversation: (conversationId: string, id: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.pendingPermissions = conv.pendingPermissions.filter((p) => p.id !== id);
conv.lastActivityAt = new Date();
}
return convs;
@@ -389,7 +427,7 @@ function createConversationsStore() {
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.workingDirectory = normalizePath(dir);
conv.workingDirectory = dir;
conv.lastActivityAt = new Date();
}
return convs;
@@ -400,7 +438,7 @@ function createConversationsStore() {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.workingDirectory = normalizePath(dir);
conv.workingDirectory = dir;
conv.lastActivityAt = new Date();
}
return convs;
@@ -421,7 +459,13 @@ function createConversationsStore() {
});
},
addLine: (type: TerminalLine["type"], content: string, toolName?: string) => {
addLine: (
type: TerminalLine["type"],
content: string,
toolName?: string,
cost?: TerminalLine["cost"],
parentToolUseId?: string
) => {
ensureInitialized();
const activeId = get(activeConversationId);
if (!activeId) return "";
@@ -432,6 +476,8 @@ function createConversationsStore() {
content,
timestamp: new Date(),
toolName,
cost,
parentToolUseId,
};
conversations.update((convs) => {
@@ -452,7 +498,9 @@ function createConversationsStore() {
conversationId: string,
type: TerminalLine["type"],
content: string,
toolName?: string
toolName?: string,
cost?: TerminalLine["cost"],
parentToolUseId?: string
) => {
ensureInitialized();
@@ -462,6 +510,8 @@ function createConversationsStore() {
content,
timestamp: new Date(),
toolName,
cost,
parentToolUseId,
};
conversations.update((convs) => {
@@ -637,6 +687,130 @@ function createConversationsStore() {
return conv?.attachments || [];
},
// Summary/compaction functions
setSummary: (conversationId: string, summary: ConversationSummary) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.summary = summary;
conv.lastActivityAt = new Date();
}
return convs;
});
},
clearSummary: (conversationId: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.summary = null;
conv.lastActivityAt = new Date();
}
return convs;
});
},
getSummary: (conversationId: string): ConversationSummary | null => {
const convs = get(conversations);
const conv = convs.get(conversationId);
return conv?.summary || null;
},
// Estimate token count for a conversation (rough approximation: ~4 chars per token)
estimateTokenCount: (conversationId: string): number => {
const convs = get(conversations);
const conv = convs.get(conversationId);
if (!conv) return 0;
const relevantLines = conv.terminalLines.filter(
(line) => line.type === "user" || line.type === "assistant"
);
const totalChars = relevantLines.reduce((sum, line) => sum + line.content.length, 0);
return Math.ceil(totalChars / 4);
},
// Get conversation content suitable for summarisation
getConversationForSummary: (conversationId: string): string => {
const convs = get(conversations);
const conv = convs.get(conversationId);
if (!conv) return "";
const relevantLines = conv.terminalLines.filter(
(line) => line.type === "user" || line.type === "assistant"
);
return relevantLines
.map((line) => {
const role = line.type === "user" ? "User" : "Assistant";
return `${role}: ${line.content}`;
})
.join("\n\n");
},
// Compact conversation by keeping only recent messages
compactConversation: (conversationId: string, keepRecentCount: number = 10) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv && conv.terminalLines.length > keepRecentCount) {
// Keep system messages and the most recent user/assistant messages
const systemLines = conv.terminalLines.filter(
(line) => line.type !== "user" && line.type !== "assistant"
);
const chatLines = conv.terminalLines.filter(
(line) => line.type === "user" || line.type === "assistant"
);
// Keep only the most recent chat messages
const recentChatLines = chatLines.slice(-keepRecentCount);
// Combine: system lines at original positions + recent chat lines
conv.terminalLines = [...systemLines.slice(-5), ...recentChatLines];
conv.lastActivityAt = new Date();
}
return convs;
});
},
// Compact conversation with a summary - clears old messages and injects summary context
compactWithSummary: (
conversationId: string,
summaryContent: string,
messageCount: number,
tokenEstimate: number
) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
// Store the summary
conv.summary = {
generatedAt: new Date(),
content: summaryContent,
messageCount,
tokenEstimate,
};
// Clear all messages and add a context injection message
conv.terminalLines = [
{
id: generateLineId(),
type: "system",
content: `[Conversation compacted] Previous session had ${messageCount} messages (~${tokenEstimate.toLocaleString()} tokens). Context preserved below.`,
timestamp: new Date(),
},
{
id: generateLineId(),
type: "system",
content: `Previous Session Context:\n${summaryContent}`,
timestamp: new Date(),
},
];
conv.lastActivityAt = new Date();
}
return convs;
});
},
// Add initialization helper
initialize: () => {
ensureInitialized();
+182
View File
@@ -0,0 +1,182 @@
import { writable, derived } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { notificationManager } from "$lib/notifications/notificationManager";
// Types matching Rust backend
export interface DailyCost {
date: string;
input_tokens: number;
output_tokens: number;
cost_usd: number;
messages_sent: number;
sessions_count: number;
}
export interface CostSummary {
period_days: number;
total_input_tokens: number;
total_output_tokens: number;
total_cost: number;
total_messages: number;
total_sessions: number;
average_daily_cost: number;
daily_breakdown: DailyCost[];
}
export type AlertType = "Daily" | "Weekly" | "Monthly";
export interface CostAlert {
alert_type: AlertType;
threshold: number;
current_cost: number;
}
export interface CostAlertThresholds {
daily: number | null;
weekly: number | null;
monthly: number | null;
}
// Store state
interface CostTrackingState {
todayCost: number;
weekCost: number;
monthCost: number;
summary: CostSummary | null;
alerts: CostAlert[];
thresholds: CostAlertThresholds;
isLoading: boolean;
lastUpdated: Date | null;
}
const defaultState: CostTrackingState = {
todayCost: 0,
weekCost: 0,
monthCost: 0,
summary: null,
alerts: [],
thresholds: { daily: null, weekly: null, monthly: null },
isLoading: false,
lastUpdated: null,
};
function createCostTrackingStore() {
const { subscribe, set, update } = writable<CostTrackingState>(defaultState);
return {
subscribe,
async refresh() {
update((s) => ({ ...s, isLoading: true }));
try {
const [todayCost, weekCost, monthCost, alerts] = await Promise.all([
invoke<number>("get_today_cost"),
invoke<number>("get_week_cost"),
invoke<number>("get_month_cost"),
invoke<CostAlert[]>("get_cost_alerts"),
]);
update((s) => ({
...s,
todayCost,
weekCost,
monthCost,
alerts,
isLoading: false,
lastUpdated: new Date(),
}));
// Trigger notifications for any new alerts
if (alerts.length > 0) {
for (const alert of alerts) {
const message = getAlertMessage(alert);
notificationManager.notifyCostAlert(message);
}
}
return alerts;
} catch (error) {
console.error("Failed to refresh cost tracking:", error);
update((s) => ({ ...s, isLoading: false }));
return [];
}
},
async getSummary(days: number): Promise<CostSummary | null> {
try {
const summary = await invoke<CostSummary>("get_cost_summary", { days });
update((s) => ({ ...s, summary }));
return summary;
} catch (error) {
console.error("Failed to get cost summary:", error);
return null;
}
},
async setAlertThresholds(thresholds: CostAlertThresholds) {
try {
await invoke("set_cost_alert_thresholds", {
daily: thresholds.daily,
weekly: thresholds.weekly,
monthly: thresholds.monthly,
});
update((s) => ({ ...s, thresholds }));
} catch (error) {
console.error("Failed to set alert thresholds:", error);
}
},
async exportCsv(days: number): Promise<string | null> {
try {
return await invoke<string>("export_cost_csv", { days });
} catch (error) {
console.error("Failed to export CSV:", error);
return null;
}
},
reset() {
set(defaultState);
},
};
}
export const costTrackingStore = createCostTrackingStore();
// Derived stores for formatted values
export const formattedCosts = derived(costTrackingStore, ($store) => ({
today: formatCost($store.todayCost),
week: formatCost($store.weekCost),
month: formatCost($store.monthCost),
todayRaw: $store.todayCost,
weekRaw: $store.weekCost,
monthRaw: $store.monthCost,
}));
// Helper functions
export function formatCost(cost: number): string {
if (cost < 0.01) {
return `$${cost.toFixed(4)}`;
}
if (cost < 1) {
return `$${cost.toFixed(3)}`;
}
return `$${cost.toFixed(2)}`;
}
export function formatAlertType(type: AlertType): string {
switch (type) {
case "Daily":
return "Today";
case "Weekly":
return "This Week";
case "Monthly":
return "This Month";
}
}
export function getAlertMessage(alert: CostAlert): string {
const period = formatAlertType(alert.alert_type);
return `${period}'s spending (${formatCost(alert.current_cost)}) has exceeded your ${formatCost(alert.threshold)} threshold`;
}
+140
View File
@@ -0,0 +1,140 @@
import { writable, derived } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
export type LogLevel = "debug" | "info" | "warn" | "error";
export interface LogEntry {
id: string;
timestamp: Date;
level: LogLevel;
message: string;
source: "frontend" | "backend";
}
interface DebugConsoleState {
logs: LogEntry[];
isOpen: boolean;
maxLogs: number;
filterLevel: LogLevel | "all";
autoScroll: boolean;
}
const MAX_LOGS = 1000; // Circular buffer size
function createDebugConsoleStore() {
const { subscribe, update } = writable<DebugConsoleState>({
logs: [],
isOpen: false,
maxLogs: MAX_LOGS,
filterLevel: "all",
autoScroll: true,
});
let logCounter = 0;
function addLog(level: LogLevel, message: string, source: "frontend" | "backend") {
update((state) => {
const newLog: LogEntry = {
id: `log-${Date.now()}-${logCounter++}`,
timestamp: new Date(),
level,
message,
source,
};
const updatedLogs = [...state.logs, newLog];
// Implement circular buffer - remove oldest if exceeding max
if (updatedLogs.length > state.maxLogs) {
updatedLogs.shift();
}
return { ...state, logs: updatedLogs };
});
}
// Override console methods to capture frontend logs
const originalConsole = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
debug: console.debug,
};
function setupConsoleCapture() {
console.log = (...args: unknown[]) => {
originalConsole.log(...args);
addLog("info", args.map((arg) => String(arg)).join(" "), "frontend");
};
console.info = (...args: unknown[]) => {
originalConsole.info(...args);
addLog("info", args.map((arg) => String(arg)).join(" "), "frontend");
};
console.warn = (...args: unknown[]) => {
originalConsole.warn(...args);
addLog("warn", args.map((arg) => String(arg)).join(" "), "frontend");
};
console.error = (...args: unknown[]) => {
originalConsole.error(...args);
addLog("error", args.map((arg) => String(arg)).join(" "), "frontend");
};
console.debug = (...args: unknown[]) => {
originalConsole.debug(...args);
addLog("debug", args.map((arg) => String(arg)).join(" "), "frontend");
};
}
function restoreConsole() {
console.log = originalConsole.log;
console.info = originalConsole.info;
console.warn = originalConsole.warn;
console.error = originalConsole.error;
console.debug = originalConsole.debug;
}
// Listen for backend logs
async function setupBackendLogsListener() {
await listen<{ level: LogLevel; message: string }>("debug:log", (event) => {
addLog(event.payload.level, event.payload.message, "backend");
});
}
return {
subscribe,
toggle: () => update((state) => ({ ...state, isOpen: !state.isOpen })),
open: () => update((state) => ({ ...state, isOpen: true })),
close: () => update((state) => ({ ...state, isOpen: false })),
clear: () => update((state) => ({ ...state, logs: [] })),
setFilterLevel: (level: LogLevel | "all") =>
update((state) => ({ ...state, filterLevel: level })),
setAutoScroll: (enabled: boolean) => update((state) => ({ ...state, autoScroll: enabled })),
setupConsoleCapture,
restoreConsole,
setupBackendLogsListener,
};
}
export const debugConsoleStore = createDebugConsoleStore();
// Derived store for filtered logs
export const filteredLogs = derived(debugConsoleStore, ($debugConsole) => {
if ($debugConsole.filterLevel === "all") {
return $debugConsole.logs;
}
const levelPriority: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
const minPriority = levelPriority[$debugConsole.filterLevel];
return $debugConsole.logs.filter((log) => levelPriority[log.level] >= minPriority);
});
+11 -15
View File
@@ -1,7 +1,6 @@
import { writable, derived, get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import type { EditorState, EditorTab, FileEntry } from "$lib/types/editor";
import { normalizePath, joinPath, getParentPath, getFilename } from "$lib/utils/paths";
const defaultState: EditorState = {
tabs: [],
@@ -159,7 +158,7 @@ function createEditorStore() {
try {
const content = await invoke<string>("read_file_content", { path: filePath });
const fileName = getFilename(filePath) || "untitled";
const fileName = filePath.split(/[/\\]/).pop() || "untitled";
const language = getLanguageFromPath(filePath);
const newTab: EditorTab = {
id: generateTabId(),
@@ -248,7 +247,7 @@ function createEditorStore() {
}
async function createFile(parentPath: string, fileName: string): Promise<boolean> {
const filePath = joinPath(parentPath, fileName);
const filePath = `${parentPath}/${fileName}`;
try {
await invoke("create_file", { path: filePath });
// Refresh the parent directory
@@ -262,7 +261,7 @@ function createEditorStore() {
}
async function createDirectory(parentPath: string, dirName: string): Promise<boolean> {
const dirPath = joinPath(parentPath, dirName);
const dirPath = `${parentPath}/${dirName}`;
try {
await invoke("create_directory", { path: dirPath });
// Refresh the parent directory
@@ -285,7 +284,7 @@ function createEditorStore() {
closeTab(openTab.id);
}
// Refresh the parent directory
const parentPath = getParentPath(filePath);
const parentPath = filePath.substring(0, filePath.lastIndexOf("/"));
await refreshDirectory(parentPath);
return true;
} catch (error) {
@@ -300,11 +299,10 @@ function createEditorStore() {
await invoke("delete_directory", { path: dirPath });
// Close any tabs that are in this directory
const currentState = get(state);
const normalizedDirPath = normalizePath(dirPath);
const tabsToClose = currentState.tabs.filter((t) => normalizePath(t.filePath).startsWith(normalizedDirPath + "/"));
const tabsToClose = currentState.tabs.filter((t) => t.filePath.startsWith(dirPath + "/"));
tabsToClose.forEach((tab) => closeTab(tab.id));
// Refresh the parent directory
const parentPath = getParentPath(dirPath);
const parentPath = dirPath.substring(0, dirPath.lastIndexOf("/"));
await refreshDirectory(parentPath);
return true;
} catch (error) {
@@ -343,8 +341,8 @@ function createEditorStore() {
}
async function renamePath(oldPath: string, newName: string): Promise<boolean> {
const parentPath = getParentPath(oldPath);
const newPath = joinPath(parentPath, newName);
const parentPath = oldPath.substring(0, oldPath.lastIndexOf("/"));
const newPath = `${parentPath}/${newName}`;
try {
await invoke("rename_path", { oldPath, newPath });
@@ -361,14 +359,12 @@ function createEditorStore() {
fileName: newName,
};
}
const normalizedOldPath = normalizePath(oldPath);
const normalizedFilePath = normalizePath(t.filePath);
if (normalizedFilePath.startsWith(normalizedOldPath + "/")) {
if (t.filePath.startsWith(oldPath + "/")) {
// File is inside a renamed directory
const relativePath = normalizedFilePath.substring(normalizedOldPath.length);
const relativePath = t.filePath.substring(oldPath.length);
return {
...t,
filePath: normalizePath(newPath + relativePath),
filePath: newPath + relativePath,
};
}
return t;
+269 -12
View File
@@ -1,7 +1,25 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { get } from "svelte/store";
import { stats, formattedStats, resetSessionStats } from "./stats";
import type { UsageStats } from "./stats";
import {
stats,
formattedStats,
resetSessionStats,
contextWarning,
getContextWarningMessage,
estimateMessageCost,
formatTokenCount,
MODEL_PRICING,
} from "./stats";
import type { UsageStats, ToolTokenStats } from "./stats";
// Helper function to create ToolTokenStats for tests
function toolStats(callCount: number, inputTokens = 0, outputTokens = 0): ToolTokenStats {
return {
call_count: callCount,
estimated_input_tokens: inputTokens,
estimated_output_tokens: outputTokens,
};
}
// Mock Tauri APIs
vi.mock("@tauri-apps/api/event", () => ({
@@ -34,6 +52,11 @@ describe("stats store", () => {
tools_usage: {},
session_tools_usage: {},
session_duration_seconds: 0,
context_tokens_used: 0,
context_window_limit: 200000,
context_utilisation_percent: 0,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
});
});
@@ -63,9 +86,14 @@ describe("stats store", () => {
session_files_edited: 2,
files_created: 1,
session_files_created: 1,
tools_usage: { Read: 5, Edit: 3 },
session_tools_usage: { Read: 2, Edit: 1 },
tools_usage: { Read: toolStats(5), Edit: toolStats(3) },
session_tools_usage: { Read: toolStats(2), Edit: toolStats(1) },
session_duration_seconds: 300,
context_tokens_used: 500,
context_window_limit: 200000,
context_utilisation_percent: 0.25,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
};
stats.set(newStats);
@@ -74,7 +102,8 @@ describe("stats store", () => {
expect(currentStats.total_input_tokens).toBe(1000);
expect(currentStats.total_output_tokens).toBe(2000);
expect(currentStats.model).toBe("claude-sonnet-4");
expect(currentStats.tools_usage).toEqual({ Read: 5, Edit: 3 });
expect(currentStats.tools_usage.Read?.call_count).toBe(5);
expect(currentStats.tools_usage.Edit?.call_count).toBe(3);
});
it("can be updated with update function", () => {
@@ -109,9 +138,14 @@ describe("stats store", () => {
session_files_edited: 2,
files_created: 1,
session_files_created: 1,
tools_usage: { Read: 5, Edit: 3 },
session_tools_usage: { Read: 2, Edit: 1 },
tools_usage: { Read: toolStats(5), Edit: toolStats(3) },
session_tools_usage: { Read: toolStats(2), Edit: toolStats(1) },
session_duration_seconds: 300,
context_tokens_used: 500,
context_window_limit: 200000,
context_utilisation_percent: 0.25,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
});
// Reset session stats
@@ -127,7 +161,8 @@ describe("stats store", () => {
expect(currentStats.code_blocks_generated).toBe(3);
expect(currentStats.files_edited).toBe(5);
expect(currentStats.files_created).toBe(1);
expect(currentStats.tools_usage).toEqual({ Read: 5, Edit: 3 });
expect(currentStats.tools_usage.Read?.call_count).toBe(5);
expect(currentStats.tools_usage.Edit?.call_count).toBe(3);
expect(currentStats.model).toBe("claude-sonnet-4");
// Session stats should be reset
@@ -277,8 +312,8 @@ describe("stats store", () => {
});
it("exposes tools usage directly", () => {
const toolsUsage = { Read: 10, Edit: 5, Write: 3 };
const sessionToolsUsage = { Read: 2, Edit: 1 };
const toolsUsage = { Read: toolStats(10), Edit: toolStats(5), Write: toolStats(3) };
const sessionToolsUsage = { Read: toolStats(2), Edit: toolStats(1) };
stats.update((current) => ({
...current,
@@ -331,9 +366,14 @@ describe("stats store", () => {
session_files_edited: 1,
files_created: 1,
session_files_created: 0,
tools_usage: { Read: 3 },
session_tools_usage: { Read: 1 },
tools_usage: { Read: toolStats(3) },
session_tools_usage: { Read: toolStats(1) },
session_duration_seconds: 60,
context_tokens_used: 50,
context_window_limit: 200000,
context_utilisation_percent: 0.025,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
};
stats.set(fullStats);
@@ -343,4 +383,221 @@ describe("stats store", () => {
expect(currentStats).toEqual(fullStats);
});
});
describe("context window tracking", () => {
it("tracks context tokens used", () => {
stats.update((current) => ({
...current,
context_tokens_used: 100000,
context_window_limit: 200000,
context_utilisation_percent: 50.0,
}));
const currentStats = get(stats);
expect(currentStats.context_tokens_used).toBe(100000);
expect(currentStats.context_window_limit).toBe(200000);
expect(currentStats.context_utilisation_percent).toBe(50.0);
});
it("formats context stats correctly", () => {
stats.update((current) => ({
...current,
context_tokens_used: 150000,
context_window_limit: 200000,
context_utilisation_percent: 75.5,
}));
const formatted = get(formattedStats);
expect(formatted.contextUsed).toBe("150,000");
expect(formatted.contextLimit).toBe("200,000");
expect(formatted.contextRemaining).toBe("50,000");
expect(formatted.contextUtilisation).toBe("75.5%");
});
it("calculates remaining tokens correctly at limit", () => {
stats.update((current) => ({
...current,
context_tokens_used: 200000,
context_window_limit: 200000,
context_utilisation_percent: 100.0,
}));
const formatted = get(formattedStats);
expect(formatted.contextRemaining).toBe("0");
});
it("handles over-limit gracefully", () => {
stats.update((current) => ({
...current,
context_tokens_used: 250000,
context_window_limit: 200000,
context_utilisation_percent: 125.0,
}));
const formatted = get(formattedStats);
expect(formatted.contextRemaining).toBe("0");
});
});
describe("contextWarning derived store", () => {
it("returns null when under 50%", () => {
stats.update((current) => ({
...current,
context_utilisation_percent: 40.0,
}));
const warning = get(contextWarning);
expect(warning).toBeNull();
});
it("returns moderate when between 50-74%", () => {
stats.update((current) => ({
...current,
context_utilisation_percent: 60.0,
}));
const warning = get(contextWarning);
expect(warning).toBe("moderate");
});
it("returns high when between 75-89%", () => {
stats.update((current) => ({
...current,
context_utilisation_percent: 80.0,
}));
const warning = get(contextWarning);
expect(warning).toBe("high");
});
it("returns critical when 90%+", () => {
stats.update((current) => ({
...current,
context_utilisation_percent: 95.0,
}));
const warning = get(contextWarning);
expect(warning).toBe("critical");
});
});
describe("getContextWarningMessage", () => {
it("returns correct message for moderate warning", () => {
const message = getContextWarningMessage("moderate");
expect(message).toContain("50%+");
expect(message).toContain("Consider starting a new conversation");
});
it("returns correct message for high warning", () => {
const message = getContextWarningMessage("high");
expect(message).toContain("75%+");
expect(message).toContain("Responses may degrade");
});
it("returns correct message for critical warning", () => {
const message = getContextWarningMessage("critical");
expect(message).toContain("90%+");
expect(message).toContain("Start a new conversation");
});
});
describe("formatTokenCount", () => {
it("formats small numbers directly", () => {
expect(formatTokenCount(0)).toBe("0");
expect(formatTokenCount(100)).toBe("100");
expect(formatTokenCount(999)).toBe("999");
});
it("formats thousands with K suffix", () => {
expect(formatTokenCount(1000)).toBe("1.0K");
expect(formatTokenCount(1500)).toBe("1.5K");
expect(formatTokenCount(10000)).toBe("10.0K");
expect(formatTokenCount(999999)).toBe("1000.0K");
});
it("formats millions with M suffix", () => {
expect(formatTokenCount(1000000)).toBe("1.0M");
expect(formatTokenCount(1500000)).toBe("1.5M");
expect(formatTokenCount(10000000)).toBe("10.0M");
});
});
describe("estimateMessageCost", () => {
it("estimates tokens at ~4 chars per token", () => {
const result = estimateMessageCost("test", 0, null); // 4 chars = 1 token
expect(result.messageTokens).toBe(1);
});
it("rounds up partial tokens", () => {
const result = estimateMessageCost("a", 0, null); // 1 char rounds up to 1 token
expect(result.messageTokens).toBe(1);
const result2 = estimateMessageCost("abcde", 0, null); // 5 chars = 2 tokens
expect(result2.messageTokens).toBe(2);
});
it("returns 0 tokens for empty string", () => {
const result = estimateMessageCost("", 0, null);
expect(result.messageTokens).toBe(0);
expect(result.estimatedCost).toBe(0);
});
it("adds context tokens to total", () => {
const result = estimateMessageCost("test", 1000, null); // 1 token + 1000 context
expect(result.messageTokens).toBe(1);
expect(result.totalInputTokens).toBe(1001);
});
it("calculates cost using Sonnet pricing by default", () => {
// 100 chars = 25 tokens, $3 per million input tokens
const result = estimateMessageCost("a".repeat(100), 0, null);
expect(result.messageTokens).toBe(25);
const expectedCost = (25 / 1_000_000) * 3.0;
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
});
it("uses Opus pricing for Opus models", () => {
const result = estimateMessageCost("a".repeat(100), 0, "claude-opus-4-5-20251101");
expect(result.messageTokens).toBe(25);
const expectedCost = (25 / 1_000_000) * 5.0; // Opus 4.5: $5 per million input
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
});
it("uses Haiku pricing for Haiku models", () => {
const result = estimateMessageCost("a".repeat(100), 0, "claude-3-5-haiku-20241022");
expect(result.messageTokens).toBe(25);
const expectedCost = (25 / 1_000_000) * 1.0; // Haiku: $1 per million
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
});
it("falls back to Sonnet pricing for unknown models", () => {
const result = estimateMessageCost("a".repeat(100), 0, "unknown-model");
expect(result.messageTokens).toBe(25);
const expectedCost = (25 / 1_000_000) * 3.0; // Default Sonnet: $3 per million
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
});
});
describe("MODEL_PRICING", () => {
it("contains expected Opus pricing", () => {
// Opus 4.5 has reduced pricing
expect(MODEL_PRICING["claude-opus-4-5-20251101"]).toEqual({ input: 5.0, output: 25.0 });
// Previous Opus models have higher pricing
expect(MODEL_PRICING["claude-opus-4-1-20250805"]).toEqual({ input: 15.0, output: 75.0 });
expect(MODEL_PRICING["claude-opus-4-20250514"]).toEqual({ input: 15.0, output: 75.0 });
});
it("contains expected Sonnet pricing", () => {
expect(MODEL_PRICING["claude-sonnet-4-5-20250929"]).toEqual({ input: 3.0, output: 15.0 });
expect(MODEL_PRICING["claude-sonnet-4-20250514"]).toEqual({ input: 3.0, output: 15.0 });
expect(MODEL_PRICING["claude-3-7-sonnet-20250219"]).toEqual({ input: 3.0, output: 15.0 });
expect(MODEL_PRICING["claude-3-5-sonnet-20241022"]).toEqual({ input: 3.0, output: 15.0 });
});
it("contains expected Haiku pricing", () => {
expect(MODEL_PRICING["claude-haiku-4-5-20251001"]).toEqual({ input: 1.0, output: 5.0 });
expect(MODEL_PRICING["claude-3-5-haiku-20241022"]).toEqual({ input: 1.0, output: 5.0 });
expect(MODEL_PRICING["claude-3-haiku-20240307"]).toEqual({ input: 0.25, output: 1.25 });
});
});
});
+219 -4
View File
@@ -1,6 +1,69 @@
import { writable, derived } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { costTrackingStore } from "./costTracking";
import { configStore } from "./config";
export type ContextWarning = "moderate" | "high" | "critical";
export type BudgetType = "token" | "cost";
// Model pricing (per million tokens) - keep in sync with stats.rs
// Source: https://platform.claude.com/docs/en/about-claude/models/overview
export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
// Current generation (Claude 4.6)
"claude-opus-4-6": { input: 5.0, output: 25.0 },
// Previous generation (Claude 4.5)
"claude-opus-4-5-20251101": { input: 5.0, output: 25.0 },
"claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 },
"claude-haiku-4-5-20251001": { input: 1.0, output: 5.0 },
// Previous generation (Claude 4.x)
"claude-opus-4-1-20250805": { input: 15.0, output: 75.0 },
"claude-opus-4-20250514": { input: 15.0, output: 75.0 },
"claude-sonnet-4-20250514": { input: 3.0, output: 15.0 },
// Legacy (Claude 3.x)
"claude-3-7-sonnet-20250219": { input: 3.0, output: 15.0 },
"claude-3-5-sonnet-20241022": { input: 3.0, output: 15.0 },
"claude-3-5-sonnet-20240620": { input: 3.0, output: 15.0 },
"claude-3-5-haiku-20241022": { input: 1.0, output: 5.0 },
"claude-3-opus-20240229": { input: 15.0, output: 75.0 },
"claude-3-sonnet-20240229": { input: 3.0, output: 15.0 },
"claude-3-haiku-20240307": { input: 0.25, output: 1.25 },
};
const DEFAULT_PRICING = { input: 3.0, output: 15.0 }; // Default to Sonnet
export interface CostEstimate {
messageTokens: number;
totalInputTokens: number;
estimatedCost: number;
}
// Estimate cost for a message before sending
export function estimateMessageCost(
messageText: string,
contextTokensUsed: number,
model: string | null
): CostEstimate {
// Estimate tokens using ~4 chars per token heuristic
const messageTokens = Math.ceil(messageText.length / 4);
const totalInputTokens = contextTokensUsed + messageTokens;
const pricing = model ? (MODEL_PRICING[model] ?? DEFAULT_PRICING) : DEFAULT_PRICING;
const estimatedCost = (totalInputTokens / 1_000_000) * pricing.input;
return { messageTokens, totalInputTokens, estimatedCost };
}
export type BudgetStatus =
| { type: "ok" }
| { type: "warning"; budget_type: BudgetType; percent_used: number }
| { type: "exceeded"; budget_type: BudgetType };
// Per-tool token usage statistics
export interface ToolTokenStats {
call_count: number;
estimated_input_tokens: number;
estimated_output_tokens: number;
}
export interface UsageStats {
total_input_tokens: number;
@@ -20,9 +83,18 @@ export interface UsageStats {
session_files_edited: number;
files_created: number;
session_files_created: number;
tools_usage: Record<string, number>;
session_tools_usage: Record<string, number>;
tools_usage: Record<string, ToolTokenStats>;
session_tools_usage: Record<string, ToolTokenStats>;
session_duration_seconds: number;
// Context window tracking
context_tokens_used: number;
context_window_limit: number;
context_utilisation_percent: number;
// Cache analytics (tracks potential savings from repeated tool calls)
potential_cache_hits: number;
potential_cache_savings_tokens: number;
}
// Main stats store
@@ -45,10 +117,26 @@ export const stats = writable<UsageStats>({
tools_usage: {},
session_tools_usage: {},
session_duration_seconds: 0,
context_tokens_used: 0,
context_window_limit: 200000,
context_utilisation_percent: 0,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
});
// Format token count with K/M suffix
export function formatTokenCount(tokens: number): string {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(1)}M`;
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}K`;
}
return tokens.toString();
}
// Derived store for formatted display values
export const formattedStats = derived(stats, ($stats) => {
export const formattedStats = derived([stats, configStore.config], ([$stats, $config]) => {
const formatNumber = (num: number) => num.toLocaleString();
const formatCost = (cost: number) => `$${cost.toFixed(4)}`;
const formatDuration = (seconds: number) => {
@@ -65,6 +153,23 @@ export const formattedStats = derived(stats, ($stats) => {
}
};
// Format tool stats with token info
const formatToolStats = (toolStats: Record<string, ToolTokenStats>) => {
return Object.entries(toolStats).map(([name, stats]) => ({
name,
callCount: stats.call_count,
totalTokens: stats.estimated_input_tokens + stats.estimated_output_tokens,
formattedTokens: formatTokenCount(
stats.estimated_input_tokens + stats.estimated_output_tokens
),
inputTokens: stats.estimated_input_tokens,
outputTokens: stats.estimated_output_tokens,
}));
};
// Use the model from stats if available, otherwise fall back to the configured model
const currentModel = $stats.model ?? $config.model ?? "No model selected";
return {
totalTokens: formatNumber($stats.total_input_tokens + $stats.total_output_tokens),
totalInputTokens: formatNumber($stats.total_input_tokens),
@@ -74,7 +179,7 @@ export const formattedStats = derived(stats, ($stats) => {
sessionInputTokens: formatNumber($stats.session_input_tokens),
sessionOutputTokens: formatNumber($stats.session_output_tokens),
sessionCost: formatCost($stats.session_cost_usd),
model: $stats.model || "No model selected",
model: currentModel,
// New formatted fields
messagesTotal: formatNumber($stats.messages_exchanged),
@@ -88,9 +193,116 @@ export const formattedStats = derived(stats, ($stats) => {
sessionDuration: formatDuration($stats.session_duration_seconds),
toolsUsage: $stats.tools_usage,
sessionToolsUsage: $stats.session_tools_usage,
// Formatted tool stats with token info
sessionToolsFormatted: formatToolStats($stats.session_tools_usage),
toolsFormatted: formatToolStats($stats.tools_usage),
// Context window tracking
contextUsed: formatNumber($stats.context_tokens_used),
contextLimit: formatNumber($stats.context_window_limit),
contextRemaining: formatNumber(
Math.max(0, $stats.context_window_limit - $stats.context_tokens_used)
),
contextUtilisation: `${$stats.context_utilisation_percent.toFixed(1)}%`,
};
});
// Derived store for context warning state
export const contextWarning = derived(stats, ($stats): ContextWarning | null => {
if ($stats.context_utilisation_percent >= 90) {
return "critical";
} else if ($stats.context_utilisation_percent >= 75) {
return "high";
} else if ($stats.context_utilisation_percent >= 50) {
return "moderate";
}
return null;
});
// Get warning message for context utilisation
export function getContextWarningMessage(warning: ContextWarning): string {
switch (warning) {
case "moderate":
return "Context window is 50%+ full. Consider starting a new conversation for better performance.";
case "high":
return "Context window is 75%+ full. Responses may degrade. Consider summarising or starting fresh.";
case "critical":
return "Context window is nearly full (90%+)! Start a new conversation to avoid errors.";
}
}
// Budget checking functions
export function checkBudget(
stats: UsageStats,
budgetEnabled: boolean,
tokenBudget: number | null,
costBudget: number | null,
warningThreshold: number
): BudgetStatus {
if (!budgetEnabled) {
return { type: "ok" };
}
const sessionTokens = stats.session_input_tokens + stats.session_output_tokens;
// Check token budget
if (tokenBudget !== null) {
if (sessionTokens >= tokenBudget) {
return { type: "exceeded", budget_type: "token" };
}
const percentUsed = sessionTokens / tokenBudget;
if (percentUsed >= warningThreshold) {
return { type: "warning", budget_type: "token", percent_used: percentUsed * 100 };
}
}
// Check cost budget
if (costBudget !== null) {
if (stats.session_cost_usd >= costBudget) {
return { type: "exceeded", budget_type: "cost" };
}
const percentUsed = stats.session_cost_usd / costBudget;
if (percentUsed >= warningThreshold) {
return { type: "warning", budget_type: "cost", percent_used: percentUsed * 100 };
}
}
return { type: "ok" };
}
// Get budget status message
export function getBudgetStatusMessage(status: BudgetStatus): string | null {
if (status.type === "ok") {
return null;
}
const budgetTypeLabel = status.budget_type === "token" ? "token" : "cost";
if (status.type === "exceeded") {
return `Session ${budgetTypeLabel} budget exceeded! Consider starting a new session.`;
}
return `Approaching ${budgetTypeLabel} budget limit (${status.percent_used.toFixed(0)}% used).`;
}
// Get remaining budget values
export function getRemainingTokenBudget(
stats: UsageStats,
tokenBudget: number | null
): number | null {
if (tokenBudget === null) return null;
const used = stats.session_input_tokens + stats.session_output_tokens;
return Math.max(0, tokenBudget - used);
}
export function getRemainingCostBudget(
stats: UsageStats,
costBudget: number | null
): number | null {
if (costBudget === null) return null;
return Math.max(0, costBudget - stats.session_cost_usd);
}
// Note: Cost calculation is now done in the Rust backend
// Initialize stats listener
@@ -102,6 +314,9 @@ export async function initStatsListener() {
// The backend already tracks all totals - just set the stats directly
stats.set(newStats);
// Refresh cost tracking to check for alerts (debounced - won't spam)
costTrackingStore.refresh();
});
// Load initial persisted stats from backend (no bridge required)
+180 -32
View File
@@ -12,13 +12,14 @@ import type {
UserQuestionEvent,
} from "$lib/types/messages";
import type { CharacterState } from "$lib/types/states";
import type { AgentStartPayload, AgentEndPayload } from "$lib/types/agents";
import { agentStore } from "$lib/stores/agents";
import {
initializeNotificationRules,
cleanupNotificationRules,
handleConnectionStatusChange,
handleNewUserMessage,
} from "$lib/notifications/rules";
import { normalizePath } from "$lib/utils/paths";
interface StateChangePayload {
state: CharacterState;
@@ -91,6 +92,12 @@ interface OutputPayload {
content: string;
tool_name: string | null;
conversation_id?: string;
cost?: {
input_tokens: number;
output_tokens: number;
cost_usd: number;
};
parent_tool_use_id?: string;
}
interface ConnectionPayload {
@@ -171,6 +178,12 @@ export async function initializeTauriListeners() {
} else if (status === "disconnected") {
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
// Mark all running agents as errored on disconnect, but not during reconnects
// (permission prompts trigger reconnects and agents may complete before reconnect)
if (!skipNextGreeting && targetConversationId) {
agentStore.markAllErrored(targetConversationId);
}
// Only remove from connected set if we're not about to reconnect
if (!skipNextGreeting && targetConversationId) {
connectedConversations.delete(targetConversationId);
@@ -243,7 +256,17 @@ export async function initializeTauriListeners() {
unlisteners.push(stateUnlisten);
const outputUnlisten = await listen<OutputPayload>("claude:output", (event) => {
const { line_type, content, tool_name, conversation_id } = event.payload;
const { line_type, content, tool_name, conversation_id, cost, parent_tool_use_id } =
event.payload;
// Convert snake_case cost to camelCase for TypeScript
const costData = cost
? {
inputTokens: cost.input_tokens,
outputTokens: cost.output_tokens,
costUsd: cost.cost_usd,
}
: undefined;
// Always store the output to the correct conversation
if (conversation_id) {
@@ -251,14 +274,18 @@ export async function initializeTauriListeners() {
conversation_id,
line_type as "user" | "assistant" | "system" | "tool" | "error",
content,
tool_name || undefined
tool_name || undefined,
costData,
parent_tool_use_id
);
} else {
// Fallback to active conversation if no conversation_id provided
claudeStore.addLine(
line_type as "user" | "assistant" | "system" | "tool" | "error",
content,
tool_name || undefined
tool_name || undefined,
costData,
parent_tool_use_id
);
}
});
@@ -291,48 +318,99 @@ export async function initializeTauriListeners() {
const cwdUnlisten = await listen<WorkingDirectoryPayload>("claude:cwd", (event) => {
const { directory, conversation_id } = event.payload;
// Normalize path separators to forward slashes
const normalizedDirectory = normalizePath(directory);
// Store working directory for the correct conversation
if (conversation_id) {
claudeStore.setWorkingDirectoryForConversation(conversation_id, normalizedDirectory);
claudeStore.setWorkingDirectoryForConversation(conversation_id, directory);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.setWorkingDirectory(normalizedDirectory);
claudeStore.setWorkingDirectory(directory);
}
});
unlisteners.push(cwdUnlisten);
console.log("[Tauri Listener] Setting up claude:permission listener");
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
const { id, tool_name, tool_input, description, conversation_id } = event.payload;
const { permissions, conversation_id } = event.payload;
// Store permission request for the specific conversation
if (conversation_id) {
claudeStore.requestPermissionForConversation(conversation_id, {
id,
tool: tool_name,
description,
input: tool_input,
});
claudeStore.addLineToConversation(
conversation_id,
"system",
`Permission requested for: ${tool_name}`
);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.requestPermission({
id,
tool: tool_name,
description,
input: tool_input,
});
claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
console.log(
`[Permission] Event received: ${permissions.length} permission(s) for conversation ${conversation_id || "active"}`,
{ permissions, conversation_id }
);
// Store each permission request for the specific conversation
for (const permission of permissions) {
const { id, tool_name, tool_input, description } = permission;
if (conversation_id) {
claudeStore.requestPermissionForConversation(conversation_id, {
id,
tool: tool_name,
description,
input: tool_input,
});
claudeStore.addLineToConversation(
conversation_id,
"system",
`Permission requested for: ${tool_name}`
);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.requestPermission({
id,
tool: tool_name,
description,
input: tool_input,
});
claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
}
}
});
unlisteners.push(permissionUnlisten);
const agentStartUnlisten = await listen<AgentStartPayload>("claude:agent-start", (event) => {
const {
tool_use_id,
agent_id,
description,
subagent_type,
started_at,
conversation_id,
parent_tool_use_id,
} = event.payload;
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (targetConversationId) {
agentStore.addAgent(targetConversationId, {
toolUseId: tool_use_id,
agentId: agent_id,
description,
subagentType: subagent_type,
startedAt: started_at,
status: "running",
parentToolUseId: parent_tool_use_id,
});
}
});
unlisteners.push(agentStartUnlisten);
const agentUpdateUnlisten = await listen<{
conversationId: string;
toolUseId: string;
agentId: string;
}>("claude:agent-update", (event) => {
const { conversationId, toolUseId, agentId } = event.payload;
agentStore.updateAgentId(conversationId, toolUseId, agentId);
});
unlisteners.push(agentUpdateUnlisten);
const agentEndUnlisten = await listen<AgentEndPayload>("claude:agent-end", (event) => {
const { tool_use_id, ended_at, is_error, conversation_id } = event.payload;
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (targetConversationId) {
agentStore.endAgent(targetConversationId, tool_use_id, ended_at, is_error);
}
});
unlisteners.push(agentEndUnlisten);
const questionUnlisten = await listen<UserQuestionEvent>("claude:question", (event) => {
const questionEvent = event.payload;
@@ -361,3 +439,73 @@ export function cleanupTauriListeners() {
// Cleanup notification rules
cleanupNotificationRules();
}
export async function initializeDiscordRpc() {
const config = configStore.getConfig();
if (config.discord_rpc_enabled) {
try {
const startedAt = new Date();
const startedAtUnixSeconds = Math.floor(startedAt.getTime() / 1000);
const model = config.model || "claude";
await invoke("log_discord_rpc", {
message: `[FRONTEND] Attempting to initialize Discord RPC: session='Idle', model='${model}', timestamp=${startedAtUnixSeconds}`,
});
console.log("Initializing Discord RPC with initial activity:", {
session_name: "Idle",
model,
started_at: startedAtUnixSeconds,
});
await invoke("init_discord_rpc", {
sessionName: "Idle",
model,
startedAt: startedAtUnixSeconds,
});
await invoke("log_discord_rpc", {
message: "[FRONTEND] Discord RPC initialized successfully!",
});
console.log("Discord RPC initialized successfully with initial presence");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await invoke("log_discord_rpc", {
message: `[FRONTEND] ERROR: Failed to initialize Discord RPC: ${errorMessage}`,
});
console.error("Failed to initialize Discord RPC:", error);
console.warn("Discord RPC will be unavailable. Make sure Discord is running.");
}
} else {
await invoke("log_discord_rpc", {
message: "[FRONTEND] Discord RPC is disabled in config, skipping initialization",
});
}
}
export async function updateDiscordRpc(sessionName: string, model: string, startedAt: Date) {
const config = configStore.getConfig();
if (!config.discord_rpc_enabled) {
return;
}
try {
const startedAtUnixSeconds = Math.floor(startedAt.getTime() / 1000);
await invoke("update_discord_rpc", {
sessionName: sessionName,
model,
startedAt: startedAtUnixSeconds,
});
} catch (error) {
console.error("Failed to update Discord RPC:", error);
}
}
export async function stopDiscordRpc() {
try {
await invoke("stop_discord_rpc");
} catch (error) {
console.error("Failed to stop Discord RPC:", error);
}
}
+32
View File
@@ -0,0 +1,32 @@
export type AgentStatus = "running" | "completed" | "errored";
export interface AgentInfo {
toolUseId: string;
agentId?: string;
description: string;
subagentType: string;
startedAt: number;
endedAt?: number;
status: AgentStatus;
parentToolUseId?: string;
durationMs?: number;
}
export interface AgentStartPayload {
tool_use_id: string;
agent_id?: string;
description: string;
subagent_type: string;
started_at: number;
conversation_id?: string;
parent_tool_use_id?: string;
}
export interface AgentEndPayload {
tool_use_id: string;
ended_at: number;
is_error: boolean;
conversation_id?: string;
duration_ms?: number;
num_turns?: number;
}
+13 -1
View File
@@ -4,6 +4,14 @@ export interface TerminalLine {
content: string;
timestamp: Date;
toolName?: string;
// Cost tracking for this specific message
cost?: {
inputTokens: number;
outputTokens: number;
costUsd: number;
};
// Indicates if this message is from a subagent
parentToolUseId?: string;
}
export interface SystemInitMessage {
@@ -118,11 +126,15 @@ export interface PermissionRequest {
input: Record<string, unknown>;
}
export interface PermissionPromptEvent {
export interface PermissionPromptEventItem {
id: string;
tool_name: string;
tool_input: Record<string, unknown>;
description: string;
}
export interface PermissionPromptEvent {
permissions: PermissionPromptEventItem[];
conversation_id?: string;
}
+188
View File
@@ -0,0 +1,188 @@
import { describe, it, expect } from "vitest";
import {
generateSummaryPrompt,
generateContextInjection,
estimateTokens,
createSummary,
shouldSuggestCompaction,
formatTokenCount,
sanitizeForJson,
} from "./conversationUtils";
import type { ConversationSummary } from "$lib/stores/conversations";
describe("conversationUtils", () => {
describe("generateSummaryPrompt", () => {
it("generates a prompt containing the conversation content", () => {
const content = "User: Hello\n\nAssistant: Hi there!";
const prompt = generateSummaryPrompt(content);
expect(prompt).toContain(content);
expect(prompt).toContain("summary");
expect(prompt).toContain("Key topics");
});
it("handles empty content", () => {
const prompt = generateSummaryPrompt("");
expect(prompt).toContain("summary");
});
});
describe("generateContextInjection", () => {
it("creates context injection message from summary", () => {
const summary: ConversationSummary = {
generatedAt: new Date("2024-01-01"),
content: "We discussed building a new feature",
messageCount: 50,
tokenEstimate: 10000,
};
const injection = generateContextInjection(summary);
expect(injection).toContain("Previous Session Context");
expect(injection).toContain("We discussed building a new feature");
expect(injection).toContain("50 messages");
expect(injection).toContain("10,000 tokens");
});
});
describe("estimateTokens", () => {
it("estimates tokens at ~4 chars per token", () => {
expect(estimateTokens("")).toBe(0);
expect(estimateTokens("test")).toBe(1); // 4 chars = 1 token
expect(estimateTokens("testing")).toBe(2); // 7 chars = 2 tokens
expect(estimateTokens("a".repeat(100))).toBe(25); // 100 chars = 25 tokens
});
it("rounds up partial tokens", () => {
expect(estimateTokens("a")).toBe(1); // 1 char rounds up to 1 token
expect(estimateTokens("ab")).toBe(1); // 2 chars rounds up to 1 token
expect(estimateTokens("abc")).toBe(1); // 3 chars rounds up to 1 token
expect(estimateTokens("abcde")).toBe(2); // 5 chars rounds up to 2 tokens
});
});
describe("createSummary", () => {
it("creates a valid ConversationSummary object", () => {
const summary = createSummary("Test summary content", 25, 5000);
expect(summary.content).toBe("Test summary content");
expect(summary.messageCount).toBe(25);
expect(summary.tokenEstimate).toBe(5000);
expect(summary.generatedAt).toBeInstanceOf(Date);
});
it("sets generatedAt to current time", () => {
const before = new Date();
const summary = createSummary("content", 10, 1000);
const after = new Date();
expect(summary.generatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(summary.generatedAt.getTime()).toBeLessThanOrEqual(after.getTime());
});
});
describe("shouldSuggestCompaction", () => {
it("returns false when under threshold", () => {
expect(shouldSuggestCompaction(50000, 200000, 60)).toBe(false); // 25%
expect(shouldSuggestCompaction(100000, 200000, 60)).toBe(false); // 50%
expect(shouldSuggestCompaction(119000, 200000, 60)).toBe(false); // 59.5%
});
it("returns true when at or above threshold", () => {
expect(shouldSuggestCompaction(120000, 200000, 60)).toBe(true); // 60%
expect(shouldSuggestCompaction(150000, 200000, 60)).toBe(true); // 75%
expect(shouldSuggestCompaction(200000, 200000, 60)).toBe(true); // 100%
});
it("handles zero context window limit", () => {
expect(shouldSuggestCompaction(50000, 0, 60)).toBe(false);
});
it("uses default threshold of 60%", () => {
expect(shouldSuggestCompaction(110000, 200000)).toBe(false); // 55%
expect(shouldSuggestCompaction(130000, 200000)).toBe(true); // 65%
});
it("respects custom threshold", () => {
expect(shouldSuggestCompaction(70000, 200000, 40)).toBe(false); // 35%
expect(shouldSuggestCompaction(90000, 200000, 40)).toBe(true); // 45%
});
});
describe("formatTokenCount", () => {
it("formats small numbers directly", () => {
expect(formatTokenCount(0)).toBe("0");
expect(formatTokenCount(100)).toBe("100");
expect(formatTokenCount(999)).toBe("999");
});
it("formats thousands with K suffix", () => {
expect(formatTokenCount(1000)).toBe("1.0K");
expect(formatTokenCount(1500)).toBe("1.5K");
expect(formatTokenCount(10000)).toBe("10.0K");
expect(formatTokenCount(999999)).toBe("1000.0K");
});
it("formats millions with M suffix", () => {
expect(formatTokenCount(1000000)).toBe("1.0M");
expect(formatTokenCount(1500000)).toBe("1.5M");
expect(formatTokenCount(10000000)).toBe("10.0M");
});
});
describe("sanitizeForJson", () => {
it("returns normal text unchanged", () => {
expect(sanitizeForJson("Hello world")).toBe("Hello world");
expect(sanitizeForJson("Test 123")).toBe("Test 123");
});
it("preserves common whitespace", () => {
expect(sanitizeForJson("line1\nline2")).toBe("line1\nline2");
expect(sanitizeForJson("col1\tcol2")).toBe("col1\tcol2");
expect(sanitizeForJson("line\r\nend")).toBe("line\r\nend");
});
it("removes null bytes", () => {
expect(sanitizeForJson("hello\x00world")).toBe("helloworld");
});
it("removes other control characters", () => {
// Bell character
expect(sanitizeForJson("alert\x07here")).toBe("alerthere");
// Backspace
expect(sanitizeForJson("back\x08space")).toBe("backspace");
// Form feed is removed
expect(sanitizeForJson("page\x0Cbreak")).toBe("pagebreak");
// Escape character
expect(sanitizeForJson("esc\x1Bhere")).toBe("eschere");
});
it("preserves printable characters including backslashes", () => {
const codeContent = '```rust\nfn main() {\n println!("Hello");\n}\n```';
expect(sanitizeForJson(codeContent)).toBe(codeContent);
});
it("handles mixed content with various characters", () => {
const mixed = "User: Hello\n\nAssistant: Here's some code:\n```\nconst x = 42;\n```";
expect(sanitizeForJson(mixed)).toBe(mixed);
});
it("preserves backslash sequences", () => {
// Backslashes followed by letters should be preserved as-is
expect(sanitizeForJson("path\\to\\file")).toBe("path\\to\\file");
expect(sanitizeForJson("color\\x1b")).toBe("color\\x1b");
});
it("removes lone surrogates", () => {
// Lone surrogates (U+D800-U+DFFF) can cause JSON parse errors
// High surrogate without low
expect(sanitizeForJson("test\uD800end")).toBe("testend");
// Low surrogate without high
expect(sanitizeForJson("test\uDC00end")).toBe("testend");
// But valid surrogate pairs should remain (they form valid characters)
// Actually, JavaScript represents emoji as surrogate pairs, so this is tricky
// The regex will remove the surrogates, which may break emoji. That's acceptable
// for a conversation summary where data integrity is more important.
});
});
});
+110
View File
@@ -0,0 +1,110 @@
import type { ConversationSummary } from "$lib/stores/conversations";
/**
* Sanitises a string for safe JSON serialization through Tauri IPC.
* Removes control characters and lone surrogates that could cause issues
* during JSON serialization/deserialization.
*/
export function sanitizeForJson(text: string): string {
// Remove control characters except for common whitespace (tab, newline, carriage return)
// These can cause JSON parsing issues and are rarely meaningful in conversation summaries.
// eslint-disable-next-line no-control-regex -- regex uses control character codes
let sanitized = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
// Remove extended ASCII control chars (C1 control codes)
sanitized = sanitized.replace(/[\x80-\x9F]/g, "");
// Remove lone surrogates (U+D800 to U+DFFF) which cause "unexpected end of hex escape"
// errors in serde_json when they appear without proper pairing.
// These are invalid in JSON and can cause parse failures.
sanitized = sanitized.replace(/[\uD800-\uDFFF]/g, "");
return sanitized;
}
/**
* Generates a prompt to ask Claude to summarise a conversation.
* This can be sent as a user message to get a summary.
*/
export function generateSummaryPrompt(conversationContent: string): string {
return `Please provide a concise summary of our conversation so far. Focus on:
1. Key topics discussed
2. Important decisions or conclusions made
3. Any ongoing tasks or context that would be helpful to remember
4. Code changes or files that were modified
Keep the summary brief but comprehensive enough to continue our work in a new session.
Here is our conversation:
${conversationContent}
Please provide the summary now:`;
}
/**
* Generates a context injection message to prepend to a new conversation.
* This provides Claude with context from a previous session.
*/
export function generateContextInjection(summary: ConversationSummary): string {
return `[Previous Session Context]
The following is a summary from our previous conversation (${summary.messageCount} messages, approximately ${summary.tokenEstimate.toLocaleString()} tokens):
${summary.content}
[End of Previous Context]
Please continue from where we left off, or let me know if you need any clarification about the previous context.`;
}
/**
* Estimates the token count for a given string.
* Uses a rough approximation of ~4 characters per token.
*/
export function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
/**
* Creates a ConversationSummary object from summary content.
*/
export function createSummary(
content: string,
messageCount: number,
originalTokenEstimate: number
): ConversationSummary {
return {
generatedAt: new Date(),
content,
messageCount,
tokenEstimate: originalTokenEstimate,
};
}
/**
* Determines if a conversation should be compacted based on token usage.
* Returns true if the conversation is using more than the threshold percentage
* of the context window.
*/
export function shouldSuggestCompaction(
contextTokensUsed: number,
contextWindowLimit: number,
thresholdPercent: number = 60
): boolean {
if (contextWindowLimit === 0) return false;
const utilisationPercent = (contextTokensUsed / contextWindowLimit) * 100;
return utilisationPercent >= thresholdPercent;
}
/**
* Formats a token count for display.
*/
export function formatTokenCount(tokens: number): string {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(1)}M`;
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}K`;
}
return tokens.toString();
}
-98
View File
@@ -1,98 +0,0 @@
import { describe, it, expect } from "vitest";
import { normalizePath, joinPath, getParentPath, getFilename } from "./paths";
describe("paths utilities", () => {
describe("normalizePath", () => {
it("should normalize Windows paths", () => {
expect(normalizePath("C:\\Users\\test")).toBe("C:/Users/test");
expect(normalizePath("path\\to\\file")).toBe("path/to/file");
expect(normalizePath("C:\\Users\\test\\file.txt")).toBe("C:/Users/test/file.txt");
});
it("should leave Unix paths unchanged", () => {
expect(normalizePath("/home/user")).toBe("/home/user");
expect(normalizePath("path/to/file")).toBe("path/to/file");
expect(normalizePath("/usr/local/bin")).toBe("/usr/local/bin");
});
it("should handle mixed separators", () => {
expect(normalizePath("mixed\\path/file")).toBe("mixed/path/file");
expect(normalizePath("C:\\Users/test\\mixed/path")).toBe("C:/Users/test/mixed/path");
});
it("should handle empty strings", () => {
expect(normalizePath("")).toBe("");
});
});
describe("joinPath", () => {
it("should join path segments with forward slashes", () => {
expect(joinPath("parent", "child")).toBe("parent/child");
expect(joinPath("/home", "user", "documents")).toBe("/home/user/documents");
expect(joinPath("C:", "Users", "test")).toBe("C:/Users/test");
});
it("should filter out empty segments", () => {
expect(joinPath("parent", "", "child")).toBe("parent/child");
expect(joinPath("", "home", "")).toBe("home");
expect(joinPath("", "", "")).toBe("");
});
it("should normalize mixed separators in segments", () => {
expect(joinPath("parent\\dir", "child")).toBe("parent/dir/child");
expect(joinPath("C:\\Users", "test\\file")).toBe("C:/Users/test/file");
});
});
describe("getParentPath", () => {
it("should get parent directory of Unix paths", () => {
expect(getParentPath("/home/user/file.txt")).toBe("/home/user");
expect(getParentPath("/home/user/")).toBe("/home");
expect(getParentPath("/home")).toBe("/");
expect(getParentPath("/")).toBe("/");
});
it("should get parent directory of Windows paths", () => {
expect(getParentPath("C:\\Users\\test\\file.txt")).toBe("C:/Users/test");
expect(getParentPath("C:\\Users\\test")).toBe("C:/Users");
expect(getParentPath("C:\\")).toBe("C:");
});
it("should handle relative paths", () => {
expect(getParentPath("parent/child/file.txt")).toBe("parent/child");
expect(getParentPath("file.txt")).toBe(".");
expect(getParentPath("")).toBe(".");
});
it("should handle paths with trailing slashes", () => {
expect(getParentPath("/home/user/")).toBe("/home");
expect(getParentPath("parent/child/")).toBe("parent");
});
});
describe("getFilename", () => {
it("should extract filename from Unix paths", () => {
expect(getFilename("/home/user/file.txt")).toBe("file.txt");
expect(getFilename("/usr/local/bin/node")).toBe("node");
expect(getFilename("/home/user/")).toBe("");
});
it("should extract filename from Windows paths", () => {
expect(getFilename("C:\\Users\\test\\file.txt")).toBe("file.txt");
expect(getFilename("C:\\Program Files\\app.exe")).toBe("app.exe");
expect(getFilename("D:\\folder\\")).toBe("");
});
it("should handle relative paths", () => {
expect(getFilename("parent/child/file.txt")).toBe("file.txt");
expect(getFilename("file.txt")).toBe("file.txt");
expect(getFilename("")).toBe("");
});
it("should handle paths without filename", () => {
expect(getFilename("/home/user/")).toBe("");
expect(getFilename("folder/")).toBe("");
expect(getFilename("/")).toBe("");
});
});
});
-67
View File
@@ -1,67 +0,0 @@
/**
* Normalize path separators to forward slashes for consistent handling across platforms
*/
export function normalizePath(path: string): string {
// Replace all backslashes with forward slashes
return path.replace(/\\/g, "/");
}
/**
* Join path segments with forward slashes
*/
export function joinPath(...segments: string[]): string {
// Filter out empty segments
const filtered = segments.filter((s) => s);
// Join with forward slash and normalize
return normalizePath(filtered.join("/"));
}
/**
* Get the parent directory of a path
*/
export function getParentPath(path: string): string {
let normalized = normalizePath(path);
// Remove trailing slash if present (unless it's the root)
if (normalized.endsWith("/") && normalized.length > 1) {
normalized = normalized.slice(0, -1);
}
// Handle Windows drive roots (e.g., "C:" or "C:/")
if (/^[A-Za-z]:$/.test(normalized)) {
return normalized; // Drive root has no parent
}
const lastSlash = normalized.lastIndexOf("/");
if (lastSlash === -1) {
// No slash found - could be just a filename or Windows drive letter
if (/^[A-Za-z]:/.test(normalized)) {
// It's a Windows path like "C:file.txt" - parent is "C:"
return normalized.substring(0, 2);
}
return ".";
}
if (lastSlash === 0) {
return "/";
}
// Check if parent would be a Windows drive root
const parent = normalized.substring(0, lastSlash);
if (/^[A-Za-z]:$/.test(parent)) {
return parent;
}
return parent;
}
/**
* Get the filename from a path
*/
export function getFilename(path: string): string {
const normalized = normalizePath(path);
const lastSlash = normalized.lastIndexOf("/");
if (lastSlash === -1) {
return normalized;
}
return normalized.substring(lastSlash + 1);
}
+2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import "../app.css";
import DebugConsole from "$lib/components/DebugConsole.svelte";
let { children } = $props();
@@ -14,4 +15,5 @@
<div id="app">
{@render children()}
<DebugConsole />
</div>
+106 -1
View File
@@ -1,8 +1,16 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { get } from "svelte/store";
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
import {
initializeTauriListeners,
cleanupTauriListeners,
initializeDiscordRpc,
stopDiscordRpc,
updateDiscordRpc,
setSkipNextGreeting,
} from "$lib/tauri";
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
import { conversationsStore } from "$lib/stores/conversations";
@@ -24,12 +32,16 @@
import AchievementNotification from "$lib/components/AchievementNotification.svelte";
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
import UpdateNotification from "$lib/components/UpdateNotification.svelte";
import CloseAppConfirmModal from "$lib/components/CloseAppConfirmModal.svelte";
import { debugConsoleStore } from "$lib/stores/debugConsole";
let initialized = false;
let updateNotification: UpdateNotification | undefined = $state(undefined);
let achievementPanelOpen = $state(false);
let currentCharacterState: CharacterState = $state("idle");
let compactModeActive = $state(false);
let closeConfirmModalOpen = $state(false);
let hasActiveConversation = $state(false);
// Editor state
const isEditorVisible = editorStore.isEditorVisible;
@@ -57,6 +69,24 @@
}
});
// Get reactive references to conversation stores
const activeConversationId = conversationsStore.activeConversationId;
const conversations = conversationsStore.conversations;
// Update Discord RPC when active conversation or model changes
$effect(() => {
// Access stores directly (without get()) to create reactive dependencies
const activeId = $activeConversationId;
const convs = $conversations;
const activeConv = activeId ? convs.get(activeId) : null;
const config = configStore.getConfig();
const model = config.model || "claude";
if (activeConv && config.discord_rpc_enabled) {
updateDiscordRpc(activeConv.name, model, activeConv.startedAt);
}
});
// Window size constants
const COMPACT_WIDTH = 280;
const COMPACT_HEIGHT = 400;
@@ -205,6 +235,13 @@
return;
}
// Ctrl+` - Toggle debug console
if (event.ctrlKey && event.key === "`") {
event.preventDefault();
debugConsoleStore.toggle();
return;
}
// Ctrl+E - Toggle editor panel (only when connected)
if (event.ctrlKey && event.key === "e") {
event.preventDefault();
@@ -254,6 +291,9 @@
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
// Set flag to preserve stats/permissions (don't treat next connect as new session)
setSkipNextGreeting(true);
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted");
} catch (error) {
@@ -314,6 +354,50 @@
}
}
async function handleCloseRequest() {
// Check if there's an active conversation with Claude running
const activeId = get(claudeStore.activeConversationId);
if (activeId) {
try {
const isRunning = await invoke<boolean>("is_claude_running", {
conversationId: activeId,
});
hasActiveConversation = isRunning;
} catch (error) {
console.error("Failed to check Claude status:", error);
hasActiveConversation = false;
}
} else {
hasActiveConversation = false;
}
// Always show confirmation modal
closeConfirmModalOpen = true;
}
async function handleConfirmClose() {
closeConfirmModalOpen = false;
try {
await invoke("close_application");
} catch (error) {
console.error("Failed to close application:", error);
}
}
async function handleMinimizeToTray() {
closeConfirmModalOpen = false;
try {
const window = getCurrentWindow();
await window.hide();
} catch (error) {
console.error("Failed to minimize to tray:", error);
}
}
function handleCancelClose() {
closeConfirmModalOpen = false;
}
onMount(async () => {
if (!initialized) {
initialized = true;
@@ -356,6 +440,19 @@
const window = getCurrentWindow();
await window.setSize(new LogicalSize(COMPACT_WIDTH, COMPACT_HEIGHT));
}
// Initialize Discord RPC
await initializeDiscordRpc();
// Listen for window close requests
const unlisten = await listen("window-close-requested", () => {
handleCloseRequest();
});
// Store the unlisten function for cleanup
window.addEventListener("beforeunload", () => {
unlisten();
});
}
});
@@ -363,6 +460,7 @@
if (initialized) {
cleanupTauriListeners();
cleanupNotificationSync();
stopDiscordRpc();
window.removeEventListener("keydown", handleGlobalKeydown);
initialized = false;
}
@@ -421,6 +519,13 @@
onClose={() => (achievementPanelOpen = false)}
/>
<UpdateNotification bind:this={updateNotification} />
<CloseAppConfirmModal
isOpen={closeConfirmModalOpen}
{hasActiveConversation}
onClose={handleConfirmClose}
onMinimize={handleMinimizeToTray}
onCancel={handleCancelClose}
/>
</div>
{/if}
-1
View File
@@ -39,7 +39,6 @@ vi.mock("@tauri-apps/api/core", () => ({
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
minimize_to_tray: false,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,