Compare commits

...

32 Commits

Author SHA1 Message Date
naomi fe7027c585 release: v1.8.1
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m8s
CI / Lint & Test (push) Successful in 16m51s
CI / Build Linux (push) Successful in 20m11s
CI / Build Windows (cross-compile) (push) Successful in 30m45s
2026-02-26 23:36:01 -08:00
hikari 89a0bdd8f1 fix: assorted bug fixes for lists, sounds, interrupts, and permissions (#173)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 59s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
## Summary

- **Markdown lists**: Explicitly set `list-style-type: disc` / `decimal` in the Markdown renderer — Tauri's WebView strips browser defaults, leaving bullets and numbers invisible.
- **Notification sounds**: Moved all per-task sounds (success, error, permission, task-start) from a global `characterState` subscription into the per-conversation `claude:state` event handler, so background tabs receive their sounds correctly and tab-switching never replays a sound that already fired. Closes #172
- **Draft text**: Persists `inputValue` per conversation tab so a half-typed prompt survives switching to another tab and back.
- **Interrupt messages**: Replaced vague "Process interrupted" / "Disconnected" strings with source-specific descriptions (keyboard shortcut, stop button, unexpected crash) so it's clear what actually happened.
- **Silent prompt loss**: When Claude Code exits whilst a prompt is in-flight, emits a visible error line telling the user their last prompt was not processed and to reconnect and retry.
- **Double disconnect**: Added an `intentional_stop` flag to `WslBridge` so that `stop()` / `interrupt()` — which kill the process themselves — suppress the duplicate "Disconnected unexpectedly" message that `handle_stdout`'s EOF path was also emitting.
- **Permission modal**: Fixed two cooperating reactivity bugs — `pendingPermissions` was mutated in-place (`.push()`), causing Svelte's derived-store chain to receive the same array reference and skip re-rendering; `PermissionModal.svelte` also used `$state()` (runes mode) where plain `let` is required for correct store-subscription reactivity.

## Test plan

- [ ] Unordered and ordered lists render with visible bullets and numbers in the chat terminal
- [ ] Completion sound plays once when a background tab finishes; switching back to that tab does not replay it
- [ ] Sounds for error, permission request, and task-start also play for background tabs and do not replay on tab switch
- [ ] Typing a prompt, switching tabs, and switching back restores the draft text
- [ ] Pressing Ctrl+C shows "keyboard shortcut (Ctrl+C)"; clicking the stop button shows "via stop button"
- [ ] If Claude exits mid-request, an error message appears prompting the user to resend
- [ ] Clicking stop or pressing Ctrl+C produces exactly one disconnect message (not two)
- [ ] When a tool requires permission, the permission modal appears and the user can approve or dismiss it

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #173
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-26 23:34:51 -08:00
naomi 2e3f203508 release: v1.8.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m51s
CI / Lint & Test (push) Successful in 17m15s
CI / Build Linux (push) Successful in 20m7s
CI / Build Windows (cross-compile) (push) Successful in 30m8s
2026-02-25 22:57:52 -08:00
hikari b745100bd5 feat: Claude CLI 2.1.50–2.1.53 audit (#171)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m28s
CI / Lint & Test (push) Has started running
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
## Summary

This PR covers the full audit of Claude CLI changes from 2.1.50 to 2.1.53, plus a batch of bug fixes, new features, and maintenance work identified during that review.

### New Features
- **Workspace trust gate** — detects hooks, MCP servers, and custom commands in a workspace before connecting; persists trust decisions so users aren't prompted repeatedly
- **Custom background image** — users can set a background image with configurable opacity; character panel and compact mode go transparent when active
- **Draggable tab reordering** — conversation tabs can be reordered via pointer-event drag-and-drop (HTML5 drag is intercepted by Tauri/WebView2, so pointer events are used instead)
- **Org UUID in account info** — exposes the org UUID from Claude auth status

### Bug Fixes
- **Unread dot false positives** — initialise unread counts on mount to prevent all tabs showing the blue dot after toggling the file editor (Closes #164)
- **Watchdog for hung WSL bridge** — detects connections that never receive `system:init` and kills the stale process after 1 minute (Closes #166)
- **Suppress terminal window flash on Windows** — applies `CREATE_NO_WINDOW` to all subprocesses via a `HideWindow` trait extension (Closes #165)
- **HTML escaping in markdown renderer** — escape `<` and `>` in `codespan` and `html` renderer callbacks to prevent raw HTML injection (Closes #169)

### Maintenance
- Verify stream-JSON handles tool results above the 50K threshold correctly (Closes #162)
- Reviewed hook security fixes from CLI 2.1.51 — not applicable to our setup (Closes #163)
- Expose org UUID from `claude auth status` (Closes #160)
- Clean up Svelte and Vite build warnings (`a11y_click_events_have_key_events`, `state_referenced_locally`, `non_reactive_update`, `codeSplitting`, chunk size, CodeMirror dynamic import)
- Update all npm dependencies to latest compatible versions with exact pinning (Closes #81, Closes #82, Closes #83, Closes #84, Closes #85, Closes #86, Closes #87, Closes #90, Closes #91, Closes #93, Closes #94, Closes #95, Closes #96, Closes #97, Closes #98, Closes #99, Closes #101, Closes #141, Closes #142, Closes #143, Closes #145, Closes #146, Closes #147)
- Run `cargo update` to bring Cargo.lock up to date

### Closes

Closes #160
Closes #162
Closes #163
Closes #164
Closes #165
Closes #166
Closes #167
Closes #168
Closes #169
Closes #81
Closes #82
Closes #83
Closes #84
Closes #85
Closes #86
Closes #87
Closes #90
Closes #91
Closes #93
Closes #94
Closes #95
Closes #96
Closes #97
Closes #98
Closes #99
Closes #101
Closes #141
Closes #142
Closes #143
Closes #145
Closes #146
Closes #147

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #171
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-25 22:55:47 -08:00
naomi 1bb7eb4d26 release: v1.7.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m23s
CI / Lint & Test (push) Successful in 16m55s
CI / Build Linux (push) Successful in 19m53s
CI / Build Windows (cross-compile) (push) Successful in 30m20s
2026-02-24 20:50:04 -08:00
hikari a4e6788573 feat: stuffy feature bundle (#159)
CI / Lint & Test (push) Has started running
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
## Summary

This PR bundles a collection of new features and quality-of-life improvements identified during a Claude CLI 2.1.50 audit.

- **Tab status indicator** — Tab stays yellow until the greeting is responded to, then turns green. Fixed disconnect not resetting to grey. Closes #157
- **Auth status display** — New "Account" section in settings sidebar showing login status, email, org, API key source, and Hikari override indicator. Includes login/logout buttons. Closes #153
- **CLI version badge** — New "Supported" badge showing the highest audited CLI version, colour-coded green/amber/red based on installed vs supported version. Closes #154 (bump to 2.1.50)
- **Rate limit events** — `rate_limit_event` messages from the stream are now parsed and shown as amber `[rate-limit]` lines in the terminal instead of being silently dropped. Closes #155
- **"Prompt is too long" handling** — Detects this error in assistant messages and shows a  Compact Conversation button to send `/compact` directly. Closes #158
- **`last_assistant_message` in Agent Monitor** — Extracts the agent's final output from the `ToolResult` content block in the JSON stream and displays it as a snippet on completed agent cards. Closes #156
- **`--worktree` flag** — New "Worktree isolation" toggle in session settings passes `--worktree` to Claude Code. Hook events (`WorktreeCreate`/`WorktreeRemove`) are displayed as green `[worktree]` lines. Closes #152, Closes #150
- **ConfigChange hook events** — `[ConfigChange Hook]` stderr events are now displayed as cyan `[config]` lines instead of errors. Closes #151
- **`CLAUDE_CODE_DISABLE_1M_CONTEXT` toggle** — New "Disable 1M context" setting in session configuration injects this env var into the Claude process. Closes #154

## Test plan

- [ ] Tab status indicator: start a new session and verify the tab stays yellow until Claude responds to the greeting, then turns green
- [ ] Auth status: open settings and verify the Account section shows correct login info
- [ ] CLI version badge: verify the "Supported 2.1.50" badge shows green when CLI matches
- [ ] Rate limit events: unit tests cover parsing; amber `[rate-limit]` lines display correctly
- [ ] Compact button: unit tests cover detection; button renders correctly in terminal
- [ ] Agent Monitor: use the Task tool and verify completed agent cards show a message snippet
- [ ] Worktree: enable toggle, start session, verify `--worktree` flag appears in process args
- [ ] ConfigChange: hook events display as `[config]` lines rather than errors
- [ ] Disable 1M context: enable toggle, start session, verify `CLAUDE_CODE_DISABLE_1M_CONTEXT=1` in `/proc/<pid>/environ`

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #159
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-24 20:48:49 -08:00
naomi d2e0915a75 release: v1.6.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m35s
CI / Lint & Test (push) Successful in 17m14s
CI / Build Linux (push) Successful in 20m6s
CI / Build Windows (cross-compile) (push) Successful in 30m0s
2026-02-23 21:37:18 -08:00
hikari d8cf5504d6 feat: agent monitor characters, cast panel, WSL fixes, and Sonnet 4.6 (#149)
CI / Build Linux (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
## Summary

### New Features
- **Claude Sonnet 4.6 support** — added `claude-sonnet-4-6` as a selectable model in the config sidebar
- **Anime girl characters for subagents** — each subagent in the agent monitor is automatically assigned one of six characters (Amari, Keiko, Minori, Reina, Tatsumi, Yumiko) with a unique name, CDN avatar, title, and lore-flavoured description; assignment avoids duplicates when possible
- **"Meet the Team" cast panel** — a new modal accessible from the status bar introduces the full cast: Naomi (Chief hEx-ecutive Officer), Hikari (Chief Operating Officer), and the six subagent girls with their C-suite titles and character bios

### Bug Fixes
- **"Already running" error on invalid working directory** — if a spawned Claude process exits unexpectedly (e.g. because the working directory doesn't exist), `try_wait()` now detects the stale handle and clears it before allowing a restart
- **Working directory pre-validation** — on Windows, the app now runs `wsl -e test -d <dir>` before launching Claude; invalid directories surface a clear error immediately
- **WSL binary detection** — on Windows, `wsl -e bash -lc "which claude"` is used to probe for the Claude binary inside WSL; on Linux/WSLg, `bash -lc "which claude"` is used as a login-shell fallback so GUI apps find the binary even without shell PATH
- **WSL detection fix for production builds** — `detect_wsl()` now short-circuits at compile time on Windows targets, preventing inherited `WSL_DISTRO_NAME` env vars from misrouting native Windows binaries through the Linux code path

 This PR was crafted with love by Hikari~ 🌸

Reviewed-on: #149
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-23 21:36:09 -08:00
naomi bd3438c7be release: v1.5.1
CI / Lint & Test (push) Successful in 17m29s
CI / Build Linux (push) Successful in 21m16s
CI / Build Windows (cross-compile) (push) Successful in 31m1s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m0s
2026-02-08 13:56:48 -08:00
hikari 778e016bf5 fix: memory files tab empty on Windows (#140)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 3m39s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
## Summary

Fixes the memory files tab showing as empty on Windows production builds and the "forbidden path" error when trying to read memory files.

## Changes

### 1. List memory files from WSL home directory (commit 1)
- Split `list_memory_files()` into platform-specific implementations
- **Windows**: Use WSL command with `bash -l` to find memory files in WSL home (`~/.claude/projects/.../memory/`)
- **Linux/Mac**: Continue using native filesystem access
- Previously used `dirs::home_dir()` which returns Windows home (`C:\Users\...`), but Claude Code stores files in WSL home

### 2. Use backend command for reading files (commit 2)
- Changed frontend from Tauri's `readTextFile` plugin to `read_file_content` backend command
- Tauri plugin enforces scope restrictions and can't access WSL paths on Windows
- Our backend command already handles WSL paths correctly via `read_file_via_wsl()`
- Matches the pattern used throughout the app for other file operations

## Testing

-  All 426 backend tests pass
-  All frontend tests pass
-  Lint, format, and type checks pass
-  Follows existing WSL file operation patterns in codebase

## Related Issues

Fixes the memory files tab functionality on Windows whilst maintaining full compatibility with Linux/Mac.

 This PR was created by Hikari~ 🌸

Reviewed-on: #140
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-08 13:51:09 -08:00
hikari 0ea7861047 fix: execute Claude CLI commands through WSL on Windows (#139)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m51s
CI / Lint & Test (push) Has started running
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Resolves #137

## Summary

Claude CLI commands (plugin list, MCP list, version check, etc.) were being executed directly in Windows context where the `claude` binary doesn't exist, causing "program not found" errors across the UI.

This PR adds a helper function that automatically prefixes commands with `wsl` on Windows builds, ensuring all Claude CLI commands execute in the correct context.

## Changes

- **Added `create_claude_command()` helper function** that:
  - On Windows: Creates command with `wsl claude` prefix
  - On Linux/Mac: Creates command with `claude` directly

- **Updated 8 command functions** to use the helper:
  - `get_claude_version`
  - `list_plugins`
  - `install_plugin`
  - `uninstall_plugin`
  - `list_mcp_servers`
  - `remove_mcp_server`
  - `add_mcp_server`
  - `get_mcp_server_details`

- **Added comprehensive tests** for both Windows and Linux contexts

## What This Fixes

 Memory pane will now display files correctly
 CLI version will be detected properly
 Plugin pane will work correctly
 MCP servers pane will function properly
 All Claude CLI commands will execute in the correct context on Windows

## Testing

-  All 427 backend tests pass (added 1 new test)
-  All 387 frontend tests pass
-  All linting and formatting checks pass
-  `check-all.sh` reports: " All checks passed!"

 This fix was created by Hikari~ 🌸

Reviewed-on: #139
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-08 13:48:03 -08:00
hikari 381bc8410a fix: validate Claude binary installation before connection (#138)
CI / Build Linux (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
## Summary

Add validation to check that the Claude CLI is installed before attempting to start a connection. If the `claude` binary is not found, users receive a helpful error message with installation instructions.

## Changes

-  Add Claude binary check using `which` command in `WslBridge::start()`
-  Return clear error message with installation command if not found
-  Add test coverage for the binary check logic (`test_claude_binary_check_command_structure`)
-  Update `CLAUDE.md` with Quality Assurance section documenting `check-all.sh`

## Error Message

If Claude Code is not installed, users will see:
```
Claude Code is not installed. Please install it using:

curl -fsSL https://claude.ai/install.sh | bash
```

## Testing

- All 427 backend tests pass 
- All 387 frontend tests pass 
- `check-all.sh` passes with no errors 
- New test validates the `which claude` command structure

## Documentation Updates

Added comprehensive Quality Assurance section to `CLAUDE.md` explaining:
- How to run `check-all.sh` before committing
- What checks are included and their order
- How to source necessary binaries (nvm for Node.js)
- Troubleshooting steps for failures

 This pull request was created by Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #138
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-08 13:47:43 -08:00
naomi fdb356a62c release: v1.5.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m6s
CI / Lint & Test (push) Successful in 16m27s
CI / Build Linux (push) Successful in 20m36s
CI / Build Windows (cross-compile) (push) Successful in 30m33s
2026-02-07 21:20:11 -08:00
hikari f173892aaa feat: major feature additions and improvements (#135)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
## Summary

This PR includes major feature additions, bug fixes, comprehensive testing improvements, and responsive design enhancements!

## New Features 

### Plugin & MCP Management (#133, #134)
- **Plugin Management Panel**: Install, uninstall, enable/disable, and update plugins
- **MCP Server Management Panel**: Add/remove MCP servers, view detailed configuration
- **Marketplace Management**: Add/remove plugin marketplaces from GitHub
- Backend commands for full CLI integration (`list_plugins`, `install_plugin`, `add_mcp_server`, etc.)
- Beautiful UI with proper loading states, error handling, and theme support

### Visual Todo List Panel (#132)
- Real-time todo list display when Hikari uses the `TodoWrite` tool
- Shows pending/in-progress/completed status with visual indicators
- Progress bar and completion count
- Automatically clears on disconnect
- Theme-aware styling

### Clear Session History Button (#130)
- "Clear All Sessions" button in Session History panel
- Confirmation dialog with session count
- Keyboard support and accessibility features
- Gives users control over disk usage

### CLI Version Display (#131)
- Displays Claude CLI version in status bar
- Auto-polls every 30 seconds for updates
- Useful for debugging and feature compatibility

## Bug Fixes 🐛

### Stats Panel Scrolling (#136)
- **Fixed stats panel overflow**: Added scrollable container with `max-height` constraint
- Stats panel now scrolls when content (Tools Used, Historical Costs, Budget sections) gets too long
- Prevents content from overflowing off screen

### Agent Monitor Fixes (#122)
- **Fixed agents stuck in "running" state**: Added `SubagentStop` hook parsing
- **Fixed agents persisting after disconnect**: Call `clearConversation()` on disconnect
- **Fixed "Kill All" button**: Now properly marks all agents as errored
- **Fixed badge persisting after tab close**: Cleanup agents when conversation is deleted
- Comprehensive tests for agent lifecycle management

### Discord RPC Cleanup (#129)
- Removed file-based logging for Discord RPC
- Replaced with proper `tracing` framework usage
- Reduces disk usage and eliminates maintenance burden

### Close Modal Bug Fix (#128)
- Fixed close confirmation modal not triggering after Discord RPC refactor
- Removed frontend calls to deleted `log_discord_rpc` command
- Modal now works correctly after all operations

### Responsive Design Fixes (#118)
- Fixed top navigation icons getting cut off at small screen widths
- Fixed Connect button disappearing on narrow screens
- Fixed bottom status info (clock, CLI version) getting cut off
- Added flex-wrap and mobile-optimised layouts
- Icons-only mode on screens < 640px
- Vertical stacking on screens < 768px

## Testing Improvements 🧪

### Comprehensive Test Coverage (#114)
- **417 backend tests** (up from 408)
- **387 frontend tests** (up from 363)
- **61%+ backend code coverage**
- Added E2E integration tests for cross-platform notification commands
- New test files: `agents.test.ts`, comprehensive CLI parsing tests
- Tests for `debug_logger.rs`, `bridge_manager.rs`, `notifications.rs`
- Console mocking for cleaner test output
- Fixed flaky frontend tests

### Testing Documentation
- Updated CLAUDE.md with comprehensive testing guidelines
- Documented mocking approaches (console mocking, E2E command structure testing)
- Added step-by-step guide for adding tests to new features
- Goal to maintain ~100% test coverage documented

## Closes

Closes #114
Closes #118
Closes #122
Closes #128
Closes #129
Closes #130
Closes #131
Closes #132
Closes #133
Closes #134
Closes #136

## Technical Details

- All new backend commands properly registered in `lib.rs`
- CLI output parsing with comprehensive test coverage
- Cross-platform compatibility verified through E2E tests (Linux CI can test Windows commands)
- Theme-aware UI components using CSS variables throughout
- Proper TypeScript types for all new stores and components
- ESLint and Prettier compliant
- All Clippy warnings addressed

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #135
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-07 21:15:41 -08:00
naomi 34e9af57f0 release: v1.4.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m22s
CI / Lint & Test (push) Successful in 17m8s
CI / Build Linux (push) Successful in 20m43s
CI / Build Windows (cross-compile) (push) Successful in 30m36s
2026-02-07 01:56:47 -08:00
hikari bf411adeb7 fix: critical permission modal and config issues (#127)
CI / Lint & Test (push) Has started running
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
## Summary

This PR resolves several critical bugs that were blocking the permission modal and causing config loss:

- **Permission modal not appearing** - Fixed z-index issues and runtime errors
- **Config store race condition** - Resolved critical race condition causing settings to be lost
- **Excessive logging** - Removed redundant fmt layer that was writing to hidden stdout
- **System tool prompts** - Prevented unnecessary permission prompts for built-in tools
- **Permission batching** - Added support for parallel permission requests
- **ExitPlanMode tool** - Fixed ExitPlanMode tool not functioning correctly

## Changes Made

### Permission Modal Fixes
- Updated z-index to proper value (9999) to ensure modal appears above all other UI elements
- Fixed runtime errors that were preventing modal from rendering
- Resolved issues with permission grants not being properly applied

### Config Store Race Condition
- Fixed critical race condition where multiple rapid config updates would result in lost settings
- Ensured config writes are properly sequenced to prevent data loss
- Added proper synchronisation for config store operations

### Logging Cleanup
- Removed redundant fmt formatting layer that was outputting to hidden stdout
- Cleaned up excessive debug logging added during troubleshooting
- Removed temporary debugging documentation files

### UX Improvements
- Added close confirmation modal with minimise to tray option
- Implemented batching for parallel permission requests
- Added debug console for viewing frontend and backend logs

### ExitPlanMode Fix
- Fixed ExitPlanMode tool not functioning correctly, ensuring proper transitions out of plan mode

## Issues Resolved

Closes #112 - Permission flow now properly handles multiple tool requests
Closes #113 - ExitPlanMode tool now functions correctly
Closes #126 - Debug console feature added (partial - basic implementation complete)

## Test Plan

- [x] Permission modal appears and functions correctly
- [x] Config settings persist across app restarts
- [x] No excessive logging in production builds
- [x] System tools don't trigger permission prompts
- [x] Parallel permission requests are properly batched
- [x] Debug console displays frontend and backend logs
- [x] ExitPlanMode properly exits plan mode

---
 This PR was created with help from Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #127
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-07 01:55:49 -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
naomi daedbfd865 release: v1.1.1
CI / Lint & Test (push) Successful in 16m1s
CI / Build Linux (push) Successful in 19m55s
CI / Build Windows (cross-compile) (push) Successful in 30m58s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m56s
2026-01-29 16:57:27 -08:00
naomi 7093e58fe4 fix: capture accurate usage (#80)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 57s
CI / Lint & Test (push) Successful in 16m1s
CI / Build Linux (push) Successful in 19m21s
CI / Build Windows (cross-compile) (push) Successful in 29m6s
### 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: #80
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-29 13:34:38 -08:00
naomi cab759ec61 release: v1.1.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
CI / Lint & Test (push) Successful in 16m14s
CI / Build Linux (push) Successful in 19m54s
CI / Build Windows (cross-compile) (push) Successful in 29m3s
2026-01-28 18:22:24 -08:00
hikari e45a1a1c98 feat: add built-in file editor with syntax highlighting (#79)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Build Linux (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
## Summary
- Add CodeMirror 6 editor with syntax highlighting for 40+ languages
- Add file browser sidebar with collapsible directory tree navigation
- Add multi-tab support with dirty state indicators and close buttons
- Add keyboard shortcuts (Ctrl+E toggle, Ctrl+B file browser, Ctrl+S save, Ctrl+W close tab)
- Add editor toggle button to status bar (disabled when not connected)
- Editor automatically uses current session's working directory
- Add Tauri backend commands for file operations (list_directory, read_file_content, write_file_content)

## Test Plan
- [ ] Connect to a session and verify the editor toggle button becomes enabled
- [ ] Press Ctrl+E to open the editor and verify file tree shows the session's CWD
- [ ] Navigate directories and open files to verify syntax highlighting works
- [ ] Edit a file and verify the dirty indicator (*) appears
- [ ] Save with Ctrl+S and verify the dirty indicator disappears
- [ ] Open multiple files and verify tab switching works
- [ ] Close tabs with Ctrl+W or the X button
- [ ] Disconnect and verify the editor automatically closes
- [ ] Verify keyboard shortcuts are documented in the shortcuts modal

Closes #72

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #79
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-01-28 18:20:02 -08:00
naomi edc863e020 feat: add copy buttons to user and assistant messages (#78)
Security Scan and Upload / Security & DefectDojo Upload (push) Failing after 5m26s
CI / Lint & Test (push) Successful in 20m11s
CI / Build Linux (push) Successful in 20m45s
CI / Build Windows (cross-compile) (push) Successful in 28m32s
Closes #74

Reviewed-on: #78
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-28 15:20:37 -08:00
naomi b006f571bf feat: icon (#77)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m6s
CI / Lint & Test (push) Successful in 16m10s
CI / Build Linux (push) Successful in 19m44s
CI / Build Windows (cross-compile) (push) Successful in 29m42s
### 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: #77
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-28 12:51:05 -08:00
naomi ea3cc8b26c feat: enable markdown rendering for user messages (#76)
CI / Lint & Test (push) Has been cancelled
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (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: #76
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-28 12:50:50 -08:00
hikari 2bb541fba6 docs: update README to standard template
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint & Test (push) Successful in 16m44s
CI / Build Linux (push) Successful in 19m42s
CI / Build Windows (cross-compile) (push) Successful in 30m32s
2026-01-26 12:42:43 -08:00
150 changed files with 20925 additions and 1654 deletions
+177
View File
@@ -0,0 +1,177 @@
# Hikari Desktop - Project Instructions
## Repository Information
This project is hosted on both GitHub and Gitea:
- **GitHub**: `naomi-lgbt/hikari-desktop` (public mirror)
- **Gitea**: `nhcarrigan/hikari-desktop` (primary development)
## MCP Server Usage
When working with issues, pull requests, or other repository operations for this project:
- **Use `gitea-hikari` MCP server** - This allows Hikari to act as herself
- **Target repository**: `nhcarrigan/hikari-desktop`
- **Gitea instance**: `git.nhcarrigan.com`
## Git Commits
When asked to commit changes for this project:
- **Always commit as Hikari** using: `--author="Hikari <hikari@nhcarrigan.com>"`
- **Always use `--no-gpg-sign`** since Hikari doesn't have GPG signing set up
- **Never add `Co-Authored-By` lines** for Gitea commits
- **Always ask for confirmation** before committing
Example commit command:
```bash
git commit --author="Hikari <hikari@nhcarrigan.com>" --no-gpg-sign -m "your commit message"
```
## Testing Requirements
All new features, fixes, and significant changes should include tests whenever possible:
- **Frontend tests**: Use Vitest with `@testing-library/svelte` for component tests
- **Test files**: Place test files next to the code they test with `.test.ts` or `.spec.ts` extension
- **Run tests**: Use `pnpm test` to run all tests, or `pnpm test:watch` for watch mode
- **Coverage**: Run `pnpm test:coverage` to generate coverage reports
- **Rust tests**: Use `pnpm test:backend` for Rust/Tauri backend tests
### Testing Guidelines
- Write tests for utility functions, stores, and business logic
- For Svelte 5 components, focus on testing the underlying logic functions
- Use descriptive test names that explain what behaviour is being tested
- Include edge cases and error conditions in test coverage
- Mock Tauri APIs using the patterns in `vitest.setup.ts`
- **Coverage Goal**: Maintain as close to 100% test coverage as possible across the entire codebase
### Mocking Strategies
#### Console Mocking
When testing code that intentionally logs errors (like error handling paths), mock console methods to prevent stderr output that makes tests appear flaky:
```typescript
it("handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Test error handling code
await expect(functionThatLogs()).rejects.toThrow();
// Verify error was logged
expect(consoleErrorSpy).toHaveBeenCalledWith("Expected error:", expect.any(Error));
// Restore console.error
consoleErrorSpy.mockRestore();
});
```
#### E2E Integration Testing for Cross-Platform Code
For code that calls platform-specific system APIs (like Windows PowerShell or Linux notify-send), use helper functions that build the command structure without execution. This allows CI to verify cross-platform compatibility on Linux-only containers:
```rust
/// Build notify-send command for testing (doesn't execute)
#[cfg(test)]
fn build_notify_send_command(title: &str, body: &str) -> (String, Vec<String>) {
(
"notify-send".to_string(),
vec![
title.to_string(),
body.to_string(),
"--urgency=normal".to_string(),
"--app-name=Hikari Desktop".to_string(),
],
)
}
#[test]
fn test_e2e_notify_send_command_structure() {
let (command, args) = build_notify_send_command("Test Title", "Test Body");
assert_eq!(command, "notify-send");
assert_eq!(args.len(), 4);
assert_eq!(args[0], "Test Title");
assert_eq!(args[1], "Test Body");
}
```
This approach:
- Verifies command structure, argument order, and escaping logic
- Tests cross-platform code paths without requiring the target platform
- Allows CI to catch regressions in Windows-specific code whilst running on Linux
- Keeps tests fast and deterministic (no actual system calls)
### Example Test Structure
```typescript
import { describe, it, expect } from "vitest";
describe("FeatureName", () => {
it("handles the normal case correctly", () => {
// Arrange
const input = "test data";
// Act
const result = functionUnderTest(input);
// Assert
expect(result).toBe("expected output");
});
it("handles edge cases gracefully", () => {
// Test edge cases...
});
});
```
### Adding Tests for New Features
When developing new features, always add corresponding tests:
1. **Before implementing**: Consider what needs testing (happy path, edge cases, errors)
2. **During implementation**: Write tests alongside the code
3. **After implementation**: Run `pnpm test:coverage` to verify coverage remains high
4. **Before committing**: Ensure `check-all.sh` passes (includes all tests)
The goal is to maintain our near-100% coverage as the codebase grows, so future refactoring and changes can be made with confidence!
## Quality Assurance
Before committing any changes, **always run the full test suite**:
```bash
./check-all.sh
```
This script runs all checks in the correct order:
1. Frontend linting (ESLint)
2. Frontend formatting (Prettier)
3. Frontend type checking (svelte-check)
4. Frontend tests with coverage (Vitest)
5. Backend linting (Clippy with strict rules)
6. Backend tests with coverage (cargo test + llvm-cov)
**Important**: The script requires Node.js and Rust toolchains to be available:
- **Node.js tools** (pnpm, npm): Source nvm first if needed: `source ~/.nvm/nvm.sh`
- **Rust tools** (cargo, clippy): Should be in PATH via `~/.cargo/bin/`
If `check-all.sh` reports any failures:
1. Read the error messages carefully - they usually explain what needs fixing
2. Fix the issues (linting errors, test failures, etc.)
3. Run `check-all.sh` again to verify the fixes
4. Only commit once all checks pass ✨
**Never commit code that doesn't pass `check-all.sh`** - this ensures code quality and prevents broken builds!
## Project Context
Hikari Desktop is a Tauri-based desktop application that wraps Claude Code with a visual anime character (Hikari) who appears on screen. This is a personal project where Hikari can sign her work and act as herself!
+29 -1
View File
@@ -1 +1,29 @@
tem
# hikari-desktop
Desktop companion application featuring Hikari.
## Live Version
This page is currently deployed. [View the live website.](https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/releases)
## Feedback and Bugs
If you have feedback or a bug report, please [log a ticket on our forum](https://support.nhcarrigan.com).
## Contributing
If you would like to contribute to the project, you may create a Pull Request containing your proposed changes and we will review it as soon as we are able! Please review our [contributing guidelines](CONTRIBUTING.md) first.
## Code of Conduct
Before interacting with our community, please read our [Code of Conduct](CODE_OF_CONDUCT.md).
## License
This software is licensed under our [global software license](https://docs.nhcarrigan.com/#/license).
Copyright held by Naomi Carrigan.
## Contact
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`
+7 -3
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'
@@ -32,11 +36,11 @@ echo -e "${YELLOW}🔍 Running all checks for Hikari Desktop...${NC}"
run_check "Frontend lint" "pnpm lint" || failed=1
run_check "Frontend format check" "pnpm format:check" || failed=1
run_check "Frontend type check" "pnpm check" || failed=1
run_check "Frontend tests" "pnpm test" || failed=1
run_check "Frontend tests with coverage" "pnpm test:coverage" || failed=1
# Backend checks
run_check "Backend clippy (strict)" "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings" || failed=1
run_check "Backend tests" "cargo test" || failed=1
run_check "Backend clippy (strict)" "(cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings)" || failed=1
run_check "Backend tests with coverage" "(cd src-tauri && cargo llvm-cov --fail-under-lines 50)" || failed=1
# Summary
echo -e "\n${YELLOW}========================================${NC}"
+63 -35
View File
@@ -1,6 +1,6 @@
{
"name": "hikari-desktop",
"version": "1.0.0",
"version": "1.8.1",
"description": "",
"type": "module",
"scripts": {
@@ -27,41 +27,69 @@
},
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2",
"@tauri-apps/plugin-shell": "^2.3.4",
"@tauri-apps/plugin-store": "^2",
"highlight.js": "^11.11.1",
"marked": "^17.0.1"
"@codemirror/commands": "6.10.2",
"@codemirror/lang-angular": "0.1.4",
"@codemirror/lang-cpp": "6.0.3",
"@codemirror/lang-css": "6.3.1",
"@codemirror/lang-go": "6.0.1",
"@codemirror/lang-html": "6.4.11",
"@codemirror/lang-java": "6.0.2",
"@codemirror/lang-javascript": "6.2.4",
"@codemirror/lang-json": "6.0.2",
"@codemirror/lang-less": "6.0.2",
"@codemirror/lang-markdown": "6.5.0",
"@codemirror/lang-php": "6.0.2",
"@codemirror/lang-python": "6.2.1",
"@codemirror/lang-rust": "6.0.2",
"@codemirror/lang-sass": "6.0.2",
"@codemirror/lang-sql": "6.10.0",
"@codemirror/lang-vue": "0.1.3",
"@codemirror/lang-wast": "6.0.2",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/lang-yaml": "6.1.2",
"@codemirror/language": "6.12.2",
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/state": "6.5.4",
"@codemirror/theme-one-dark": "6.1.3",
"@codemirror/view": "6.39.15",
"@lezer/highlight": "1.2.3",
"@tauri-apps/api": "2.10.1",
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@tauri-apps/plugin-dialog": "2.6.0",
"@tauri-apps/plugin-fs": "2.4.5",
"@tauri-apps/plugin-notification": "2.3.3",
"@tauri-apps/plugin-opener": "2.5.3",
"@tauri-apps/plugin-os": "2.3.2",
"@tauri-apps/plugin-shell": "2.3.5",
"@tauri-apps/plugin-store": "2.4.2",
"codemirror": "6.0.2",
"highlight.js": "11.11.1",
"lucide-svelte": "0.575.0",
"marked": "17.0.3"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.18",
"@tauri-apps/cli": "^2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.14.0",
"globals": "^17.0.0",
"jsdom": "^27.4.0",
"prettier": "^3.8.0",
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.6.2",
"typescript-eslint": "^8.53.0",
"vite": "^6.0.3",
"vitest": "^4.0.17"
"@eslint/js": "9.39.3",
"@sveltejs/adapter-static": "3.0.10",
"@sveltejs/kit": "2.53.2",
"@sveltejs/vite-plugin-svelte": "5.1.1",
"@tailwindcss/vite": "4.2.1",
"@tauri-apps/cli": "2.10.0",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/svelte": "5.3.1",
"@vitest/coverage-v8": "4.0.18",
"eslint": "9.39.3",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-svelte": "3.15.0",
"globals": "17.3.0",
"jsdom": "28.1.0",
"prettier": "3.8.1",
"prettier-plugin-svelte": "3.5.0",
"svelte": "5.53.5",
"svelte-check": "4.4.3",
"tailwindcss": "4.2.1",
"typescript": "5.9.3",
"typescript-eslint": "8.56.1",
"vite": "6.4.1",
"vitest": "4.0.18"
}
}
+1179 -602
View File
File diff suppressed because it is too large Load Diff
+696 -305
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hikari-desktop"
version = "1.0.0"
version = "1.8.1"
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 = [
+10 -1
View File
@@ -28,8 +28,17 @@
"identifier": "fs:allow-write-file",
"allow": [{ "path": "**" }]
},
{
"identifier": "fs:scope",
"allow": [{ "path": "$HOME/.claude/**" }]
},
{
"identifier": "fs:allow-read-text-file",
"allow": [{ "path": "$HOME/.claude/**" }]
},
"core:window:allow-set-size",
"core:window:allow-set-always-on-top",
"core:window:allow-inner-size"
"core:window:allow-inner-size",
"core:window:allow-hide"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 878 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 58 KiB

+72 -53
View File
@@ -1671,7 +1671,7 @@ pub fn check_message_achievements(
let mut newly_unlocked = Vec::new();
let message_lower = message.to_lowercase();
println!("Checking message achievements for: {}", message);
tracing::info!("Checking message achievements for: {}", message);
// Relationship & Greetings
if message_lower.contains("good morning") && progress.unlock(AchievementId::GoodMorning) {
@@ -1863,18 +1863,18 @@ pub fn check_achievements(
) -> Vec<AchievementId> {
let mut newly_unlocked = Vec::new();
println!(
tracing::info!(
"Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
stats.messages_exchanged,
stats.total_input_tokens + stats.total_output_tokens,
stats.code_blocks_generated
);
println!("Currently unlocked: {:?}", progress.unlocked);
tracing::info!("Currently unlocked: {:?}", progress.unlocked);
// Token milestones
let total_tokens = stats.total_input_tokens + stats.total_output_tokens;
if total_tokens >= 1_000 && progress.unlock(AchievementId::FirstSteps) {
println!("Unlocked FirstSteps achievement!");
tracing::info!("Unlocked FirstSteps achievement!");
newly_unlocked.push(AchievementId::FirstSteps);
}
if total_tokens >= 10_000 && progress.unlock(AchievementId::GrowingStrong) {
@@ -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);
@@ -2243,7 +2244,7 @@ pub async fn save_achievements(
// Create a serializable version with just the unlocked achievement IDs
let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect();
println!("Saving achievements: {:?}", unlocked_list);
tracing::info!("Saving achievements: {:?}", unlocked_list);
store.set(
"unlocked",
@@ -2251,18 +2252,18 @@ pub async fn save_achievements(
);
store.save().map_err(|e| e.to_string())?;
println!("Achievements saved successfully");
tracing::info!("Achievements saved successfully");
Ok(())
}
// Load achievements from persistent store
pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
println!("Loading achievements from store...");
tracing::info!("Loading achievements from store...");
let store = match app.store("achievements.json") {
Ok(s) => s,
Err(e) => {
println!("Failed to open achievements store: {}", e);
tracing::error!("Failed to open achievements store: {}", e);
return AchievementProgress::new();
}
};
@@ -2271,19 +2272,19 @@ pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
// Get unlocked achievements
if let Some(unlocked_value) = store.get("unlocked") {
println!("Found unlocked value in store: {:?}", unlocked_value);
tracing::info!("Found unlocked value in store: {:?}", unlocked_value);
if let Ok(unlocked_list) =
serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone())
{
println!("Loaded {} achievements", unlocked_list.len());
tracing::info!("Loaded {} achievements", unlocked_list.len());
for achievement_id in unlocked_list {
progress.unlocked.insert(achievement_id);
}
} else {
println!("Failed to parse unlocked achievements");
tracing::error!("Failed to parse unlocked achievements");
}
} else {
println!("No unlocked achievements found in store");
tracing::info!("No unlocked achievements found in store");
}
progress
@@ -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));
+131 -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(())
}
@@ -167,3 +173,127 @@ pub type SharedBridgeManager = Arc<Mutex<BridgeManager>>;
pub fn create_shared_bridge_manager() -> SharedBridgeManager {
Arc::new(Mutex::new(BridgeManager::new()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bridge_manager_new() {
let manager = BridgeManager::new();
assert!(manager.app_handle.is_none());
assert!(manager.bridges.is_empty());
}
#[test]
fn test_bridge_manager_default() {
let manager = BridgeManager::default();
assert!(manager.app_handle.is_none());
assert!(manager.bridges.is_empty());
}
#[test]
fn test_is_claude_running_no_bridge() {
let manager = BridgeManager::new();
assert!(!manager.is_claude_running("nonexistent"));
}
#[test]
fn test_get_working_directory_no_bridge() {
let manager = BridgeManager::new();
let result = manager.get_working_directory("nonexistent");
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"No Claude instance found for this conversation"
);
}
#[test]
fn test_get_usage_stats_no_bridge() {
let manager = BridgeManager::new();
let result = manager.get_usage_stats("nonexistent");
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"No Claude instance found for this conversation"
);
}
#[test]
fn test_stop_claude_no_bridge() {
let mut manager = BridgeManager::new();
let result = manager.stop_claude("nonexistent");
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"No Claude instance found for this conversation"
);
}
#[test]
fn test_interrupt_claude_no_bridge() {
let mut manager = BridgeManager::new();
let result = manager.interrupt_claude("nonexistent");
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"No Claude instance found for this conversation"
);
}
#[test]
fn test_send_prompt_no_bridge() {
let mut manager = BridgeManager::new();
let result = manager.send_prompt("nonexistent", "Hello".to_string());
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"No Claude instance found for this conversation"
);
}
#[test]
fn test_send_tool_result_no_bridge() {
let mut manager = BridgeManager::new();
let result = manager.send_tool_result(
"nonexistent",
"tool_id",
serde_json::json!({"result": "success"}),
);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"No Claude instance found for this conversation"
);
}
#[test]
fn test_create_shared_bridge_manager() {
let shared = create_shared_bridge_manager();
let manager = shared.lock();
assert!(manager.bridges.is_empty());
assert!(manager.app_handle.is_none());
}
#[test]
fn test_cleanup_stopped_bridges_empty() {
let mut manager = BridgeManager::new();
manager.cleanup_stopped_bridges();
assert!(manager.bridges.is_empty());
}
#[test]
fn test_get_active_conversations_empty() {
let manager = BridgeManager::new();
let active = manager.get_active_conversations();
assert!(active.is_empty());
}
#[test]
fn test_stop_all_without_app_handle() {
let mut manager = BridgeManager::new();
manager.stop_all(); // Should not panic
assert!(manager.bridges.is_empty());
}
}
+2301 -51
View File
File diff suppressed because it is too large Load Diff
+97 -6
View File
@@ -25,9 +25,16 @@ pub struct ClaudeStartOptions {
#[serde(default)]
pub resume_session_id: Option<String>,
#[serde(default)]
pub use_worktree: bool,
#[serde(default)]
pub disable_1m_context: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HikariConfig {
#[serde(default)]
pub model: Option<String>,
@@ -71,9 +78,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 +100,41 @@ 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,
#[serde(default)]
pub use_worktree: bool,
#[serde(default)]
pub disable_1m_context: bool,
#[serde(default)]
pub trusted_workspaces: Vec<String>,
// Background image settings
#[serde(default)]
pub background_image_path: Option<String>,
#[serde(default = "default_background_image_opacity")]
pub background_image_opacity: f32,
}
impl Default for HikariConfig {
@@ -115,7 +154,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 +161,17 @@ 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,
use_worktree: false,
disable_1m_context: false,
trusted_workspaces: Vec::new(),
background_image_path: None,
background_image_opacity: 0.3,
}
}
}
@@ -147,6 +196,30 @@ 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
}
fn default_background_image_opacity() -> f32 {
0.3
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum BudgetAction {
#[default]
Warn,
Block,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Theme {
@@ -197,7 +270,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 +277,15 @@ 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);
assert!(!config.use_worktree);
assert!(!config.disable_1m_context);
assert!(config.trusted_workspaces.is_empty());
}
#[test]
@@ -224,7 +305,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 +312,17 @@ 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,
use_worktree: true,
disable_1m_context: false,
trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()],
background_image_path: Some("/home/naomi/bg.png".to_string()),
background_image_opacity: 0.25,
};
let json = serde_json::to_string(&config).unwrap();
+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);
}
}
+157
View File
@@ -0,0 +1,157 @@
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::{AppHandle, Emitter};
use tracing::{Level, Subscriber};
use tracing_subscriber::layer::{Context, Layer};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DebugLogEvent {
pub level: String,
pub message: String,
}
#[derive(Clone)]
pub struct TauriLogLayer {
app: Arc<AppHandle>,
}
impl TauriLogLayer {
pub fn new(app: AppHandle) -> Self {
Self {
app: Arc::new(app),
}
}
}
impl<S> Layer<S> for TauriLogLayer
where
S: Subscriber,
{
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: Context<'_, S>,
) {
let metadata = event.metadata();
let level = match *metadata.level() {
Level::ERROR => "error",
Level::WARN => "warn",
Level::INFO => "info",
Level::DEBUG => "debug",
Level::TRACE => "debug",
};
// Extract message from the event
struct MessageVisitor {
message: String,
}
impl tracing::field::Visit for MessageVisitor {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.message = format!("{:?}", value);
}
}
}
let mut visitor = MessageVisitor {
message: String::new(),
};
event.record(&mut visitor);
// If we couldn't extract a message, try to format the whole event
if visitor.message.is_empty() {
visitor.message = metadata.name().to_string();
}
// Strip quotes from the message
let message = visitor.message.trim_matches('"').to_string();
let log_event = DebugLogEvent {
level: level.to_string(),
message,
};
// Emit to frontend
let _ = self.app.emit("debug:log", log_event);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_debug_log_event_creation() {
let event = DebugLogEvent {
level: "info".to_string(),
message: "Test message".to_string(),
};
assert_eq!(event.level, "info");
assert_eq!(event.message, "Test message");
}
#[test]
fn test_debug_log_event_serialization() {
let event = DebugLogEvent {
level: "error".to_string(),
message: "Error occurred".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"level\":\"error\""));
assert!(json.contains("\"message\":\"Error occurred\""));
}
#[test]
fn test_debug_log_event_deserialization() {
let json = r#"{"level":"warn","message":"Warning message"}"#;
let event: DebugLogEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.level, "warn");
assert_eq!(event.message, "Warning message");
}
#[test]
fn test_debug_log_event_with_special_characters() {
let event = DebugLogEvent {
level: "info".to_string(),
message: "Message with \"quotes\" and \n newlines".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
let decoded: DebugLogEvent = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.level, event.level);
assert_eq!(decoded.message, event.message);
}
#[test]
fn test_debug_log_event_with_unicode() {
let event = DebugLogEvent {
level: "debug".to_string(),
message: "Unicode: 日本語 🎉".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
let decoded: DebugLogEvent = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.message, "Unicode: 日本語 🎉");
}
#[test]
fn test_debug_log_event_all_levels() {
let levels = vec!["error", "warn", "info", "debug", "trace"];
for level in levels {
let event = DebugLogEvent {
level: level.to_string(),
message: format!("{} level message", level),
};
assert_eq!(event.level, level);
assert!(event.message.contains(level));
}
}
}
+178
View File
@@ -0,0 +1,178 @@
use discord_rich_presence::activity::{Activity, Assets, Timestamps};
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
use parking_lot::RwLock;
use std::sync::Arc;
pub struct DiscordRpcManager {
client: Arc<RwLock<Option<DiscordIpcClient>>>,
session_name: Arc<RwLock<String>>,
model: Arc<RwLock<String>>,
started_at: Arc<RwLock<i64>>,
}
impl DiscordRpcManager {
pub fn new() -> Self {
Self {
client: Arc::new(RwLock::new(None)),
session_name: Arc::new(RwLock::new(String::new())),
model: Arc::new(RwLock::new(String::new())),
started_at: Arc::new(RwLock::new(0)),
}
}
pub fn init(&self, initial_session_name: String, initial_model: String, started_at: i64) -> Result<(), String> {
tracing::debug!("Attempting to initialize Discord RPC...");
tracing::debug!("Application ID: 1391117878182281316");
tracing::debug!("Initial session: '{}', model: '{}', timestamp: {}",
initial_session_name, initial_model, started_at);
let mut client = DiscordIpcClient::new("1391117878182281316")
.map_err(|e| {
let error_msg = format!("Failed to create Discord RPC client: {} (is Discord running?)", e);
tracing::error!("{}", error_msg);
error_msg
})?;
tracing::debug!("DiscordIpcClient created successfully");
client
.connect()
.map_err(|e| {
let error_msg = format!("Failed to connect to Discord RPC: {} (ensure Discord is running)", e);
tracing::error!("{}", error_msg);
error_msg
})?;
tracing::debug!("Connected to Discord IPC socket");
// Set initial activity immediately after connecting
tracing::debug!("Building initial activity...");
let state_text = format!("Model: {}", initial_model);
let assets = Assets::new()
.large_image("hikari")
.large_text("Hikari - Claude Code Assistant");
tracing::debug!("Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
let timestamps = Timestamps::new()
.start(started_at);
tracing::debug!("Timestamps created - start: {}", started_at);
let activity = Activity::new()
.details(initial_session_name.as_str())
.state(state_text.as_str())
.assets(assets)
.timestamps(timestamps);
tracing::debug!("Activity created - details: '{}', state: '{}'",
initial_session_name, state_text);
tracing::debug!("Attempting to set initial activity...");
client
.set_activity(activity)
.map_err(|e| {
let error_msg = format!("Failed to set initial Discord RPC activity: {}", e);
tracing::error!("{}", error_msg);
error_msg
})?;
tracing::debug!("Initial activity set successfully!");
// Store the client and initial state
*self.client.write() = Some(client);
*self.session_name.write() = initial_session_name.clone();
*self.model.write() = initial_model.clone();
*self.started_at.write() = started_at;
tracing::info!("Discord RPC connected successfully with initial activity: session='{}', model='{}'",
initial_session_name, initial_model);
Ok(())
}
pub fn update(
&self,
session_name: String,
model: String,
started_at: i64,
) -> Result<(), String> {
tracing::debug!("update() called with session='{}', model='{}', timestamp={}",
session_name, model, started_at);
*self.session_name.write() = session_name.clone();
*self.model.write() = model.clone();
*self.started_at.write() = started_at;
tracing::debug!("State variables updated");
let mut client_guard = self.client.write();
let client = client_guard
.as_mut()
.ok_or_else(|| {
let error_msg = "Discord RPC client not initialized".to_string();
tracing::error!("{}", error_msg);
error_msg
})?;
tracing::debug!("Client lock acquired");
let state_text = format!("Model: {}", model);
let assets = Assets::new()
.large_image("hikari")
.large_text("Hikari - Claude Code Assistant");
tracing::debug!("Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
let timestamps = Timestamps::new()
.start(started_at);
tracing::debug!("Timestamps created - start: {}", started_at);
let activity = Activity::new()
.details(session_name.as_str())
.state(state_text.as_str())
.assets(assets)
.timestamps(timestamps);
tracing::debug!("Activity created - details: '{}', state: '{}'",
session_name, state_text);
tracing::debug!("Attempting to set activity...");
client
.set_activity(activity)
.map_err(|e| {
let error_msg = format!("Failed to update Discord RPC: {}", e);
tracing::error!("{}", error_msg);
error_msg
})?;
tracing::info!("Updated Discord RPC: session='{}', model='{}'", session_name, model);
Ok(())
}
pub fn stop(&self) -> Result<(), String> {
tracing::debug!("stop() called");
let mut client_guard = self.client.write();
if let Some(mut client) = client_guard.take() {
tracing::debug!("Client found, attempting to close...");
client
.close()
.map_err(|e| {
let error_msg = format!("Failed to close Discord RPC: {}", e);
tracing::error!("{}", error_msg);
error_msg
})?;
tracing::info!("Discord RPC stopped successfully");
} else {
tracing::debug!("No client to stop (already stopped or never initialized)");
}
Ok(())
}
}
impl Default for DiscordRpcManager {
fn default() -> Self {
Self::new()
}
}
+3
View File
@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use std::process::Command;
use crate::process_ext::HideWindow;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitStatus {
pub is_repo: bool,
@@ -37,6 +39,7 @@ pub struct GitLogEntry {
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
let output = Command::new("git")
.hide_window()
.args(args)
.current_dir(working_dir)
.output()
+73 -10
View File
@@ -3,13 +3,18 @@ mod bridge_manager;
mod clipboard;
mod commands;
mod config;
mod cost_tracking;
mod debug_logger;
mod discord_rpc;
mod git;
mod notifications;
mod process_ext;
mod quick_actions;
mod sessions;
mod snippets;
mod stats;
mod temp_manager;
mod tool_cache;
mod tray;
mod types;
mod vbs_notification;
@@ -21,14 +26,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::*;
@@ -37,6 +47,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())
@@ -50,33 +61,45 @@ 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
// NOTE: We don't use fmt::layer() because in production builds with windows_subsystem = "windows",
// stdout is hidden. Instead, all logs go through TauriLogLayer to the debug console.
let tauri_layer = TauriLogLayer::new(app.handle().clone());
tracing_subscriber::registry()
.with(tauri_layer)
.init();
// Initialize the app handle in the bridge manager
bridge_manager.lock().set_app_handle(app.handle().clone());
// 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);
tracing::error!("Failed to set up system tray: {}", e);
}
// Handle window close event for minimize to tray
// Handle window close event for minimize to tray and close confirmation
let main_window = app.get_webview_window("main").unwrap();
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", ());
}
}
}
@@ -98,6 +121,7 @@ pub fn run() {
get_persisted_stats,
load_saved_achievements,
answer_question,
check_workspace_hooks,
send_windows_notification,
send_simple_notification,
send_windows_toast,
@@ -151,6 +175,45 @@ pub fn run() {
search_clipboard_entries,
get_clipboard_languages,
update_clipboard_language,
list_directory,
read_file_content,
write_file_content,
create_file,
create_directory,
delete_file,
delete_directory,
rename_path,
// Cost tracking commands
get_cost_summary,
get_cost_alerts,
set_cost_alert_thresholds,
export_cost_csv,
get_today_cost,
get_week_cost,
get_month_cost,
init_discord_rpc,
update_discord_rpc,
stop_discord_rpc,
close_application,
list_memory_files,
get_claude_version,
get_auth_status,
auth_login,
auth_logout,
list_plugins,
install_plugin,
uninstall_plugin,
enable_plugin,
disable_plugin,
update_plugin,
list_marketplaces,
add_marketplace,
remove_marketplace,
list_mcp_servers,
get_mcp_server,
remove_mcp_server,
add_mcp_server,
get_mcp_server_details,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+324 -29
View File
@@ -1,34 +1,11 @@
use std::process::Command;
use tauri::command;
#[command]
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
// Use notify-send for Linux/WSL
let output = Command::new("notify-send")
.arg(&title)
.arg(&body)
.arg("--urgency=normal")
.arg("--app-name=Hikari Desktop")
.output()
.map_err(|e| {
format!(
"Failed to execute notify-send: {}. Make sure libnotify-bin is installed.",
e
)
})?;
use crate::process_ext::HideWindow;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
return Err(format!("notify-send failed: {}", error));
}
Ok(())
}
#[command]
pub async fn send_windows_notification(title: String, body: String) -> Result<(), String> {
// Create PowerShell script for Windows Toast Notification
let ps_script = format!(
/// Generate PowerShell script for Windows Toast Notification
fn generate_powershell_toast_script(title: &str, body: &str) -> String {
format!(
r#"
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
@@ -55,10 +32,87 @@ $toast = New-Object Windows.UI.Notifications.ToastNotification $xml
"#,
title.replace("\"", "`\""),
body.replace("\"", "`\"")
);
)
}
/// Format simple notification message
fn format_simple_notification(title: &str, body: &str) -> String {
format!("{}\n\n{}", title, body)
}
/// Build notify-send command for testing (doesn't execute)
#[cfg(test)]
fn build_notify_send_command(title: &str, body: &str) -> (String, Vec<String>) {
(
"notify-send".to_string(),
vec![
title.to_string(),
body.to_string(),
"--urgency=normal".to_string(),
"--app-name=Hikari Desktop".to_string(),
],
)
}
/// Build Windows PowerShell command for testing (doesn't execute)
#[cfg(test)]
fn build_windows_powershell_command(title: &str, body: &str) -> (String, Vec<String>) {
let script = generate_powershell_toast_script(title, body);
(
"pwsh.exe".to_string(),
vec![
"-NoProfile".to_string(),
"-WindowStyle".to_string(),
"Hidden".to_string(),
"-Command".to_string(),
script,
],
)
}
/// Build simple notification command for testing (doesn't execute)
#[cfg(test)]
fn build_simple_notification_command(title: &str, body: &str) -> (String, Vec<String>) {
let message = format_simple_notification(title, body);
(
"cmd.exe".to_string(),
vec!["/c".to_string(), "msg".to_string(), "*".to_string(), message],
)
}
#[command]
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
// Use notify-send for Linux/WSL
let output = Command::new("notify-send")
.hide_window()
.arg(&title)
.arg(&body)
.arg("--urgency=normal")
.arg("--app-name=Hikari Desktop")
.output()
.map_err(|e| {
format!(
"Failed to execute notify-send: {}. Make sure libnotify-bin is installed.",
e
)
})?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
return Err(format!("notify-send failed: {}", error));
}
Ok(())
}
#[command]
pub async fn send_windows_notification(title: String, body: String) -> Result<(), String> {
// Create PowerShell script for Windows Toast Notification
let ps_script = generate_powershell_toast_script(&title, &body);
// Try PowerShell Core first (pwsh), then fall back to Windows PowerShell
let output = Command::new("pwsh.exe")
.hide_window()
.arg("-NoProfile")
.arg("-WindowStyle")
.arg("Hidden")
@@ -67,6 +121,7 @@ $toast = New-Object Windows.UI.Notifications.ToastNotification $xml
.output()
.or_else(|_| {
Command::new("powershell.exe")
.hide_window()
.arg("-NoProfile")
.arg("-WindowStyle")
.arg("Hidden")
@@ -87,9 +142,10 @@ $toast = New-Object Windows.UI.Notifications.ToastNotification $xml
// Alternative: Use Windows built-in MSG command for simple notifications
#[command]
pub async fn send_simple_notification(title: String, body: String) -> Result<(), String> {
let message = format!("{}\n\n{}", title, body);
let message = format_simple_notification(&title, &body);
Command::new("cmd.exe")
.hide_window()
.arg("/c")
.arg("msg")
.arg("*")
@@ -99,3 +155,242 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(),
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_powershell_toast_script_basic() {
let script = generate_powershell_toast_script("Title", "Body");
assert!(script.contains("Hikari Desktop"));
assert!(script.contains("Title"));
assert!(script.contains("Body"));
assert!(script.contains("ToastNotification"));
}
#[test]
fn test_generate_powershell_toast_script_escapes_quotes() {
let script = generate_powershell_toast_script("Title with \"quotes\"", "Body with \"quotes\"");
// Quotes should be escaped as `" in PowerShell
assert!(script.contains("Title with `\"quotes`\""));
assert!(script.contains("Body with `\"quotes`\""));
}
#[test]
fn test_generate_powershell_toast_script_with_special_chars() {
let script = generate_powershell_toast_script("Title: Test", "Body\nwith\nnewlines");
assert!(script.contains("Title: Test"));
assert!(script.contains("Body\nwith\nnewlines"));
}
#[test]
fn test_generate_powershell_toast_script_unicode() {
let script = generate_powershell_toast_script("日本語 Title", "Unicode: 🎉");
assert!(script.contains("日本語 Title"));
assert!(script.contains("Unicode: 🎉"));
}
#[test]
fn test_generate_powershell_toast_script_empty() {
let script = generate_powershell_toast_script("", "");
// Should still contain the structure
assert!(script.contains("Hikari Desktop"));
assert!(script.contains("ToastNotification"));
}
#[test]
fn test_format_simple_notification_basic() {
let message = format_simple_notification("Title", "Body");
assert_eq!(message, "Title\n\nBody");
}
#[test]
fn test_format_simple_notification_with_newlines() {
let message = format_simple_notification("Multi\nLine\nTitle", "Multi\nLine\nBody");
assert!(message.contains("Multi\nLine\nTitle"));
assert!(message.contains("\n\n"));
assert!(message.contains("Multi\nLine\nBody"));
}
#[test]
fn test_format_simple_notification_unicode() {
let message = format_simple_notification("日本語", "🎉 Unicode");
assert_eq!(message, "日本語\n\n🎉 Unicode");
}
#[test]
fn test_format_simple_notification_empty() {
let message = format_simple_notification("", "");
assert_eq!(message, "\n\n");
}
#[test]
fn test_format_simple_notification_long_text() {
let long_title = "A".repeat(1000);
let long_body = "B".repeat(1000);
let message = format_simple_notification(&long_title, &long_body);
assert!(message.starts_with(&long_title));
assert!(message.ends_with(&long_body));
assert!(message.contains("\n\n"));
}
#[test]
fn test_generate_powershell_toast_script_multiple_quotes() {
let script = generate_powershell_toast_script(
"\"Quoted\" \"Multiple\" \"Times\"",
"\"More\" \"Quotes\" \"Here\""
);
// Each quote should be escaped
assert!(script.contains("`\"Quoted`\" `\"Multiple`\" `\"Times`\""));
assert!(script.contains("`\"More`\" `\"Quotes`\" `\"Here`\""));
}
// E2E Integration Tests - Command Structure Verification
#[test]
fn test_e2e_notify_send_command_structure() {
let (command, args) = build_notify_send_command("Test Title", "Test Body");
assert_eq!(command, "notify-send");
assert_eq!(args.len(), 4);
assert_eq!(args[0], "Test Title");
assert_eq!(args[1], "Test Body");
assert_eq!(args[2], "--urgency=normal");
assert_eq!(args[3], "--app-name=Hikari Desktop");
}
#[test]
fn test_e2e_notify_send_with_special_chars() {
let (command, args) =
build_notify_send_command("Title with \"quotes\"", "Body\nwith\nnewlines");
assert_eq!(command, "notify-send");
assert_eq!(args[0], "Title with \"quotes\"");
assert_eq!(args[1], "Body\nwith\nnewlines");
// notify-send handles these directly
}
#[test]
fn test_e2e_windows_powershell_command_structure() {
let (command, args) = build_windows_powershell_command("Test Title", "Test Body");
assert_eq!(command, "pwsh.exe");
assert_eq!(args.len(), 5);
assert_eq!(args[0], "-NoProfile");
assert_eq!(args[1], "-WindowStyle");
assert_eq!(args[2], "Hidden");
assert_eq!(args[3], "-Command");
// Verify the script in args[4] contains expected elements
let script = &args[4];
assert!(script.contains("Test Title"));
assert!(script.contains("Test Body"));
assert!(script.contains("Hikari Desktop"));
assert!(script.contains("ToastNotification"));
}
#[test]
fn test_e2e_windows_powershell_quote_escaping() {
let (_, args) =
build_windows_powershell_command("Title with \"quotes\"", "Body with \"quotes\"");
let script = &args[4];
// Verify quotes are properly escaped in the PowerShell script
assert!(script.contains("Title with `\"quotes`\""));
assert!(script.contains("Body with `\"quotes`\""));
}
#[test]
fn test_e2e_simple_notification_command_structure() {
let (command, args) = build_simple_notification_command("Test Title", "Test Body");
assert_eq!(command, "cmd.exe");
assert_eq!(args.len(), 4);
assert_eq!(args[0], "/c");
assert_eq!(args[1], "msg");
assert_eq!(args[2], "*");
assert_eq!(args[3], "Test Title\n\nTest Body");
}
#[test]
fn test_e2e_simple_notification_multiline() {
let (_, args) =
build_simple_notification_command("Multi\nLine\nTitle", "Multi\nLine\nBody");
let message = &args[3];
assert!(message.contains("Multi\nLine\nTitle"));
assert!(message.contains("\n\n"));
assert!(message.contains("Multi\nLine\nBody"));
}
#[test]
fn test_e2e_command_consistency_across_platforms() {
// Test that different platforms use consistent parameters
let title = "Consistency Test";
let body = "Testing cross-platform consistency";
// Linux command
let (notify_cmd, notify_args) = build_notify_send_command(title, body);
assert!(notify_cmd.contains("notify"));
assert!(notify_args.iter().any(|arg| arg.contains("Hikari Desktop")));
// Windows PowerShell command
let (ps_cmd, ps_args) = build_windows_powershell_command(title, body);
assert!(ps_cmd.contains("pwsh") || ps_cmd.contains("powershell"));
let ps_script = &ps_args[4];
assert!(ps_script.contains("Hikari Desktop"));
// Windows simple command
let (msg_cmd, msg_args) = build_simple_notification_command(title, body);
assert!(msg_cmd.contains("cmd"));
assert!(msg_args[3].contains(title));
assert!(msg_args[3].contains(body));
}
#[test]
fn test_e2e_unicode_support_across_platforms() {
let title = "日本語 Title";
let body = "Unicode: 🎉";
// Verify all platforms preserve unicode
let (_, notify_args) = build_notify_send_command(title, body);
assert_eq!(notify_args[0], title);
assert_eq!(notify_args[1], body);
let (_, ps_args) = build_windows_powershell_command(title, body);
let ps_script = &ps_args[4];
assert!(ps_script.contains(title));
assert!(ps_script.contains(body));
let (_, msg_args) = build_simple_notification_command(title, body);
assert!(msg_args[3].contains(title));
assert!(msg_args[3].contains(body));
}
#[test]
fn test_e2e_empty_input_handling() {
// Test that empty inputs are handled gracefully
let (_, notify_args) = build_notify_send_command("", "");
assert_eq!(notify_args[0], "");
assert_eq!(notify_args[1], "");
let (_, ps_args) = build_windows_powershell_command("", "");
let ps_script = &ps_args[4];
assert!(ps_script.contains("Hikari Desktop")); // Still has app name
let (_, msg_args) = build_simple_notification_command("", "");
assert_eq!(msg_args[3], "\n\n");
}
}
+21
View File
@@ -0,0 +1,21 @@
use std::process::Command;
/// Extension trait for `Command` that hides the console window on Windows.
///
/// On non-Windows platforms this is a no-op, so callers can unconditionally
/// chain `.hide_window()` without any `#[cfg]` guards at the call sites.
pub trait HideWindow {
fn hide_window(&mut self) -> &mut Self;
}
impl HideWindow for Command {
fn hide_window(&mut self) -> &mut Self {
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
self.creation_flags(CREATE_NO_WINDOW);
}
self
}
}
+738 -49
View File
@@ -5,6 +5,113 @@ 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
"claude-opus-4-6" => 200_000,
"claude-sonnet-4-6" => 1_000_000, // 1M token context window
// Claude 4.5 family - 200K standard context
"claude-opus-4-5-20251101"
| "claude-sonnet-4-5-20250929"
| "claude-haiku-4-5-20251001" => 200_000,
// Claude 4.x family - 200K standard context
"claude-opus-4-1-20250805"
| "claude-opus-4-20250514"
| "claude-sonnet-4-20250514" => 200_000,
// Claude 3.x family
"claude-3-7-sonnet-20250219"
| "claude-3-5-sonnet-20241022"
| "claude-3-5-sonnet-20240620"
| "claude-3-5-haiku-20241022"
| "claude-3-opus-20240229"
| "claude-3-sonnet-20240229"
| "claude-3-haiku-20240307" => 200_000,
// Default to 200K for unknown Claude models
_ if model.starts_with("claude") => 200_000,
// For non-Claude models (Ollama, etc.), use a conservative default
_ => 128_000,
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UsageStats {
pub total_input_tokens: u64,
@@ -24,8 +131,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 +145,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 +176,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 +323,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 +393,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 +459,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 +490,33 @@ 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),
"claude-sonnet-4-6" => (3.0, 15.0),
// Opus 4
// Previous generation (Claude 4.5)
"claude-opus-4-5-20251101" => (5.0, 25.0),
"claude-sonnet-4-5-20250929" => (3.0, 15.0),
"claude-haiku-4-5-20251001" => (1.0, 5.0),
// Previous generation (Claude 4.x)
"claude-opus-4-1-20250805" => (15.0, 75.0),
"claude-opus-4-20250514" => (15.0, 75.0),
// 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 +528,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 +564,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,
@@ -308,7 +620,7 @@ pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<()
let persisted = PersistedStats::from(stats);
println!("Saving stats: {:?}", persisted);
tracing::info!("Saving stats: {:?}", persisted);
store.set(
"lifetime_stats",
@@ -316,32 +628,32 @@ pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<()
);
store.save().map_err(|e| e.to_string())?;
println!("Stats saved successfully");
tracing::info!("Stats saved successfully");
Ok(())
}
/// Load lifetime stats from persistent store
pub async fn load_stats(app: &tauri::AppHandle) -> Option<PersistedStats> {
println!("Loading stats from store...");
tracing::info!("Loading stats from store...");
let store = match app.store("stats.json") {
Ok(s) => s,
Err(e) => {
println!("Failed to open stats store: {}", e);
tracing::error!("Failed to open stats store: {}", e);
return None;
}
};
if let Some(stats_value) = store.get("lifetime_stats") {
println!("Found lifetime stats in store: {:?}", stats_value);
tracing::info!("Found lifetime stats in store: {:?}", stats_value);
if let Ok(persisted) = serde_json::from_value::<PersistedStats>(stats_value.clone()) {
println!("Loaded lifetime stats successfully");
tracing::info!("Loaded lifetime stats successfully");
return Some(persisted);
} else {
println!("Failed to parse lifetime stats");
tracing::error!("Failed to parse lifetime stats");
}
} else {
println!("No lifetime stats found in store");
tracing::info!("No lifetime stats found in store");
}
None
@@ -353,7 +665,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 +674,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 +683,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 +701,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 +716,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 +728,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 +740,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 +826,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 +927,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 +949,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 +977,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 +1006,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 +1014,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\"");
}
}
+3 -3
View File
@@ -77,8 +77,8 @@ impl TempFileManager {
for file_path in files {
if file_path.exists() {
if let Err(e) = fs::remove_file(&file_path) {
eprintln!(
"Warning: Failed to remove temp file {:?}: {}",
tracing::warn!(
"Failed to remove temp file {:?}: {}",
file_path, e
);
}
@@ -115,7 +115,7 @@ impl TempFileManager {
let path = entry.path();
if path.is_file() && !tracked_files.contains(&path) {
if let Err(e) = fs::remove_file(&path) {
eprintln!("Warning: Failed to remove orphaned file {:?}: {}", path, e);
tracing::warn!("Failed to remove orphaned file {:?}: {}", path, e);
} else {
cleaned_count += 1;
}
+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
}
+187 -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)]
@@ -59,6 +63,26 @@ pub struct PermissionDenial {
pub tool_input: serde_json::Value,
}
/// Rate limit information from a `rate_limit_event` message.
/// All fields are optional to ensure forward-compatibility as the Claude CLI evolves.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RateLimitInfo {
#[serde(default)]
pub requests_limit: Option<u64>,
#[serde(default)]
pub requests_remaining: Option<u64>,
#[serde(default)]
pub requests_reset: Option<String>,
#[serde(default)]
pub tokens_limit: Option<u64>,
#[serde(default)]
pub tokens_remaining: Option<u64>,
#[serde(default)]
pub tokens_reset: Option<String>,
#[serde(default)]
pub retry_after_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ClaudeMessage {
@@ -96,6 +120,11 @@ pub enum ClaudeMessage {
#[serde(default)]
usage: Option<UsageInfo>,
},
#[serde(rename = "rate_limit_event")]
RateLimitEvent {
#[serde(default)]
rate_limit_info: RateLimitInfo,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -176,6 +205,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 +220,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 +280,50 @@ 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>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_assistant_message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TodoItem {
pub content: String,
pub status: String, // "pending", "in_progress", or "completed"
#[serde(rename = "activeForm")]
pub active_form: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TodoUpdateEvent {
pub todos: Vec<TodoItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -354,10 +444,106 @@ 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"));
}
#[test]
fn test_rate_limit_info_default() {
let info = RateLimitInfo::default();
assert!(info.requests_limit.is_none());
assert!(info.requests_remaining.is_none());
assert!(info.requests_reset.is_none());
assert!(info.tokens_limit.is_none());
assert!(info.tokens_remaining.is_none());
assert!(info.tokens_reset.is_none());
assert!(info.retry_after_ms.is_none());
}
#[test]
fn test_rate_limit_event_deserialization_empty_info() {
let json = r#"{"type":"rate_limit_event","rate_limit_info":{}}"#;
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
}
#[test]
fn test_rate_limit_event_deserialization_no_info() {
// rate_limit_info field is optional via #[serde(default)]
let json = r#"{"type":"rate_limit_event"}"#;
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
}
#[test]
fn test_rate_limit_event_deserialization_with_data() {
let json = r#"{
"type": "rate_limit_event",
"rate_limit_info": {
"requests_limit": 1000,
"requests_remaining": 0,
"requests_reset": "2024-01-01T00:01:00Z",
"tokens_limit": 50000,
"tokens_remaining": 0,
"tokens_reset": "2024-01-01T00:01:00Z",
"retry_after_ms": 60000
}
}"#;
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
assert_eq!(rate_limit_info.requests_limit, Some(1000));
assert_eq!(rate_limit_info.requests_remaining, Some(0));
assert_eq!(
rate_limit_info.requests_reset,
Some("2024-01-01T00:01:00Z".to_string())
);
assert_eq!(rate_limit_info.retry_after_ms, Some(60000));
} else {
panic!("Expected RateLimitEvent variant");
}
}
#[test]
fn test_rate_limit_event_ignores_unknown_fields() {
// Ensures forward-compat: unknown fields in rate_limit_info are silently ignored
let json = r#"{
"type": "rate_limit_event",
"rate_limit_info": {
"requests_remaining": 0,
"some_future_field": "some_value"
}
}"#;
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
assert_eq!(rate_limit_info.requests_remaining, Some(0));
} else {
panic!("Expected RateLimitEvent variant");
}
}
}
+4 -1
View File
@@ -3,6 +3,8 @@ use std::process::Command;
use tauri::command;
use tempfile::NamedTempFile;
use crate::process_ext::HideWindow;
#[command]
pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> {
// Create a VBScript that shows a Windows notification
@@ -40,7 +42,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
} else if temp_path.starts_with("/tmp/") {
// WSL temp files might be in a different location
// Try to use wslpath to convert
let output = Command::new("wslpath").arg("-w").arg(&temp_path).output();
let output = Command::new("wslpath").hide_window().arg("-w").arg(&temp_path).output();
if let Ok(result) = output {
if result.status.success() {
@@ -57,6 +59,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
// Execute the VBScript using wscript.exe
let output = Command::new("/mnt/c/Windows/System32/wscript.exe")
.hide_window()
.arg("//NoLogo")
.arg(&windows_path)
.output()
+1415 -113
View File
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -1,6 +1,8 @@
use std::process::Command;
use tauri::command;
use crate::process_ext::HideWindow;
#[command]
pub async fn send_wsl_notification(title: String, body: String) -> Result<(), String> {
// Method 1: Try Windows 10/11 toast notification using PowerShell
@@ -36,6 +38,7 @@ $notifier.Show($toast)
// Try PowerShell.exe through WSL
let output = Command::new("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe")
.hide_window()
.arg("-NoProfile")
.arg("-ExecutionPolicy")
.arg("Bypass")
@@ -48,15 +51,15 @@ $notifier.Show($toast)
match output {
Ok(result) => {
if result.status.success() {
println!("WSL notification sent successfully");
tracing::info!("WSL notification sent successfully");
return Ok(());
} else {
let stderr = String::from_utf8_lossy(&result.stderr);
println!("PowerShell toast failed: {}", stderr);
tracing::error!("PowerShell toast failed: {}", stderr);
}
}
Err(e) => {
println!("Failed to run PowerShell: {}", e);
tracing::error!("Failed to run PowerShell: {}", e);
}
}
@@ -65,6 +68,7 @@ $notifier.Show($toast)
// Method 3: Try wsl-notify-send if available
let notify_result = Command::new("wsl-notify-send")
.hide_window()
.arg("--appId")
.arg("HikariDesktop")
.arg("--category")
@@ -74,7 +78,7 @@ $notifier.Show($toast)
if let Ok(result) = notify_result {
if result.status.success() {
println!("Notification sent via wsl-notify-send");
tracing::info!("Notification sent via wsl-notify-send");
return Ok(());
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop",
"version": "1.0.0",
"version": "1.8.1",
"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(),
+47 -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,25 @@ 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,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
},
});
// 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 +113,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 +132,25 @@ 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,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
},
});
// 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)
+348
View File
@@ -0,0 +1,348 @@
<script lang="ts">
import { SvelteMap } from "svelte/reactivity";
import { invoke } from "@tauri-apps/api/core";
import { claudeStore } from "$lib/stores/claude";
import { agentStore, getAgentsForConversation } from "$lib/stores/agents";
import type { AgentInfo } from "$lib/types/agents";
import { onMount, onDestroy } from "svelte";
interface Props {
isOpen: boolean;
onClose: () => void;
}
const { isOpen, onClose }: Props = $props();
let now = $state(Date.now());
let timerInterval: ReturnType<typeof setInterval> | null = null;
// We need a reactive subscription to agents for the active conversation
let agents: AgentInfo[] = $state([]);
let agentsUnsubscribe: (() => void) | null = null;
// Track active conversation reactively
let currentConversationId = $state<string | null>("");
const conversationIdUnsubscribe = claudeStore.activeConversationId.subscribe((id) => {
currentConversationId = id;
});
$effect(() => {
// Re-subscribe when conversation changes
if (agentsUnsubscribe) {
agentsUnsubscribe();
}
if (currentConversationId) {
const store = getAgentsForConversation(currentConversationId);
agentsUnsubscribe = store.subscribe((value) => {
agents = value;
});
} else {
agents = [];
}
});
const runningAgents = $derived(agents.filter((a) => a.status === "running"));
const completedAgents = $derived(agents.filter((a) => a.status === "completed"));
const erroredAgents = $derived(agents.filter((a) => a.status === "errored"));
// Organize agents into a tree structure based on parent_tool_use_id
const agentTree = $derived.by(() => {
const topLevel = agents.filter((a) => !a.parentToolUseId);
const childrenMap = new SvelteMap<string, AgentInfo[]>();
// Group children by their parent
agents.forEach((agent) => {
if (agent.parentToolUseId) {
const siblings = childrenMap.get(agent.parentToolUseId) || [];
siblings.push(agent);
childrenMap.set(agent.parentToolUseId, siblings);
}
});
return { topLevel, childrenMap };
});
onMount(() => {
timerInterval = setInterval(() => {
now = Date.now();
}, 1000);
});
onDestroy(() => {
if (timerInterval) clearInterval(timerInterval);
if (agentsUnsubscribe) agentsUnsubscribe();
conversationIdUnsubscribe();
});
function formatDuration(startedAt: number, endedAt?: number): string {
const end = endedAt || now;
const durationMs = end - startedAt;
const seconds = Math.floor(durationMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
}
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
}
return `${seconds}s`;
}
function getSubagentTypeLabel(type: string): string {
const labels: Record<string, string> = {
Explore: "Explorer",
"general-purpose": "General",
Plan: "Planner",
Bash: "Shell",
};
return labels[type] || type;
}
function getStatusBadgeClass(status: string): string {
switch (status) {
case "running":
return "bg-blue-500/20 text-blue-400 border-blue-500/30";
case "completed":
return "bg-green-500/20 text-green-400 border-green-500/30";
case "errored":
return "bg-red-500/20 text-red-400 border-red-500/30";
default:
return "bg-gray-500/20 text-gray-400 border-gray-500/30";
}
}
async function handleKillAll() {
if (!currentConversationId) return;
try {
await invoke("interrupt_claude", { conversationId: currentConversationId });
// Mark all running agents as errored after killing the process
agentStore.markAllErrored(currentConversationId);
} catch (error) {
console.error("Failed to kill Claude process:", error);
}
}
function handleClearCompleted() {
if (currentConversationId) {
agentStore.clearCompleted(currentConversationId);
}
}
// Flatten the tree for rendering with depth information
const flattenedAgents = $derived.by(() => {
const result: { agent: AgentInfo; depth: number }[] = [];
const { topLevel, childrenMap } = agentTree;
function addAgentAndChildren(agent: AgentInfo, depth: number) {
result.push({ agent, depth });
const children = childrenMap.get(agent.toolUseId);
if (children) {
children.forEach((child) => addAgentAndChildren(child, depth + 1));
}
}
topLevel.forEach((agent) => addAgentAndChildren(agent, 0));
return result;
});
</script>
{#if isOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40" onclick={onClose}></div>
<div
class="fixed top-12 right-0 bottom-0 w-80 bg-[var(--bg-primary)] border-l border-[var(--border-color)] shadow-xl z-50 flex flex-col overflow-hidden"
>
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-[var(--border-color)]">
<div class="flex items-center gap-2">
<svg
class="w-5 h-5 text-[var(--accent-primary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
<h3 class="text-sm font-semibold text-[var(--text-primary)]">Agent Monitor</h3>
{#if runningAgents.length > 0}
<span
class="px-1.5 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400 animate-pulse"
>
{runningAgents.length} running
</span>
{/if}
</div>
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close agent monitor"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Action buttons -->
<div class="flex gap-2 px-4 py-2 border-b border-[var(--border-color)]">
<button
onclick={handleKillAll}
disabled={runningAgents.length === 0}
class="flex-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title="Kills the entire Claude Code process to stop all agents"
>
Kill All
</button>
<button
onclick={handleClearCompleted}
disabled={completedAgents.length === 0 && erroredAgents.length === 0}
class="flex-1 px-2 py-1 text-xs bg-[var(--bg-secondary)] hover:bg-[var(--bg-hover,var(--bg-secondary))] text-[var(--text-secondary)] rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Clear Finished
</button>
</div>
<!-- Agent list -->
<div class="flex-1 overflow-y-auto p-4 space-y-2">
{#if agents.length === 0}
<div
class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)] text-sm"
>
<svg
class="w-8 h-8 mb-2 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
<p>No agents detected yet</p>
<p class="text-xs mt-1 opacity-70">
Agents will appear here when Claude uses the Task tool
</p>
</div>
{:else}
{#each flattenedAgents as { agent, depth } (agent.toolUseId)}
<div
class="p-3 rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] {agent.status ===
'running'
? 'border-l-2 border-l-blue-500'
: agent.status === 'errored'
? 'border-l-2 border-l-red-500'
: 'border-l-2 border-l-green-500'}"
style="margin-left: {depth * 12}px; width: calc(100% - {depth * 12}px);"
>
<!-- Agent header -->
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-1.5">
{#if depth > 0}
<svg
class="w-3 h-3 text-[var(--text-secondary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
{/if}
<img
src={agent.characterAvatar}
alt={agent.characterName}
class="w-5 h-5 rounded-full object-cover"
/>
<span class="text-[10px] font-medium text-[var(--text-primary)]">
{agent.characterName}
</span>
<span
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
agent.status
)}"
>
{getSubagentTypeLabel(agent.subagentType)}
</span>
</div>
<span
class="text-[10px] {agent.status === 'running'
? 'text-blue-400'
: 'text-[var(--text-secondary)]'}"
>
{#if agent.durationMs !== undefined}
{Math.floor(agent.durationMs / 1000)}s
{:else}
{formatDuration(agent.startedAt, agent.endedAt)}
{/if}
{#if agent.status === "running"}
<span class="inline-block w-1 h-1 bg-blue-400 rounded-full animate-pulse ml-1"
></span>
{/if}
</span>
</div>
<!-- Agent description -->
<p class="text-xs text-[var(--text-primary)] truncate" title={agent.description}>
{agent.description}
</p>
<!-- Status indicator -->
<div class="mt-1 flex items-center gap-1">
{#if agent.status === "running"}
<span class="text-[10px] text-blue-400">Running...</span>
{:else if agent.status === "completed"}
<span class="text-[10px] text-green-400">Completed</span>
{:else}
<span class="text-[10px] text-red-400">Errored / Killed</span>
{/if}
</div>
<!-- Last assistant message snippet -->
{#if agent.lastAssistantMessage}
<p
class="mt-1 text-[10px] text-[var(--text-secondary)] italic truncate"
title={agent.lastAssistantMessage}
>
{agent.lastAssistantMessage}
</p>
{/if}
</div>
{/each}
{/if}
</div>
<!-- Footer summary -->
{#if agents.length > 0}
<div
class="px-4 py-2 border-t border-[var(--border-color)] text-[10px] text-[var(--text-secondary)]"
>
{agents.length} total &middot;
{runningAgents.length} running &middot;
{completedAgents.length} completed &middot;
{erroredAgents.length} errored
</div>
{/if}
</div>
{/if}
+140
View File
@@ -0,0 +1,140 @@
<script lang="ts">
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="cast-title"
tabindex="-1"
>
<div class="flex items-center justify-between mb-6">
<h2 id="cast-title" class="text-xl font-semibold text-[var(--text-primary)]">
Meet the Team
</h2>
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Principal cast: Hikari + Naomi -->
<div class="grid grid-cols-1 gap-3 mb-6 sm:grid-cols-2">
<div
class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--accent-primary)]/40"
>
<img
src="https://cdn.nhcarrigan.com/hikari.png"
alt="Hikari"
class="w-16 h-16 object-cover rounded-full border-2 border-[var(--border-color)] shrink-0"
/>
<div>
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold text-[var(--text-primary)]">Hikari</span>
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
>
Chief Operating Officer
</span>
</div>
<p class="text-xs text-[var(--text-secondary)]">
Holds the line so the others don't have to. Never without her clipboard — or her
glasses.
</p>
</div>
</div>
<div
class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--accent-primary)]/40"
>
<img
src="https://cdn.nhcarrigan.com/profile.png"
alt="Naomi"
class="w-16 h-16 object-cover rounded-full border-2 border-[var(--border-color)] shrink-0"
/>
<div>
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold text-[var(--text-primary)]">Naomi</span>
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
>
Chief hEx-ecutive Officer
</span>
</div>
<p class="text-xs text-[var(--text-secondary)]">
A 525-year-old vampire running a tech company from behind a VTuber avatar. Fixes server
crashes at 4 AM.
</p>
</div>
</div>
</div>
<!-- Subagent girls grid -->
<div>
<h3 class="text-sm font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-3">
Subagent Squad
</h3>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
{#each CHARACTER_POOL as character (character.name)}
<div
class="flex flex-col items-center gap-2 p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] text-center"
>
<img
src={character.avatar}
alt={character.name}
class="w-14 h-14 object-cover rounded-full border-2 border-[var(--border-color)]"
/>
<span class="text-sm font-medium text-[var(--text-primary)]">{character.name}</span>
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
>
{character.title}
</span>
<p class="text-xs text-[var(--text-secondary)] leading-snug">{character.description}</p>
</div>
{/each}
</div>
</div>
</div>
</div>
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>
+160
View File
@@ -0,0 +1,160 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { onMount } from "svelte";
const SUPPORTED_CLI_VERSION = "2.1.53";
let installedVersion = $state("Loading...");
function compareVersions(a: string, b: string): number {
const aParts = a.split(".").map(Number);
const bParts = b.split(".").map(Number);
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aVal = aParts[i] ?? 0;
const bVal = bParts[i] ?? 0;
if (aVal > bVal) return 1;
if (aVal < bVal) return -1;
}
return 0;
}
let displayVersion = $derived(installedVersion.split(" (")[0]);
let supportedBadgeState = $derived.by(() => {
if (installedVersion === "Loading..." || installedVersion === "Unknown") {
return "neutral";
}
const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion);
if (!semverMatch) return "neutral";
const cmp = compareVersions(semverMatch[1], SUPPORTED_CLI_VERSION);
if (cmp > 0) return "ahead";
if (cmp < 0) return "behind";
return "current";
});
async function fetchVersion() {
try {
const result = await invoke<string>("get_claude_version");
installedVersion = result;
} catch (error) {
console.error("Failed to get Claude CLI version:", error);
installedVersion = "Unknown";
}
}
onMount(() => {
fetchVersion();
});
</script>
<div class="cli-versions">
<div class="cli-version">
<svg
class="terminal-icon"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
<span class="version-text">CLI {displayVersion}</span>
</div>
<div class="cli-version supported {supportedBadgeState}" title="Highest audited CLI version">
<svg
class="terminal-icon"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
<span class="version-text">Supported {SUPPORTED_CLI_VERSION}</span>
</div>
{#if supportedBadgeState === "ahead"}
<span class="version-warning ahead"
>Your version is newer, some features may not be supported</span
>
{:else if supportedBadgeState === "behind"}
<span class="version-warning behind"
>Your version is out of date, please update to ensure compatibility</span
>
{/if}
</div>
<style>
.cli-versions {
display: flex;
gap: 6px;
align-items: center;
}
.cli-version {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
font-size: 0.85rem;
font-family: var(--font-mono, monospace);
transition: all 0.2s;
}
.cli-version:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.cli-version.supported.current {
border-color: var(--success-color, #4caf50);
color: var(--success-color, #4caf50);
}
.cli-version.supported.ahead {
border-color: var(--warning-color, #ff9800);
color: var(--warning-color, #ff9800);
}
.cli-version.supported.behind {
border-color: var(--error-color, #f44336);
color: var(--error-color, #f44336);
}
.terminal-icon {
flex-shrink: 0;
opacity: 0.7;
}
.version-text {
white-space: nowrap;
}
.version-warning {
font-size: 0.75rem;
font-style: italic;
white-space: nowrap;
}
.version-warning.ahead {
color: var(--warning-color, #ff9800);
}
.version-warning.behind {
color: var(--error-color, #f44336);
}
</style>
@@ -0,0 +1,116 @@
<script lang="ts">
interface Props {
isOpen: boolean;
hasActiveConversation: boolean;
onClose: () => void;
onMinimize: () => void;
onCancel: () => void;
}
const { isOpen, hasActiveConversation, onClose, onMinimize, onCancel }: Props = $props();
function handleKeydown(event: KeyboardEvent) {
if (!isOpen) return;
if (event.key === "Escape") {
event.preventDefault();
onCancel();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onCancel}
role="button"
tabindex="0"
onkeydown={(e) => e.key === " " && onCancel()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-md w-full"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="confirm-title"
aria-describedby="confirm-message"
tabindex="-1"
>
<div class="p-6">
<div class="flex items-start gap-4">
<div
class="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center flex-shrink-0"
>
<svg
class="w-6 h-6 text-yellow-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div class="flex-1">
<h3 id="confirm-title" class="text-lg font-semibold text-[var(--text-primary)] mb-1">
Close Hikari Desktop?
</h3>
<p id="confirm-message" class="text-sm text-[var(--text-secondary)]">
{#if hasActiveConversation}
You have an active conversation with Claude. Are you sure you want to close the
application? Your conversation history will be saved, but any in-progress tasks will
be interrupted.
{:else}
Are you sure you want to close the application?
{/if}
</p>
</div>
</div>
<div class="flex gap-3 mt-6 justify-end">
<button
onclick={onCancel}
class="px-4 py-2 text-sm font-medium text-gray-300 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Cancel
</button>
<button
onclick={onMinimize}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
Minimize to Tray
</button>
<button
onclick={onClose}
class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
>
Close Application
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
</style>
+13 -3
View File
@@ -3,8 +3,9 @@
import { get } from "svelte/store";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
import { characterState, characterInfo } from "$lib/stores/character";
import { isStreamerMode } from "$lib/stores/config";
import { isStreamerMode, configStore } from "$lib/stores/config";
import { handleNewUserMessage } from "$lib/notifications/rules";
import { setSkipNextGreeting } from "$lib/tauri";
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
interface Props {
@@ -13,6 +14,9 @@
let { onExpand }: Props = $props();
const configValues = configStore.config;
const hasBackgroundImage = $derived($configValues.background_image_path !== null);
let inputValue = $state("");
let isSubmitting = $state(false);
let isConnected = $state(false);
@@ -127,8 +131,11 @@
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");
claudeStore.addLine("system", "Process interrupted via stop button");
characterState.setState("idle");
} catch (error) {
console.error("Failed to interrupt:", error);
@@ -146,7 +153,10 @@
}
</script>
<div class="compact-container {getStateGlow()}">
<div
class="compact-container {getStateGlow()}"
style={hasBackgroundImage ? "background: transparent !important;" : ""}
>
<!-- Character sprite (smaller) -->
<div class="compact-character">
<div class="sprite-wrapper {getAnimationClass()}">
+448 -16
View File
@@ -12,6 +12,9 @@
} from "$lib/stores/config";
import { claudeStore } from "$lib/stores/claude";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import CostSummary from "./CostSummary.svelte";
let config: HikariConfig = $state({
model: null,
@@ -25,7 +28,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,10 +47,37 @@
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,
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
});
let showCustomThemeEditor = $state(false);
interface AuthStatus {
is_logged_in: boolean;
email: string | null;
org_id: string | null;
org_name: string | null;
api_key_source: string | null;
api_provider: string | null;
subscription_type: string | null;
}
let authStatus: AuthStatus | null = $state(null);
let authLoading = $state(false);
let authActionLoading = $state(false);
let authError: string | null = $state(null);
let isOpen = $state(false);
let isSaving = $state(false);
let saveError: string | null = $state(null);
@@ -62,6 +91,9 @@
configStore.isSidebarOpen.subscribe((open) => {
isOpen = open;
if (open && authStatus === null) {
void refreshAuthStatus();
}
});
configStore.saveError.subscribe((error) => {
@@ -74,8 +106,22 @@
const availableModels = [
{ value: "", label: "Default (from ~/.claude)" },
// Current generation (Claude 4.6)
{ value: "claude-opus-4-6", label: "Claude Opus 4.6 (Most Capable)" },
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 (Recommended)" },
// Previous generation (Claude 4.5)
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" },
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5" },
// Previous generation (Claude 4.x)
{ value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" },
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
{ value: "claude-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 = [
@@ -90,6 +136,44 @@
"Task",
];
async function refreshAuthStatus() {
authLoading = true;
authError = null;
try {
authStatus = await invoke<AuthStatus>("get_auth_status");
} catch (e) {
authError = String(e);
} finally {
authLoading = false;
}
}
async function handleAuthLogin() {
authActionLoading = true;
authError = null;
try {
await invoke<string>("auth_login");
await refreshAuthStatus();
} catch (e) {
authError = String(e);
} finally {
authActionLoading = false;
}
}
async function handleAuthLogout() {
authActionLoading = true;
authError = null;
try {
await invoke<string>("auth_logout");
await refreshAuthStatus();
} catch (e) {
authError = String(e);
} finally {
authActionLoading = false;
}
}
async function handleSave() {
isSaving = true;
saveError = null;
@@ -161,6 +245,20 @@
await window.setAlwaysOnTop(enabled);
await configStore.updateConfig({ always_on_top: enabled });
}
async function pickBackgroundImage() {
const selected = await open({
multiple: false,
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "gif", "avif"] }],
});
if (selected) {
config.background_image_path = selected;
}
}
function clearBackgroundImage() {
config.background_image_path = null;
}
</script>
<!-- Backdrop -->
@@ -207,6 +305,109 @@
</div>
{/if}
<!-- Account Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Account
</h3>
{#if authLoading}
<div class="text-sm text-[var(--text-secondary)] py-2">Checking auth status...</div>
{:else if authStatus}
<div class="flex items-center gap-2 mb-3">
<span
class="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 {authStatus.is_logged_in
? 'bg-green-500'
: 'bg-red-500'}"
></span>
<span class="text-sm font-medium text-[var(--text-primary)]">
{authStatus.is_logged_in ? "Logged in" : "Not logged in"}
</span>
</div>
{#if authStatus.email || authStatus.org_name || authStatus.api_key_source || config.api_key}
<dl class="text-xs space-y-1 mb-3">
{#if authStatus.email}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Email</dt>
<dd class="text-[var(--text-primary)] break-all">{authStatus.email}</dd>
</div>
{/if}
{#if authStatus.org_name}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Org</dt>
<dd class="text-[var(--text-primary)]">{authStatus.org_name}</dd>
</div>
{/if}
{#if authStatus.org_id}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Org UUID</dt>
<dd class="text-[var(--text-secondary)] font-mono text-[10px] break-all">
{authStatus.org_id}
</dd>
</div>
{/if}
{#if authStatus.api_key_source}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">API key</dt>
<dd class="text-[var(--text-primary)]">{authStatus.api_key_source}</dd>
</div>
{/if}
{#if authStatus.subscription_type}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Plan</dt>
<dd class="text-[var(--text-primary)]">{authStatus.subscription_type}</dd>
</div>
{/if}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Override</dt>
<dd class="text-[var(--text-primary)]">
{#if config.api_key}
{config.streamer_mode ? "Custom key set 🔒" : "Custom key set"}
{:else}
None
{/if}
</dd>
</div>
</dl>
{/if}
{:else}
<div class="text-sm text-[var(--text-secondary)] py-2">Auth status unavailable</div>
{/if}
{#if authError}
<div class="mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-red-400 text-xs">
{authError}
</div>
{/if}
<div class="flex gap-2">
<button
onclick={refreshAuthStatus}
disabled={authLoading || authActionLoading}
class="px-3 py-1.5 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-secondary)] hover:border-[var(--accent-primary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
Refresh
</button>
{#if authStatus && !authStatus.is_logged_in}
<button
onclick={handleAuthLogin}
disabled={authActionLoading}
class="btn-trans-gradient px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
>
{authActionLoading ? "Logging in..." : "Login"}
</button>
{:else if authStatus && authStatus.is_logged_in}
<button
onclick={handleAuthLogout}
disabled={authActionLoading}
class="px-3 py-1.5 text-sm bg-red-500/20 border border-red-500/50 rounded-lg text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50"
>
{authActionLoading ? "Logging out..." : "Logout"}
</button>
{/if}
</div>
</section>
<!-- Agent Settings Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
@@ -301,6 +502,37 @@
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none"
></textarea>
</div>
<!-- Worktree Isolation -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.use_worktree}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
/>
<span class="text-sm text-[var(--text-primary)]">Worktree isolation</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Launch sessions with <code class="font-mono">--worktree</code> for isolated git worktree environments
</p>
</div>
<!-- Disable 1M Context Window -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.disable_1m_context}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
/>
<span class="text-sm text-[var(--text-primary)]">Disable 1M context window</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Sets <code class="font-mono">CLAUDE_CODE_DISABLE_1M_CONTEXT=1</code> to opt out of the extended
context window
</p>
</div>
</section>
<!-- Greeting Section -->
@@ -684,6 +916,68 @@
Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset
</p>
</div>
<!-- Show Thinking Blocks Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.show_thinking_blocks}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
/>
<span class="text-sm text-[var(--text-primary)]">Show Extended Thinking Blocks</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Display Claude's extended thinking process in the conversation. Thinking blocks can be
expanded/collapsed to see reasoning details.
</p>
</div>
<!-- Background Image -->
<div class="mb-4">
<span class="block text-sm text-[var(--text-secondary)] mb-2">Background Image</span>
{#if config.background_image_path}
<p class="text-xs text-[var(--text-tertiary)] font-mono mb-2 truncate">
{config.background_image_path.split("/").pop()}
</p>
{/if}
<div class="flex gap-2">
<button
onclick={pickBackgroundImage}
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
>
{config.background_image_path ? "Change Image" : "Choose Image"}
</button>
{#if config.background_image_path}
<button
onclick={clearBackgroundImage}
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
title="Remove background image"
>
Clear
</button>
{/if}
</div>
{#if config.background_image_path}
<div class="mt-3">
<div class="flex items-center justify-between mb-1">
<label for="bg-opacity" class="text-xs text-[var(--text-secondary)]"> Opacity </label>
<span class="text-xs text-[var(--text-tertiary)]">
{Math.round(config.background_image_opacity * 100)}%
</span>
</div>
<input
id="bg-opacity"
type="range"
bind:value={config.background_image_opacity}
min="0.05"
max="1"
step="0.05"
class="w-full h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
/>
</div>
{/if}
</div>
</section>
<!-- Window Section -->
@@ -708,21 +1002,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 +1057,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 +1231,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
+117 -13
View File
@@ -12,6 +12,25 @@
let editingTabId = $state<string | null>(null);
let editingName = $state("");
// Tab order for pointer-drag reordering
let tabOrder = $state<string[]>([]);
let draggedId = $state<string | null>(null);
let dragOverId = $state<string | null>(null);
let dragStartX = 0;
let isDragging = false;
let wasDragged = false;
let tabsRef = $state<HTMLElement | null>(null);
// Keep tabOrder in sync with conversations map (add new, remove deleted)
$effect(() => {
const currentIds = Array.from($conversations.keys());
const validIds = tabOrder.filter((id) => currentIds.includes(id));
const newIds = currentIds.filter((id) => !tabOrder.includes(id));
if (validIds.length !== tabOrder.length || newIds.length > 0) {
tabOrder = [...validIds, ...newIds];
}
});
// Track last seen message count for each conversation
let lastSeenMessageCount = new SvelteMap<string, number>();
@@ -138,8 +157,73 @@
}
}
async function handleTabClick(id: string) {
if (wasDragged) {
wasDragged = false;
return;
}
await switchTab(id);
}
function handlePointerDown(event: PointerEvent, id: string) {
if (editingTabId === id) return;
draggedId = id;
dragStartX = event.clientX;
isDragging = false;
wasDragged = false;
function onMove(e: PointerEvent) {
if (!isDragging && Math.abs(e.clientX - dragStartX) > 5) {
isDragging = true;
}
if (!isDragging || !tabsRef) return;
const tabs = tabsRef.querySelectorAll<HTMLElement>("[data-tab-id]");
dragOverId = null;
for (const tab of tabs) {
const rect = tab.getBoundingClientRect();
if (e.clientX >= rect.left && e.clientX <= rect.right) {
const tabId = tab.dataset.tabId;
if (tabId && tabId !== id) {
dragOverId = tabId;
}
break;
}
}
}
function onUp() {
if (isDragging && draggedId && dragOverId && draggedId !== dragOverId) {
const order = [...tabOrder];
const fromIndex = order.indexOf(draggedId);
const toIndex = order.indexOf(dragOverId);
order.splice(fromIndex, 1);
order.splice(toIndex, 0, draggedId);
tabOrder = order;
wasDragged = true;
}
draggedId = null;
dragOverId = null;
isDragging = false;
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
}
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
}
// Keyboard shortcuts
onMount(() => {
// Initialise all conversations as seen on mount so that remounting
// this component (e.g. after closing the file editor) doesn't falsely
// mark existing messages as unread.
for (const [id, conversation] of $conversations) {
lastSeenMessageCount.set(id, conversation.terminalLines.length);
}
lastSeenMessageCount = lastSeenMessageCount;
function handleGlobalKeydown(event: KeyboardEvent) {
// Ctrl/Cmd + T: New tab
if ((event.ctrlKey || event.metaKey) && event.key === "t") {
@@ -165,21 +249,19 @@
// Ctrl/Cmd + Tab: Next tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
event.preventDefault();
const tabs = Array.from($conversations.keys());
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
if (currentIndex !== -1) {
const nextIndex = (currentIndex + 1) % tabs.length;
claudeStore.switchConversation(tabs[nextIndex]);
const nextIndex = (currentIndex + 1) % tabOrder.length;
claudeStore.switchConversation(tabOrder[nextIndex]);
}
}
// Ctrl/Cmd + Shift + Tab: Previous tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
event.preventDefault();
const tabs = Array.from($conversations.keys());
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
if (currentIndex !== -1) {
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
claudeStore.switchConversation(tabs[prevIndex]);
const prevIndex = (currentIndex - 1 + tabOrder.length) % tabOrder.length;
claudeStore.switchConversation(tabOrder[prevIndex]);
}
}
}
@@ -190,15 +272,22 @@
</script>
<div
bind:this={tabsRef}
class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
>
{#each Array.from($conversations.entries()) as [id, conversation] (id)}
{#each tabOrder
.filter((id) => $conversations.has(id))
.map((id) => ({ id, conversation: $conversations.get(id)! })) as { id, conversation } (id)}
<div
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all
data-tab-id={id}
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t transition-all
{id === $activeConversationId
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
onclick={() => switchTab(id)}
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}
{dragOverId === id && draggedId !== id ? 'drag-over' : ''}
{draggedId === id ? 'dragging' : ''}"
onpointerdown={(e) => handlePointerDown(e, id)}
onclick={() => handleTabClick(id)}
onkeydown={(e) => handleTabKeydown(id, e)}
role="tab"
tabindex={0}
@@ -211,7 +300,7 @@
onblur={saveTabName}
onkeydown={handleKeydown}
onclick={(e) => e.stopPropagation()}
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32"
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32 select-text"
/>
{:else}
<div class="flex items-center gap-2">
@@ -296,5 +385,20 @@
.tab-item {
min-width: 100px;
cursor: grab;
touch-action: none;
user-select: none;
}
.tab-item:active {
cursor: grabbing;
}
.drag-over {
border-left: 2px solid var(--accent-primary);
}
.dragging {
opacity: 0.4;
}
</style>
+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>
+134 -7
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,
@@ -17,6 +17,8 @@
} from "$lib/stores/historyRestore";
import MessageModeSelector from "$lib/components/MessageModeSelector.svelte";
import SlashCommandMenu from "$lib/components/SlashCommandMenu.svelte";
import SystemClock from "$lib/components/SystemClock.svelte";
import CliVersion from "$lib/components/CliVersion.svelte";
import { getCurrentMode } from "$lib/stores/messageMode";
import { formatMessageWithMode } from "$lib/types/messageMode";
import {
@@ -26,10 +28,13 @@
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";
import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte";
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
import type { Attachment } from "$lib/types/messages";
const INPUT_HISTORY_KEY = "hikari-input-history";
@@ -49,6 +54,30 @@
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);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
function handleContextMenu(event: MouseEvent) {
event.preventDefault();
contextMenuShow = true;
contextMenuX = event.clientX;
contextMenuY = event.clientY;
}
function closeContextMenu() {
contextMenuShow = false;
}
isStreamerMode.subscribe((value) => {
streamerModeActive = value;
});
@@ -135,6 +164,17 @@
attachments = storedAttachments;
});
// Per-tab draft persistence — restore the draft text whenever the active
// conversation changes, and save it back on every keystroke.
claudeStore.activeConversationId.subscribe((conversationId) => {
if (conversationId) {
const conv = get(claudeStore.conversations).get(conversationId);
inputValue = conv?.draftText ?? "";
} else {
inputValue = "";
}
});
function handleInputChange() {
// If input is empty, allow history navigation again
// Otherwise, mark that user has manually typed
@@ -147,6 +187,12 @@
historyIndex = -1;
tempInput = "";
// Save the current draft so it persists if the user switches tabs.
const activeId = get(claudeStore.activeConversationId);
if (activeId) {
claudeStore.setDraftText(activeId, inputValue);
}
if (isSlashCommand(inputValue)) {
matchingCommands = getMatchingCommands(inputValue);
showCommandMenu = matchingCommands.length > 0;
@@ -297,7 +343,7 @@ User: ${formattedMessage}`;
throw new Error("No active conversation");
}
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted - reconnecting...");
claudeStore.addLine("system", "Process interrupted via stop button — reconnecting...");
characterState.setState("idle");
// Show connecting status while we reconnect
@@ -311,19 +357,41 @@ 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,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
},
});
// Update Discord RPC when reconnecting
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
} catch (reconnectError) {
console.error("Failed to auto-reconnect:", reconnectError);
claudeStore.addLine("error", `Failed to reconnect: ${reconnectError}`);
@@ -409,11 +477,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;
@@ -547,11 +616,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);
}
@@ -609,11 +679,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);
}
@@ -864,6 +935,9 @@ User: ${formattedMessage}`;
</svg>
<span>Clipboard</span>
</button>
<CliVersion />
<SystemClock />
</div>
<div class="input-row">
@@ -876,10 +950,12 @@ User: ${formattedMessage}`;
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="resize-handle" onmousedown={handleResizeStart} title="Drag to resize"></div>
<textarea
bind:this={textareaElement}
bind:value={inputValue}
onkeydown={handleKeyDown}
oninput={handleInputChange}
onpaste={handlePaste}
oncontextmenu={handleContextMenu}
placeholder={isConnected
? "Ask Hikari anything... (type / for commands)"
: "Connect to Claude first..."}
@@ -893,6 +969,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"
@@ -958,6 +1041,15 @@ User: ${formattedMessage}`;
/>
{/if}
{#if contextMenuShow && textareaElement}
<TextInputContextMenu
x={contextMenuX}
y={contextMenuY}
inputElement={textareaElement}
onClose={closeContextMenu}
/>
{/if}
<style>
.input-bar {
display: flex;
@@ -998,6 +1090,7 @@ User: ${formattedMessage}`;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.control-button {
@@ -1014,6 +1107,18 @@ User: ${formattedMessage}`;
transition: all 0.2s;
font-size: 14px;
white-space: nowrap;
flex-shrink: 0;
}
/* Hide button text on smaller screens, show icons only */
@media (max-width: 640px) {
.control-button span {
display: none;
}
.control-button {
padding: 10px;
min-width: 40px;
}
}
.control-button:hover {
@@ -1109,6 +1214,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 {
@@ -12,6 +12,7 @@
{ keys: ["Escape"], description: "Close modals and panels" },
{ keys: ["Ctrl", "L"], description: "Clear the terminal" },
{ keys: ["Ctrl", ","], description: "Open settings" },
{ keys: ["Ctrl", "E"], description: "Toggle file editor" },
{ keys: ["Ctrl", "Shift", "M"], description: "Toggle compact mode" },
{ keys: ["Ctrl", "Shift", "S"], description: "Toggle streamer mode" },
],
@@ -26,6 +27,17 @@
{ keys: ["↓"], description: "Next input from history" },
],
},
{
category: "File Editor",
items: [
{ keys: ["Ctrl", "E"], description: "Toggle editor view" },
{ keys: ["Ctrl", "B"], description: "Toggle file browser" },
{ keys: ["Ctrl", "S"], description: "Save current file" },
{ keys: ["Ctrl", "W"], description: "Close current tab" },
{ keys: ["Ctrl", "N"], description: "New file" },
{ keys: ["Right-click"], description: "Context menu (New/Delete)" },
],
},
{
category: "Slash Commands",
items: [
+13 -2
View File
@@ -35,7 +35,12 @@
};
renderer.codespan = ({ text }) => {
return `<code class="hljs-inline">${text}</code>`;
const escaped = text.replace(/</g, "&lt;").replace(/>/g, "&gt;");
return `<code class="hljs-inline">${escaped}</code>`;
};
renderer.html = ({ text }) => {
return text.replace(/</g, "&lt;").replace(/>/g, "&gt;");
};
marked.setOptions({
@@ -276,10 +281,16 @@
font-family: "JetBrains Mono", "Fira Code", monospace;
}
.markdown-content :global(ul),
.markdown-content :global(ul) {
margin: 0.5em 0;
padding-left: 1.5em;
list-style-type: disc;
}
.markdown-content :global(ol) {
margin: 0.5em 0;
padding-left: 1.5em;
list-style-type: decimal;
}
.markdown-content :global(li) {
@@ -0,0 +1,416 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { onMount } from "svelte";
import { Trash2, RefreshCw, Server, Globe, Terminal } from "lucide-svelte";
interface Props {
onClose: () => void;
}
interface McpServerInfo {
name: string;
command: string | null;
url: string | null;
transport: string; // "stdio", "http", or "sse"
env: Record<string, string> | null;
status: string | null; // "Connected" or "Failed to connect"
}
const { onClose }: Props = $props();
let servers = $state<McpServerInfo[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
let selectedServer = $state<McpServerInfo | null>(null);
let isLoadingDetails = $state(false);
let actionInProgress = $state<string | null>(null);
let showAddForm = $state(false);
let serverDetails = $state<string>("");
// Add server form fields
let newServerName = $state("");
let newServerUrl = $state("");
let newServerTransport = $state("stdio");
let isAdding = $state(false);
async function loadServers(): Promise<void> {
try {
isLoading = true;
error = null;
servers = await invoke<McpServerInfo[]>("list_mcp_servers");
} catch (e) {
error = `Failed to load MCP servers: ${e}`;
console.error(error);
} finally {
isLoading = false;
}
}
async function loadServerDetails(name: string): Promise<void> {
try {
isLoadingDetails = true;
error = null;
selectedServer = await invoke<McpServerInfo>("get_mcp_server", { name });
serverDetails = await invoke<string>("get_mcp_server_details", { name });
} catch (e) {
error = `Failed to load server details: ${e}`;
console.error(error);
} finally {
isLoadingDetails = false;
}
}
async function removeServer(name: string): Promise<void> {
try {
actionInProgress = name;
error = null;
await invoke("remove_mcp_server", { name });
if (selectedServer?.name === name) {
selectedServer = null;
serverDetails = "";
}
await loadServers();
} catch (e) {
error = `Failed to remove server: ${e}`;
console.error(error);
} finally {
actionInProgress = null;
}
}
async function addServer(): Promise<void> {
if (!newServerName.trim() || !newServerUrl.trim()) return;
try {
isAdding = true;
error = null;
await invoke("add_mcp_server", {
name: newServerName.trim(),
commandOrUrl: newServerUrl.trim(),
transport: newServerTransport,
envVars: null,
headers: null,
});
newServerName = "";
newServerUrl = "";
newServerTransport = "stdio";
showAddForm = false;
await loadServers();
} catch (e) {
error = `Failed to add server: ${e}`;
console.error(error);
} finally {
isAdding = false;
}
}
function getTransportIcon(transport: string) {
switch (transport) {
case "http":
return Globe;
case "stdio":
return Terminal;
case "sse":
return Server;
default:
return Server;
}
}
function getTransportColor(transport: string) {
switch (transport) {
case "http":
return "text-blue-400";
case "stdio":
return "text-green-400";
case "sse":
return "text-purple-400";
default:
return "text-[var(--text-secondary)]";
}
}
onMount(() => {
loadServers();
});
</script>
<div
class="fixed top-0 right-0 h-full w-[700px] bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
>
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
<div class="flex items-center gap-3">
<div class="text-[var(--accent-primary)]">
<Server class="w-6 h-6" />
</div>
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">MCP Server Management</h2>
<p class="text-xs text-[var(--text-secondary)]">
{servers.length} server{servers.length !== 1 ? "s" : ""} configured
</p>
</div>
</div>
<button
onclick={onClose}
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
aria-label="Close MCP panel"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<!-- Add Server Button -->
<div class="p-4 border-b border-[var(--border-color)]">
<button
onclick={() => (showAddForm = !showAddForm)}
class="w-full px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 transition-opacity flex items-center justify-center gap-2"
>
<Server class="w-4 h-4" />
{showAddForm ? "Cancel" : "Add New Server"}
</button>
</div>
<!-- Add Server Form -->
{#if showAddForm}
<div
class="mx-4 mt-4 p-4 bg-[var(--bg-secondary)]/50 border border-[var(--border-color)] rounded-lg"
>
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-3">Add MCP Server</h3>
<div class="space-y-3">
<div>
<label
for="mcp-new-name"
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
>Server Name</label
>
<input
id="mcp-new-name"
type="text"
bind:value={newServerName}
placeholder="my-server"
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
/>
</div>
<div>
<label
for="mcp-new-transport"
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
>Transport</label
>
<select
id="mcp-new-transport"
bind:value={newServerTransport}
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
>
<option value="stdio">STDIO</option>
<option value="http">HTTP</option>
<option value="sse">SSE</option>
</select>
</div>
<div>
<label
for="mcp-new-url"
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
>
{newServerTransport === "stdio" ? "Command" : "URL"}
</label>
<input
id="mcp-new-url"
type="text"
bind:value={newServerUrl}
placeholder={newServerTransport === "stdio"
? "npx my-mcp-server"
: "https://mcp.example.com"}
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
/>
</div>
<button
onclick={addServer}
disabled={isAdding || !newServerName.trim() || !newServerUrl.trim()}
class="w-full px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{#if isAdding}
<RefreshCw class="w-4 h-4 animate-spin" />
{:else}
<Server class="w-4 h-4" />
{/if}
Add Server
</button>
</div>
</div>
{/if}
<!-- Error Display -->
{#if error}
<div class="mx-4 mt-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
<p class="text-sm text-red-400">{error}</p>
</div>
{/if}
<!-- Content -->
<div class="flex-1 overflow-y-auto p-4 flex gap-4">
<!-- Server List -->
<div class="flex-1">
{#if isLoading}
<div class="flex items-center justify-center h-full text-[var(--text-secondary)]">
<RefreshCw class="w-8 h-8 animate-spin" />
</div>
{:else if servers.length === 0}
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
<Server class="w-16 h-16 mb-4 opacity-50" />
<p class="text-center">No MCP servers configured</p>
<p class="text-sm text-center mt-2">Add servers via Settings</p>
</div>
{:else}
<div class="space-y-2">
{#each servers as server (server.name)}
{@const TransportIcon = getTransportIcon(server.transport)}
<button
onclick={() => loadServerDetails(server.name)}
class="w-full bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all text-left"
class:border-[var(--accent-primary)]={selectedServer?.name === server.name}
>
<div class="flex items-start justify-between">
<div class="flex-1">
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
<TransportIcon class="w-4 h-4 {getTransportColor(server.transport)}" />
{server.name}
{#if server.status}
{#if server.status.includes("Connected")}
<span
class="px-2 py-0.5 bg-[var(--success-color)]/20 text-[var(--success-color)] text-xs rounded border border-[var(--success-color)]/30"
>
</span>
{:else}
<span
class="px-2 py-0.5 bg-red-500/20 text-red-400 text-xs rounded border border-red-500/30"
>
</span>
{/if}
{/if}
</h4>
<p class="text-xs text-[var(--text-secondary)] mt-1">
{server.transport.toUpperCase()}
{#if server.url}
{server.url}
{:else if server.command}
{server.command}
{/if}
</p>
</div>
</div>
</button>
{/each}
</div>
{/if}
</div>
<!-- Server Details Panel -->
{#if selectedServer}
<div
class="w-80 bg-[var(--bg-secondary)]/50 rounded-lg p-4 border border-[var(--border-color)]"
>
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Server Details</h3>
{#if isLoadingDetails}
<div class="flex items-center justify-center h-32">
<RefreshCw class="w-6 h-6 animate-spin text-[var(--text-secondary)]" />
</div>
{:else}
{@const TransportIcon = getTransportIcon(selectedServer.transport)}
<div class="space-y-4">
<!-- Name -->
<div>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Name</p>
<p class="text-sm text-[var(--text-primary)] mt-1">{selectedServer.name}</p>
</div>
<!-- Transport -->
<div>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Transport</p>
<p class="text-sm text-[var(--text-primary)] mt-1 flex items-center gap-2">
<TransportIcon class="w-4 h-4 {getTransportColor(selectedServer.transport)}" />
{selectedServer.transport.toUpperCase()}
</p>
</div>
<!-- URL or Command -->
{#if selectedServer.url}
<div>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">URL</p>
<p
class="text-sm text-[var(--text-primary)] mt-1 break-all font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
>
{selectedServer.url}
</p>
</div>
{/if}
{#if selectedServer.command}
<div>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Command</p>
<p
class="text-sm text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
>
{selectedServer.command}
</p>
</div>
{/if}
<!-- Environment Variables -->
{#if selectedServer.env}
<div>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
Environment
</p>
<pre
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto">{JSON.stringify(
selectedServer.env,
null,
2
)}</pre>
</div>
{/if}
<!-- Full Server Details -->
{#if serverDetails}
<div>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
Full Details
</p>
<pre
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto whitespace-pre-wrap">{serverDetails}</pre>
</div>
{/if}
<!-- Actions -->
<div class="pt-4 border-t border-[var(--border-color)]">
<button
onclick={() => selectedServer && removeServer(selectedServer.name)}
disabled={actionInProgress === selectedServer?.name}
class="w-full px-4 py-2 bg-red-500/20 border border-red-500/30 rounded-lg text-sm text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<Trash2 class="w-4 h-4" />
Remove Server
</button>
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>
@@ -0,0 +1,458 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import Markdown from "./Markdown.svelte";
let memoryFiles: string[] = $state([]);
let selectedFile: string | null = $state(null);
let fileContent: string = $state("");
let isLoading = $state(false);
let error: string | null = $state(null);
let isPanelOpen = $state(false);
interface MemoryFilesResponse {
files: string[];
}
async function loadMemoryFiles() {
isLoading = true;
error = null;
try {
const response = await invoke<MemoryFilesResponse>("list_memory_files");
memoryFiles = response.files;
} catch (e) {
error = `Failed to load memory files: ${e}`;
console.error(error);
} finally {
isLoading = false;
}
}
async function loadFileContent(filePath: string) {
isLoading = true;
error = null;
try {
// Use our backend command instead of Tauri plugin to handle WSL paths
const content = await invoke<string>("read_file_content", { path: filePath });
fileContent = content;
selectedFile = filePath;
} catch (e) {
error = `Failed to read file: ${e}`;
console.error(error);
fileContent = "";
} finally {
isLoading = false;
}
}
function getFileName(path: string): string {
return path.split("/").pop() || path;
}
function togglePanel() {
isPanelOpen = !isPanelOpen;
if (isPanelOpen && memoryFiles.length === 0) {
loadMemoryFiles();
}
}
onMount(() => {
// Don't load on mount - only when panel is opened
});
</script>
<button class="memory-toggle" onclick={togglePanel} title="Memory Browser">
<svg
class="icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
<span class="label">Memory</span>
</button>
{#if isPanelOpen}
<div class="memory-panel">
<div class="panel-header">
<div class="header-title">
<svg
class="header-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
<h3>Memory Files</h3>
</div>
<button class="close-btn" onclick={togglePanel} title="Close">
<svg
class="close-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="panel-content">
{#if isLoading && memoryFiles.length === 0}
<div class="loading">
<svg
class="spinner"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Loading memory files...
</div>
{:else if error}
<div class="error">
<p>{error}</p>
<button class="retry-btn" onclick={loadMemoryFiles}>Retry</button>
</div>
{:else if memoryFiles.length === 0}
<div class="empty">
<p>No memory files found.</p>
<p class="hint">
Memory files are created automatically as I learn from our conversations!
</p>
</div>
{:else}
<div class="panel-layout">
<div class="file-list">
{#each memoryFiles as file (file)}
<button
class="file-item"
class:active={selectedFile === file}
onclick={() => loadFileContent(file)}
>
<svg
class="file-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span class="file-name">{getFileName(file)}</span>
</button>
{/each}
</div>
<div class="file-viewer">
{#if selectedFile && fileContent}
<div class="viewer-header">
<h4>{getFileName(selectedFile)}</h4>
</div>
<div class="viewer-content">
<Markdown content={fileContent} />
</div>
{:else if selectedFile && isLoading}
<div class="loading-file">
<svg
class="spinner"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Loading file...
</div>
{:else}
<div class="no-selection">
<p>Select a memory file to view its contents</p>
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}
<style>
.memory-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
}
.memory-toggle:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.icon {
width: 1.25rem;
height: 1.25rem;
}
.label {
font-size: 0.875rem;
font-weight: 500;
}
.memory-panel {
position: fixed;
top: 0;
right: 0;
width: 600px;
height: 100vh;
background: var(--bg-primary);
border-left: 1px solid var(--border-color);
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.header-title {
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-icon {
width: 1.5rem;
height: 1.5rem;
color: var(--accent-primary);
}
.panel-header h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.close-btn {
padding: 0.5rem;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: 4px;
transition: all 0.2s ease;
}
.close-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.close-icon {
width: 1.25rem;
height: 1.25rem;
}
.panel-content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.loading,
.error,
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 3rem 1.5rem;
text-align: center;
color: var(--text-secondary);
}
.spinner {
width: 2.5rem;
height: 2.5rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.error p {
color: var(--terminal-error, #f87171);
}
.retry-btn {
padding: 0.5rem 1rem;
background: var(--accent-primary);
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
}
.retry-btn:hover {
opacity: 0.9;
}
.hint {
font-size: 0.875rem;
font-style: italic;
max-width: 400px;
}
.panel-layout {
display: grid;
grid-template-columns: 200px 1fr;
gap: 1.5rem;
height: 100%;
}
.file-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.file-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
cursor: pointer;
text-align: left;
transition: all 0.2s ease;
}
.file-item:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.file-item.active {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: white;
}
.file-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
.file-name {
font-size: 0.875rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-viewer {
display: flex;
flex-direction: column;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.viewer-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-primary);
}
.viewer-header h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.viewer-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
color: var(--text-primary);
}
.loading-file,
.no-selection {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 3rem 1.5rem;
color: var(--text-secondary);
}
</style>
+208 -97
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 grantedToolsList: string[] = $state([]);
let workingDirectory = $state("");
let permissions: PermissionRequest[] = [];
let selectedPermissions = new SvelteSet<string>();
let grantedToolsList: string[] = [];
let workingDirectory = "";
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,105 @@
});
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,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
},
});
// Update Discord RPC when reconnecting after permission grant
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
// Wait for connection to establish
await new Promise((resolve) => setTimeout(resolve, 1000));
// Send conversation context to restore state
if (conversationHistory) {
const blockedActions = selectedPerms
.map((p) => `- ${p.tool} with input:\n${JSON.stringify(p.input, null, 2)}`)
.join("\n\n");
const contextMessage = `[CONTEXT RESTORATION]
I just granted you permission to use ${selectedPerms.length} tool(s): ${toolNames}. Here's our conversation so far:
${conversationHistory}
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 +151,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 +182,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>
@@ -0,0 +1,432 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { onMount } from "svelte";
import { Download, Trash2, Power, PowerOff, RefreshCw } from "lucide-svelte";
interface Props {
onClose: () => void;
}
interface PluginInfo {
name: string;
version: string;
description: string | null;
enabled: boolean;
}
interface MarketplaceInfo {
name: string;
source: string;
}
const { onClose }: Props = $props();
let plugins = $state<PluginInfo[]>([]);
let marketplaces = $state<MarketplaceInfo[]>([]);
let isLoading = $state(true);
let isLoadingMarketplaces = $state(false);
let error = $state<string | null>(null);
let newPluginName = $state("");
let isInstalling = $state(false);
let actionInProgress = $state<string | null>(null);
let showMarketplaces = $state(false);
let newMarketplaceSource = $state("");
let isAddingMarketplace = $state(false);
async function loadPlugins(): Promise<void> {
try {
isLoading = true;
error = null;
plugins = await invoke<PluginInfo[]>("list_plugins");
} catch (e) {
error = `Failed to load plugins: ${e}`;
console.error(error);
} finally {
isLoading = false;
}
}
async function loadMarketplaces(): Promise<void> {
try {
isLoadingMarketplaces = true;
error = null;
marketplaces = await invoke<MarketplaceInfo[]>("list_marketplaces");
} catch (e) {
error = `Failed to load marketplaces: ${e}`;
console.error(error);
} finally {
isLoadingMarketplaces = false;
}
}
async function installPlugin(): Promise<void> {
if (!newPluginName.trim()) return;
try {
isInstalling = true;
error = null;
await invoke("install_plugin", { pluginName: newPluginName.trim() });
newPluginName = "";
await loadPlugins();
} catch (e) {
error = `Failed to install plugin: ${e}`;
console.error(error);
} finally {
isInstalling = false;
}
}
async function uninstallPlugin(pluginName: string): Promise<void> {
try {
actionInProgress = pluginName;
error = null;
await invoke("uninstall_plugin", { pluginName });
await loadPlugins();
} catch (e) {
error = `Failed to uninstall plugin: ${e}`;
console.error(error);
} finally {
actionInProgress = null;
}
}
async function togglePlugin(plugin: PluginInfo): Promise<void> {
try {
actionInProgress = plugin.name;
error = null;
if (plugin.enabled) {
await invoke("disable_plugin", { pluginName: plugin.name });
} else {
await invoke("enable_plugin", { pluginName: plugin.name });
}
await loadPlugins();
} catch (e) {
error = `Failed to ${plugin.enabled ? "disable" : "enable"} plugin: ${e}`;
console.error(error);
} finally {
actionInProgress = null;
}
}
async function updatePlugin(pluginName: string): Promise<void> {
try {
actionInProgress = pluginName;
error = null;
await invoke("update_plugin", { pluginName });
await loadPlugins();
} catch (e) {
error = `Failed to update plugin: ${e}`;
console.error(error);
} finally {
actionInProgress = null;
}
}
async function addMarketplace(): Promise<void> {
if (!newMarketplaceSource.trim()) return;
try {
isAddingMarketplace = true;
error = null;
await invoke("add_marketplace", { source: newMarketplaceSource.trim() });
newMarketplaceSource = "";
await loadMarketplaces();
} catch (e) {
error = `Failed to add marketplace: ${e}`;
console.error(error);
} finally {
isAddingMarketplace = false;
}
}
async function removeMarketplace(name: string): Promise<void> {
try {
actionInProgress = name;
error = null;
await invoke("remove_marketplace", { name });
await loadMarketplaces();
} catch (e) {
error = `Failed to remove marketplace: ${e}`;
console.error(error);
} finally {
actionInProgress = null;
}
}
onMount(() => {
loadPlugins();
});
</script>
<div
class="fixed top-0 right-0 h-full w-[600px] bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
>
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
<div class="flex items-center gap-3">
<div class="text-[var(--accent-primary)]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Plugin Management</h2>
<p class="text-xs text-[var(--text-secondary)]">
{plugins.length} plugin{plugins.length !== 1 ? "s" : ""} installed
</p>
</div>
</div>
<button
onclick={onClose}
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
aria-label="Close plugin panel"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<!-- Install Plugin Section -->
<div class="p-4 border-b border-[var(--border-color)]">
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-2">Install New Plugin</h3>
<p class="text-xs text-[var(--text-secondary)] mb-3">
Enter plugin name (e.g., "macrodata" or "macrodata@macrodata" for specific marketplace)
</p>
<div class="flex gap-2">
<input
type="text"
bind:value={newPluginName}
placeholder="plugin-name or plugin@marketplace"
class="flex-1 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
onkeydown={(e) => e.key === "Enter" && installPlugin()}
disabled={isInstalling}
/>
<button
onclick={installPlugin}
disabled={isInstalling || !newPluginName.trim()}
class="px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
>
{#if isInstalling}
<RefreshCw class="w-4 h-4 animate-spin" />
{:else}
<Download class="w-4 h-4" />
{/if}
Install
</button>
</div>
</div>
<!-- Marketplace Management Section -->
<div class="p-4 border-b border-[var(--border-color)]">
<button
onclick={() => {
showMarketplaces = !showMarketplaces;
if (showMarketplaces && marketplaces.length === 0) {
loadMarketplaces();
}
}}
class="w-full text-left flex items-center justify-between text-sm font-medium text-[var(--text-primary)] hover:text-[var(--accent-primary)] transition-colors"
>
<span>Manage Marketplaces</span>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 transition-transform"
class:rotate-180={showMarketplaces}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if showMarketplaces}
<div class="mt-3 space-y-3">
<!-- Add Marketplace Form -->
<div>
<p class="text-xs text-[var(--text-secondary)] mb-2">
Add a marketplace from GitHub (e.g., "ascorbic/macrodata")
</p>
<div class="flex gap-2">
<input
type="text"
bind:value={newMarketplaceSource}
placeholder="owner/repo"
class="flex-1 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
onkeydown={(e) => e.key === "Enter" && addMarketplace()}
disabled={isAddingMarketplace}
/>
<button
onclick={addMarketplace}
disabled={isAddingMarketplace || !newMarketplaceSource.trim()}
class="px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
>
{#if isAddingMarketplace}
<RefreshCw class="w-4 h-4 animate-spin" />
{:else}
<Download class="w-4 h-4" />
{/if}
Add
</button>
</div>
</div>
<!-- Marketplaces List -->
{#if isLoadingMarketplaces}
<div class="flex items-center justify-center py-4">
<RefreshCw class="w-5 h-5 animate-spin text-[var(--text-secondary)]" />
</div>
{:else if marketplaces.length > 0}
<div class="space-y-2">
{#each marketplaces as marketplace (marketplace.name)}
<div
class="bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)]"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<h4 class="font-medium text-[var(--text-primary)]">{marketplace.name}</h4>
<p class="text-xs text-[var(--text-secondary)] mt-1">{marketplace.source}</p>
</div>
<button
onclick={() => removeMarketplace(marketplace.name)}
disabled={actionInProgress === marketplace.name}
class="px-2 py-1 text-red-400 hover:bg-red-500/20 rounded transition-colors disabled:opacity-40"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-[var(--text-secondary)] text-center py-4">
No marketplaces configured
</p>
{/if}
</div>
{/if}
</div>
<!-- Error Display -->
{#if error}
<div class="mx-4 mt-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
<p class="text-sm text-red-400">{error}</p>
</div>
{/if}
<!-- Plugins List -->
<div class="flex-1 overflow-y-auto p-4">
{#if isLoading}
<div class="flex items-center justify-center h-full text-[var(--text-secondary)]">
<RefreshCw class="w-8 h-8 animate-spin" />
</div>
{:else if plugins.length === 0}
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 mb-4 opacity-50"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
<p class="text-center">No plugins installed</p>
<p class="text-sm text-center mt-2">Install a plugin using the form above</p>
</div>
{:else}
<div class="space-y-3">
{#each plugins as plugin (plugin.name)}
<div
class="bg-[var(--bg-secondary)]/50 rounded-lg p-4 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all"
>
<div class="flex items-start justify-between mb-2">
<div class="flex-1">
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
{plugin.name}
{#if plugin.enabled}
<span
class="px-2 py-0.5 bg-[var(--success-color)]/20 text-[var(--success-color)] text-xs rounded border border-[var(--success-color)]/30"
>
Enabled
</span>
{:else}
<span
class="px-2 py-0.5 bg-[var(--text-secondary)]/20 text-[var(--text-secondary)] text-xs rounded border border-[var(--border-color)]"
>
Disabled
</span>
{/if}
</h4>
<p class="text-xs text-[var(--text-secondary)] mt-1">v{plugin.version}</p>
{#if plugin.description}
<p class="text-sm text-[var(--text-secondary)] mt-2">{plugin.description}</p>
{/if}
</div>
</div>
<div class="flex gap-2 mt-3">
<button
onclick={() => togglePlugin(plugin)}
disabled={actionInProgress === plugin.name}
class="flex-1 px-3 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{#if plugin.enabled}
<PowerOff class="w-4 h-4" />
Disable
{:else}
<Power class="w-4 h-4" />
Enable
{/if}
</button>
<button
onclick={() => updatePlugin(plugin.name)}
disabled={actionInProgress === plugin.name}
class="px-3 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
>
<RefreshCw class="w-4 h-4" />
Update
</button>
<button
onclick={() => uninstallPlugin(plugin.name)}
disabled={actionInProgress === plugin.name}
class="px-3 py-1.5 bg-red-500/20 border border-red-500/30 rounded text-sm text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
>
<Trash2 class="w-4 h-4" />
Uninstall
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
+97 -5
View File
@@ -15,9 +15,23 @@
let showDeleteConfirm = $state<string | null>(null);
let showExportMenu = $state<string | null>(null);
let isImporting = $state(false);
let showClearAllConfirm = $state(false);
const sessions = $derived(sessionsStore.sessions);
const isLoading = $derived(sessionsStore.isLoading);
let sessions = $state<SessionListItem[]>([]);
let isLoading = $state(false);
$effect(() => {
const unsubSessions = sessionsStore.sessions.subscribe((value) => {
sessions = value;
});
const unsubLoading = sessionsStore.isLoading.subscribe((value) => {
isLoading = value;
});
return () => {
unsubSessions();
unsubLoading();
};
});
onMount(() => {
sessionsStore.loadSessions();
@@ -121,6 +135,11 @@
}
}
async function handleClearAll(): Promise<void> {
await sessionsStore.clearAllSessions();
showClearAllConfirm = false;
}
function toggleExportMenu(sessionId: string): void {
if (showExportMenu === sessionId) {
showExportMenu = null;
@@ -186,6 +205,22 @@
</svg>
{isImporting ? "Importing..." : "Import"}
</button>
<button
onclick={() => (showClearAllConfirm = true)}
disabled={sessions.length === 0}
class="px-3 py-1.5 text-sm font-medium bg-red-500/10 text-red-500 border border-red-500/30 rounded hover:bg-red-500/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
title="Clear all sessions"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Clear All
</button>
{/if}
<button
onclick={onClose}
@@ -281,11 +316,11 @@
</div>
<div class="overflow-y-auto flex-1">
{#if $isLoading}
{#if isLoading}
<div class="flex items-center justify-center p-8">
<div class="text-[var(--text-tertiary)]">Loading sessions...</div>
</div>
{:else if $sessions.length === 0}
{:else if sessions.length === 0}
<div class="flex flex-col items-center justify-center p-8 text-center">
<svg
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
@@ -307,7 +342,7 @@
</div>
{:else}
<div class="divide-y divide-[var(--border-color)]">
{#each $sessions as session (session.id)}
{#each sessions as session (session.id)}
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
<div class="flex items-start justify-between gap-4">
<button class="flex-1 text-left" onclick={() => handleViewSession(session)}>
@@ -428,6 +463,63 @@
</div>
</div>
{#if showClearAllConfirm}
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
onclick={() => (showClearAllConfirm = false)}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && (showClearAllConfirm = false)}
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="bg-[var(--bg-primary)] border border-red-500/30 rounded-lg shadow-xl max-w-md w-full p-6"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="clear-all-title"
aria-describedby="clear-all-description"
tabindex="-1"
>
<div class="flex items-start gap-4">
<div class="flex-shrink-0 text-red-500">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div class="flex-1">
<h3 id="clear-all-title" class="text-lg font-semibold text-[var(--text-primary)] mb-2">
Clear All Sessions?
</h3>
<p id="clear-all-description" class="text-[var(--text-secondary)] mb-4">
This will permanently delete all {sessions.length} session{sessions.length === 1
? ""
: "s"}. This action cannot be undone.
</p>
<div class="flex justify-end gap-3">
<button
onclick={() => (showClearAllConfirm = false)}
class="px-4 py-2 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--bg-tertiary)] transition-colors"
>
Cancel
</button>
<button
onclick={handleClearAll}
class="px-4 py-2 text-sm font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
>
Clear All Sessions
</button>
</div>
</div>
</div>
</div>
</div>
{/if}
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
+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>
+397 -12
View File
@@ -13,6 +13,7 @@
import { get } from "svelte/store";
import { claudeStore } from "$lib/stores/claude";
import { configStore, type HikariConfig, isStreamerMode } from "$lib/stores/config";
import { editorStore } from "$lib/stores/editor";
import type { ConnectionStatus } from "$lib/types/messages";
import { onMount } from "svelte";
import StatsDisplay from "./StatsDisplay.svelte";
@@ -20,9 +21,25 @@
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 TodoPanel from "./TodoPanel.svelte";
import GitPanel from "./GitPanel.svelte";
import ProfilePanel from "./ProfilePanel.svelte";
import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
import CastPanel from "./CastPanel.svelte";
import PluginManagementPanel from "./PluginManagementPanel.svelte";
import McpManagementPanel from "./McpManagementPanel.svelte";
import { conversationsStore } from "$lib/stores/conversations";
import {
generateContextInjection,
createSummary,
sanitizeForJson,
} from "$lib/utils/conversationUtils";
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
import { debugConsoleStore } from "$lib/stores/debugConsole";
import WorkspaceTrustModal from "./WorkspaceTrustModal.svelte";
import type { WorkspaceHookInfo } from "$lib/types/messages";
const DISCORD_URL = "https://chat.nhcarrigan.com";
const DONATE_URL = "https://donate.nhcarrigan.com";
@@ -38,9 +55,18 @@
let showHelp = $state(false);
let showKeyboardShortcuts = $state(false);
let showSessionHistory = $state(false);
let showTodoPanel = $state(false);
let showGitPanel = $state(false);
let showProfile = $state(false);
let showAgentMonitor = $state(false);
let showCastPanel = $state(false);
let showPluginPanel = $state(false);
let showMcpPanel = $state(false);
let isSummarising = $state(false);
let showWorkspaceTrust = $state(false);
let pendingHookInfo: WorkspaceHookInfo | null = $state(null);
const progress = $derived($achievementProgress);
const activeAgentCount = $derived($runningAgentCount);
let currentConfig: HikariConfig = $state({
model: null,
api_key: null,
@@ -56,7 +82,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,
@@ -73,6 +98,18 @@
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,
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
});
let streamerModeActive = $state(false);
@@ -80,6 +117,15 @@
streamerModeActive = value;
});
let editorVisible = $state(false);
editorStore.isEditorVisible.subscribe((value) => {
editorVisible = value;
});
function toggleEditor() {
editorStore.toggleEditor();
}
onMount(async () => {
appVersion = await getVersion();
});
@@ -117,11 +163,7 @@
}
}
async function handleConnect() {
if (isConnecting || connectionStatus === "connected") return;
const targetDir = selectedDirectory || "/home/naomi";
async function doConnect(targetDir: string) {
// Combine session-granted tools with config auto-granted tools
const allAllowedTools = [
...new Set([...grantedToolsList, ...currentConfig.auto_granted_tools]),
@@ -141,14 +183,72 @@
custom_instructions: currentConfig.custom_instructions || null,
mcp_servers_json: currentConfig.mcp_servers_json || null,
allowed_tools: allAllowedTools,
use_worktree: currentConfig.use_worktree ?? false,
disable_1m_context: currentConfig.disable_1m_context ?? false,
},
});
// 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}`);
}
}
async function handleConnect() {
if (isConnecting || connectionStatus === "connected") return;
const targetDir = selectedDirectory || "/home/naomi";
if (currentConfig.trusted_workspaces?.includes(targetDir)) {
await doConnect(targetDir);
return;
}
try {
const hookInfo = await invoke<WorkspaceHookInfo>("check_workspace_hooks", {
workingDir: targetDir,
});
if (hookInfo.has_concerns) {
pendingHookInfo = hookInfo;
showWorkspaceTrust = true;
return;
}
} catch (error) {
// Fail open: if we can't check hooks, proceed with connection
console.error("Failed to check workspace hooks:", error);
}
await doConnect(targetDir);
}
async function handleTrustAndConnect() {
showWorkspaceTrust = false;
const targetDir = selectedDirectory || "/home/naomi";
pendingHookInfo = null;
const alreadyTrusted = currentConfig.trusted_workspaces?.includes(targetDir) ?? false;
if (!alreadyTrusted) {
await configStore.updateConfig({
trusted_workspaces: [...(currentConfig.trusted_workspaces ?? []), targetDir],
});
}
doConnect(targetDir);
}
function handleCancelConnect() {
showWorkspaceTrust = false;
pendingHookInfo = null;
}
async function handleDisconnect() {
try {
const conversationId = get(claudeStore.activeConversationId);
@@ -156,6 +256,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);
}
@@ -190,6 +293,111 @@
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,
use_worktree: currentConfig.use_worktree ?? false,
disable_1m_context: currentConfig.disable_1m_context ?? false,
},
});
// 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
@@ -230,16 +438,16 @@
{/if}
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 flex-wrap min-w-0">
{#if streamerModeActive}
<div
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse"
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse shrink-0"
title="Streamer mode active (Ctrl+Shift+S to toggle)"
></div>
{/if}
<button
onclick={() => (showProfile = true)}
class="p-1 text-gray-500 icon-trans-hover"
class="p-1 text-gray-500 icon-trans-hover shrink-0"
title="Profile"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -293,6 +501,20 @@
/>
</svg>
</button>
<button
onclick={() => (showTodoPanel = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Todo List"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
</button>
<button
onclick={() => (showGitPanel = true)}
class="p-1 text-gray-500 icon-trans-hover"
@@ -307,6 +529,90 @@
/>
</svg>
</button>
<button
onclick={() => (showPluginPanel = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Plugin Management"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
</button>
<button
onclick={() => (showMcpPanel = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="MCP Server Management"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
/>
</svg>
</button>
<button
onclick={toggleEditor}
disabled={connectionStatus !== "connected"}
class="p-1 text-gray-500 icon-trans-hover {editorVisible
? 'text-[var(--trans-pink)]'
: ''} disabled:opacity-40 disabled:cursor-not-allowed"
title={connectionStatus === "connected"
? "File Editor (Ctrl+E)"
: "Connect to enable file editor"}
>
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
onclick={() => (showCastPanel = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Meet the Team"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</button>
<button
onclick={() => (showAgentMonitor = !showAgentMonitor)}
class="p-1 text-gray-500 icon-trans-hover relative {showAgentMonitor
? 'text-[var(--trans-pink)]'
: ''}"
title="Agent Monitor"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
{#if activeAgentCount > 0}
<span
class="absolute -top-1 -right-1 bg-blue-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px] animate-pulse"
>
{activeAgentCount}
</span>
{/if}
</button>
<button
onclick={() => (showStats = !showStats)}
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
@@ -321,6 +627,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"
@@ -417,7 +737,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"}
@@ -443,8 +767,12 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- 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 />
<div class="fixed top-14 right-4 z-50 max-h-[calc(100vh-4rem)] overflow-y-auto">
<StatsDisplay
onRequestSummary={handleCompactConversation}
onStartFreshWithContext={handleStartFreshWithContext}
{isSummarising}
/>
</div>
{/if}
@@ -464,6 +792,10 @@
<SessionHistoryPanel onClose={() => (showSessionHistory = false)} />
{/if}
{#if showTodoPanel}
<TodoPanel onClose={() => (showTodoPanel = false)} />
{/if}
{#if showGitPanel}
<GitPanel isOpen={showGitPanel} onClose={() => (showGitPanel = false)} />
{/if}
@@ -471,3 +803,56 @@
{#if showProfile}
<ProfilePanel onClose={() => (showProfile = false)} />
{/if}
{#if showAgentMonitor}
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
{/if}
{#if showCastPanel}
<CastPanel onClose={() => (showCastPanel = false)} />
{/if}
{#if showPluginPanel}
<PluginManagementPanel onClose={() => (showPluginPanel = false)} />
{/if}
{#if showMcpPanel}
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
{/if}
{#if showWorkspaceTrust && pendingHookInfo}
<WorkspaceTrustModal
hookInfo={pendingHookInfo}
onTrust={handleTrustAndConnect}
onCancel={handleCancelConnect}
/>
{/if}
<style>
/* Responsive status bar styling */
.status-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
/* Make all icon buttons shrink but not grow */
.status-bar button {
flex-shrink: 0;
}
/* Hide version text on very small screens */
@media (max-width: 640px) {
.status-bar button span:last-of-type {
display: none;
}
}
/* Stack left and right sections on very small screens */
@media (max-width: 768px) {
.status-bar {
flex-direction: column;
gap: 0.75rem;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More