Compare commits

..

10 Commits

Author SHA1 Message Date
hikari 7781b2caab feat: add extended thinking blocks display
Implements support for displaying Claude's extended thinking blocks in
the conversation UI with a collapsible, visually distinct component.

Changes:
- Backend: Update wsl_bridge.rs to emit thinking blocks with dedicated
  line_type instead of system messages
- Types: Add "thinking" to TerminalLine type union
- Config: Add show_thinking_blocks toggle (default: true)
- UI: Create ThinkingBlock.svelte component with collapsible interface
- Terminal: Update to conditionally render thinking blocks
- Settings: Add toggle in Appearance section of ConfigSidebar

The ThinkingBlock component features:
- Lightbulb icon to indicate extended thinking
- Collapsible/expandable with animated chevron
- Distinct styling: dimmed, italic, monospace
- Timestamp display
- Respects global show_thinking_blocks config setting

Note: Extended thinking support in Claude Code CLI appears to be in
development. This implementation is ready and will automatically display
thinking blocks once the CLI begins emitting them.

Issue: #120

 Implemented by Hikari~ 🌸
2026-02-07 12:42:34 -08:00
hikari e397435dbe fix: resolve linting issues in memory system
- Add key to #each block in MemoryBrowserPanel (ESLint)
- Replace .last() with .next_back() for better performance (Clippy)
- Format files with Prettier

Co-Authored-By: Hikari <hikari@nhcarrigan.com>
2026-02-07 12:11:14 -08:00
hikari 89e99b1524 feat: add memory system activity display
Implements issue #118 to display auto-memory system operations and
provide a browser for viewing memory files.

Backend changes:
- Detect memory file operations in format_tool_description()
- Add emoji icons (📝/💾) for memory Read/Write/Edit operations
- Add list_memory_files() Tauri command to find all memory files
- Add 4 new tests for memory detection logic
- Update Tauri capabilities to allow reading .claude directory

Frontend changes:
- Create MemoryBrowserPanel component with file list and viewer
- Add memory panel to main app layout
- Support Markdown rendering of memory file contents
- Add loading states and error handling

Testing:
- All 354 Rust tests passing
- TypeScript type-checks pass

Co-Authored-By: Hikari <hikari@nhcarrigan.com>
2026-02-07 12:01:31 -08:00
hikari e40ae989f7 fix: support Task(agent_type) tool name syntax from CLI v2.1.33+
Claude Code CLI v2.1.33 introduced support for restricting sub-agents
via Task(agent_type) syntax in agent tools frontmatter. This commit
updates our Task tool detection to handle both the legacy "Task" syntax
and the new "Task(agent_type)" syntax.

Changes:
- Updated agent-start event detection to match "Task" or "Task("
- Updated character state detection to recognize Task(agent_type)
- Added comprehensive test cases for new syntax variants:
  - Task(Explore)
  - Task(Plan)
  - Task(general-purpose)

The fix is backwards compatible and all 350+ tests pass.

Fixes: #114
2026-02-07 10:55:17 -08:00
hikari 32a74235c0 feat: add system clock display with comprehensive tests
Add a SystemClock component that displays the current date and time in
British format. The clock appears in the top control bar (same row as
Clipboard and Actions buttons) and updates every second.

Features:
- Date format: "7 February 2026" (British English)
- Time format: "14:35:42" (24-hour)
- Auto-updates every second via setInterval
- Proper cleanup on unmount using Svelte 5 $effect
- Hover effect with accent colour border
- Positioned with margin-left: auto to align right

Testing:
- Added comprehensive unit tests for date/time formatting logic
- 12 test cases covering edge cases, month boundaries, leap years
- All tests passing with proper local timezone handling
- Updated CLAUDE.md with testing requirements and guidelines

Resolves: #128
2026-02-07 10:42:59 -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
49 changed files with 4291 additions and 445 deletions
+76
View File
@@ -0,0 +1,76 @@
# 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`
### 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...
});
});
```
## Project Context
Hikari Desktop is a Tauri-based desktop application that wraps Claude Code with a visual anime character (Hikari) who appears on screen. This is a personal project where Hikari can sign her work and act as herself!
+7 -3
View File
@@ -1,5 +1,9 @@
#!/bin/bash #!/bin/bash
# Source nvm to get access to pnpm
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
# Colors for output # Colors for output
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
@@ -32,11 +36,11 @@ echo -e "${YELLOW}🔍 Running all checks for Hikari Desktop...${NC}"
run_check "Frontend lint" "pnpm lint" || failed=1 run_check "Frontend lint" "pnpm lint" || failed=1
run_check "Frontend format check" "pnpm format:check" || failed=1 run_check "Frontend format check" "pnpm format:check" || failed=1
run_check "Frontend type check" "pnpm check" || failed=1 run_check "Frontend type check" "pnpm check" || failed=1
run_check "Frontend tests" "pnpm test" || failed=1 run_check "Frontend tests with coverage" "pnpm test:coverage" || failed=1
# Backend checks # Backend checks
run_check "Backend clippy (strict)" "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings" || failed=1 run_check "Backend clippy (strict)" "(cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings)" || failed=1
run_check "Backend tests" "cargo test" || failed=1 run_check "Backend tests with coverage" "(cd src-tauri && cargo llvm-cov --fail-under-lines 50)" || failed=1
# Summary # Summary
echo -e "\n${YELLOW}========================================${NC}" echo -e "\n${YELLOW}========================================${NC}"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hikari-desktop", "name": "hikari-desktop",
"version": "1.3.0", "version": "1.4.0",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
+75 -1
View File
@@ -1636,7 +1636,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]] [[package]]
name = "hikari-desktop" name = "hikari-desktop"
version = "1.2.0" version = "1.4.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"dirs 5.0.1", "dirs 5.0.1",
@@ -1658,6 +1658,8 @@ dependencies = [
"tauri-plugin-store", "tauri-plugin-store",
"tempfile", "tempfile",
"tokio", "tokio",
"tracing",
"tracing-subscriber",
"uuid 1.19.0", "uuid 1.19.0",
"windows 0.62.2", "windows 0.62.2",
] ]
@@ -2245,6 +2247,15 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]] [[package]]
name = "matches" name = "matches"
version = "0.1.10" version = "0.1.10"
@@ -2401,6 +2412,15 @@ dependencies = [
"zbus", "zbus",
] ]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@@ -3879,6 +3899,15 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]] [[package]]
name = "shared_child" name = "shared_child"
version = "1.1.1" version = "1.1.1"
@@ -4695,6 +4724,15 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "tiff" name = "tiff"
version = "0.10.3" version = "0.10.3"
@@ -4986,6 +5024,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
] ]
[[package]] [[package]]
@@ -5167,6 +5235,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
+3 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "hikari-desktop" name = "hikari-desktop"
version = "1.3.0" version = "1.4.0"
description = "Hikari - Claude Code Visual Assistant" description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"] authors = ["Naomi Carrigan"]
edition = "2021" edition = "2021"
@@ -33,6 +33,8 @@ semver = "1"
chrono = { version = "0.4.43", features = ["serde"] } chrono = { version = "0.4.43", features = ["serde"] }
discord-rich-presence = "0.2" discord-rich-presence = "0.2"
dirs = "5" dirs = "5"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = [ windows = { version = "0.62", features = [
+10 -1
View File
@@ -28,8 +28,17 @@
"identifier": "fs:allow-write-file", "identifier": "fs:allow-write-file",
"allow": [{ "path": "**" }] "allow": [{ "path": "**" }]
}, },
{
"identifier": "fs:scope",
"allow": [{ "path": "$HOME/.claude/**" }]
},
{
"identifier": "fs:allow-read-text-file",
"allow": [{ "path": "$HOME/.claude/**" }]
},
"core:window:allow-set-size", "core:window:allow-set-size",
"core:window:allow-set-always-on-top", "core:window:allow-set-always-on-top",
"core:window:allow-inner-size" "core:window:allow-inner-size",
"core:window:allow-hide"
] ]
} }
+16 -12
View File
@@ -1671,7 +1671,7 @@ pub fn check_message_achievements(
let mut newly_unlocked = Vec::new(); let mut newly_unlocked = Vec::new();
let message_lower = message.to_lowercase(); let message_lower = message.to_lowercase();
println!("Checking message achievements for: {}", message); tracing::info!("Checking message achievements for: {}", message);
// Relationship & Greetings // Relationship & Greetings
if message_lower.contains("good morning") && progress.unlock(AchievementId::GoodMorning) { if message_lower.contains("good morning") && progress.unlock(AchievementId::GoodMorning) {
@@ -1863,18 +1863,18 @@ pub fn check_achievements(
) -> Vec<AchievementId> { ) -> Vec<AchievementId> {
let mut newly_unlocked = Vec::new(); let mut newly_unlocked = Vec::new();
println!( tracing::info!(
"Checking achievements with stats: messages={}, tokens={}, code_blocks={}", "Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
stats.messages_exchanged, stats.messages_exchanged,
stats.total_input_tokens + stats.total_output_tokens, stats.total_input_tokens + stats.total_output_tokens,
stats.code_blocks_generated stats.code_blocks_generated
); );
println!("Currently unlocked: {:?}", progress.unlocked); tracing::info!("Currently unlocked: {:?}", progress.unlocked);
// Token milestones // Token milestones
let total_tokens = stats.total_input_tokens + stats.total_output_tokens; let total_tokens = stats.total_input_tokens + stats.total_output_tokens;
if total_tokens >= 1_000 && progress.unlock(AchievementId::FirstSteps) { if total_tokens >= 1_000 && progress.unlock(AchievementId::FirstSteps) {
println!("Unlocked FirstSteps achievement!"); tracing::info!("Unlocked FirstSteps achievement!");
newly_unlocked.push(AchievementId::FirstSteps); newly_unlocked.push(AchievementId::FirstSteps);
} }
if total_tokens >= 10_000 && progress.unlock(AchievementId::GrowingStrong) { if total_tokens >= 10_000 && progress.unlock(AchievementId::GrowingStrong) {
@@ -2244,7 +2244,7 @@ pub async fn save_achievements(
// Create a serializable version with just the unlocked achievement IDs // Create a serializable version with just the unlocked achievement IDs
let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect(); let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect();
println!("Saving achievements: {:?}", unlocked_list); tracing::info!("Saving achievements: {:?}", unlocked_list);
store.set( store.set(
"unlocked", "unlocked",
@@ -2252,18 +2252,18 @@ pub async fn save_achievements(
); );
store.save().map_err(|e| e.to_string())?; store.save().map_err(|e| e.to_string())?;
println!("Achievements saved successfully"); tracing::info!("Achievements saved successfully");
Ok(()) Ok(())
} }
// Load achievements from persistent store // Load achievements from persistent store
pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress { pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
println!("Loading achievements from store..."); tracing::info!("Loading achievements from store...");
let store = match app.store("achievements.json") { let store = match app.store("achievements.json") {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
println!("Failed to open achievements store: {}", e); tracing::error!("Failed to open achievements store: {}", e);
return AchievementProgress::new(); return AchievementProgress::new();
} }
}; };
@@ -2272,19 +2272,19 @@ pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
// Get unlocked achievements // Get unlocked achievements
if let Some(unlocked_value) = store.get("unlocked") { if let Some(unlocked_value) = store.get("unlocked") {
println!("Found unlocked value in store: {:?}", unlocked_value); tracing::info!("Found unlocked value in store: {:?}", unlocked_value);
if let Ok(unlocked_list) = if let Ok(unlocked_list) =
serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone()) serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone())
{ {
println!("Loaded {} achievements", unlocked_list.len()); tracing::info!("Loaded {} achievements", unlocked_list.len());
for achievement_id in unlocked_list { for achievement_id in unlocked_list {
progress.unlocked.insert(achievement_id); progress.unlocked.insert(achievement_id);
} }
} else { } else {
println!("Failed to parse unlocked achievements"); tracing::error!("Failed to parse unlocked achievements");
} }
} else { } else {
println!("No unlocked achievements found in store"); tracing::info!("No unlocked achievements found in store");
} }
progress progress
@@ -2329,6 +2329,10 @@ mod tests {
context_utilisation_percent: 0.0, context_utilisation_percent: 0.0,
potential_cache_hits: 0, potential_cache_hits: 0,
potential_cache_savings_tokens: 0, potential_cache_savings_tokens: 0,
current_request_input: None,
current_request_output_chars: 0,
current_request_thinking_chars: 0,
current_request_tools: Vec::new(),
achievements: AchievementProgress::new(), achievements: AchievementProgress::new(),
} }
} }
+83
View File
@@ -1154,6 +1154,89 @@ pub async fn log_discord_rpc(
Ok(()) Ok(())
} }
#[tauri::command]
pub async fn close_application(app_handle: AppHandle) -> Result<(), String> {
// Get the main window
if let Some(window) = app_handle.get_webview_window("main") {
// Hide the window first for a smoother close
let _ = window.hide();
}
// Exit the application
app_handle.exit(0);
Ok(())
}
#[derive(serde::Serialize)]
pub struct MemoryFilesResponse {
pub files: Vec<String>,
}
#[tauri::command]
pub async fn list_memory_files() -> Result<MemoryFilesResponse, String> {
use std::fs;
// Get the .claude directory in the user's home
let home_dir = match dirs::home_dir() {
Some(dir) => dir,
None => return Err("Could not find home directory".to_string()),
};
let claude_dir = home_dir.join(".claude");
let projects_dir = claude_dir.join("projects");
if !projects_dir.exists() {
return Ok(MemoryFilesResponse { files: Vec::new() });
}
let mut memory_files = Vec::new();
// Recursively find all memory directories
fn find_memory_files(dir: &std::path::Path, files: &mut Vec<String>) -> std::io::Result<()> {
if !dir.is_dir() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
// Check if this is a "memory" directory
if path.file_name().and_then(|n| n.to_str()) == Some("memory") {
// List all files in the memory directory
for mem_entry in fs::read_dir(&path)? {
let mem_entry = mem_entry?;
let mem_path = mem_entry.path();
if mem_path.is_file() {
if let Some(path_str) = mem_path.to_str() {
files.push(path_str.to_string());
}
}
}
} else {
// Recurse into subdirectories
find_memory_files(&path, files)?;
}
}
}
Ok(())
}
if let Err(e) = find_memory_files(&projects_dir, &mut memory_files) {
return Err(format!("Failed to list memory files: {}", e));
}
// Sort files alphabetically
memory_files.sort();
Ok(MemoryFilesResponse {
files: memory_files,
})
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+1 -6
View File
@@ -28,6 +28,7 @@ pub struct ClaudeStartOptions {
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HikariConfig { pub struct HikariConfig {
#[serde(default)] #[serde(default)]
pub model: Option<String>, pub model: Option<String>,
@@ -71,9 +72,6 @@ pub struct HikariConfig {
#[serde(default = "default_font_size")] #[serde(default = "default_font_size")]
pub font_size: u32, pub font_size: u32,
#[serde(default)]
pub minimize_to_tray: bool,
#[serde(default)] #[serde(default)]
pub streamer_mode: bool, pub streamer_mode: bool,
@@ -134,7 +132,6 @@ impl Default for HikariConfig {
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: None, character_panel_width: None,
font_size: 14, font_size: 14,
minimize_to_tray: false,
streamer_mode: false, streamer_mode: false,
streamer_hide_paths: false, streamer_hide_paths: false,
compact_mode: false, compact_mode: false,
@@ -242,7 +239,6 @@ mod tests {
assert!(config.update_checks_enabled); assert!(config.update_checks_enabled);
assert!(config.character_panel_width.is_none()); assert!(config.character_panel_width.is_none());
assert_eq!(config.font_size, 14); assert_eq!(config.font_size, 14);
assert!(!config.minimize_to_tray);
assert!(!config.streamer_mode); assert!(!config.streamer_mode);
assert!(!config.streamer_hide_paths); assert!(!config.streamer_hide_paths);
assert!(!config.compact_mode); assert!(!config.compact_mode);
@@ -275,7 +271,6 @@ mod tests {
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: Some(400), character_panel_width: Some(400),
font_size: 16, font_size: 16,
minimize_to_tray: true,
streamer_mode: false, streamer_mode: false,
streamer_hide_paths: false, streamer_hide_paths: false,
compact_mode: false, compact_mode: false,
+78
View File
@@ -0,0 +1,78 @@
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::{AppHandle, Emitter};
use tracing::{Level, Subscriber};
use tracing_subscriber::layer::{Context, Layer};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DebugLogEvent {
pub level: String,
pub message: String,
}
#[derive(Clone)]
pub struct TauriLogLayer {
app: Arc<AppHandle>,
}
impl TauriLogLayer {
pub fn new(app: AppHandle) -> Self {
Self {
app: Arc::new(app),
}
}
}
impl<S> Layer<S> for TauriLogLayer
where
S: Subscriber,
{
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: Context<'_, S>,
) {
let metadata = event.metadata();
let level = match *metadata.level() {
Level::ERROR => "error",
Level::WARN => "warn",
Level::INFO => "info",
Level::DEBUG => "debug",
Level::TRACE => "debug",
};
// Extract message from the event
struct MessageVisitor {
message: String,
}
impl tracing::field::Visit for MessageVisitor {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.message = format!("{:?}", value);
}
}
}
let mut visitor = MessageVisitor {
message: String::new(),
};
event.record(&mut visitor);
// If we couldn't extract a message, try to format the whole event
if visitor.message.is_empty() {
visitor.message = metadata.name().to_string();
}
// Strip quotes from the message
let message = visitor.message.trim_matches('"').to_string();
let log_event = DebugLogEvent {
level: level.to_string(),
message,
};
// Emit to frontend
let _ = self.app.emit("debug:log", log_event);
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ impl DiscordRpcManager {
if let Ok(app_data_dir) = app_handle.path().app_data_dir() { if let Ok(app_data_dir) = app_handle.path().app_data_dir() {
// Ensure the directory exists // Ensure the directory exists
if let Err(e) = std::fs::create_dir_all(&app_data_dir) { if let Err(e) = std::fs::create_dir_all(&app_data_dir) {
eprintln!("Failed to create app data directory: {}", e); tracing::error!("Failed to create app data directory: {}", e);
return; return;
} }
let log_path = app_data_dir.join("hikari_discord_rpc.log"); let log_path = app_data_dir.join("hikari_discord_rpc.log");
+27 -10
View File
@@ -4,6 +4,7 @@ mod clipboard;
mod commands; mod commands;
mod config; mod config;
mod cost_tracking; mod cost_tracking;
mod debug_logger;
mod discord_rpc; mod discord_rpc;
mod git; mod git;
mod notifications; mod notifications;
@@ -24,6 +25,7 @@ use bridge_manager::create_shared_bridge_manager;
use clipboard::*; use clipboard::*;
use commands::load_saved_achievements; use commands::load_saved_achievements;
use commands::*; use commands::*;
use debug_logger::TauriLogLayer;
use discord_rpc::DiscordRpcManager; use discord_rpc::DiscordRpcManager;
use git::*; use git::*;
use notifications::*; use notifications::*;
@@ -31,9 +33,11 @@ use quick_actions::*;
use sessions::*; use sessions::*;
use snippets::*; use snippets::*;
use std::sync::Arc; use std::sync::Arc;
use tauri::Manager; use tauri::{Emitter, Manager};
use temp_manager::create_shared_temp_manager; use temp_manager::create_shared_temp_manager;
use tray::{setup_tray, should_minimize_to_tray}; use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tray::setup_tray;
use vbs_notification::*; use vbs_notification::*;
use windows_toast::*; use windows_toast::*;
use wsl_notifications::*; use wsl_notifications::*;
@@ -58,6 +62,14 @@ pub fn run() {
.manage(temp_manager.clone()) .manage(temp_manager.clone())
.manage(discord_rpc.clone()) .manage(discord_rpc.clone())
.setup(move |app| { .setup(move |app| {
// Initialize tracing with custom layer that emits to frontend
// NOTE: We don't use fmt::layer() because in production builds with windows_subsystem = "windows",
// stdout is hidden. Instead, all logs go through TauriLogLayer to the debug console.
let tauri_layer = TauriLogLayer::new(app.handle().clone());
tracing_subscriber::registry()
.with(tauri_layer)
.init();
// Initialize the app handle in the bridge manager // Initialize the app handle in the bridge manager
bridge_manager.lock().set_app_handle(app.handle().clone()); bridge_manager.lock().set_app_handle(app.handle().clone());
@@ -67,26 +79,29 @@ pub fn run() {
// Clean up any orphaned temp files from previous sessions // Clean up any orphaned temp files from previous sessions
if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() { if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() {
if count > 0 { if count > 0 {
println!("Cleaned up {} orphaned temp files", count); tracing::info!("Cleaned up {} orphaned temp files", count);
} }
} }
tracing::info!("Hikari Desktop started successfully");
// Set up system tray // Set up system tray
if let Err(e) = setup_tray(app.handle()) { if let Err(e) = setup_tray(app.handle()) {
eprintln!("Failed to set up system tray: {}", e); tracing::error!("Failed to set up system tray: {}", e);
} }
// Handle window close event for minimize to tray // Handle window close event for minimize to tray and close confirmation
let main_window = app.get_webview_window("main").unwrap(); let main_window = app.get_webview_window("main").unwrap();
main_window.on_window_event({ main_window.on_window_event({
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
move |event| { move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event { if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if should_minimize_to_tray(&app_handle) { // Always prevent default close - let frontend handle it
api.prevent_close(); api.prevent_close();
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.hide(); // Emit event to frontend to show confirmation modal
} if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.emit("window-close-requested", ());
} }
} }
} }
@@ -181,6 +196,8 @@ pub fn run() {
update_discord_rpc, update_discord_rpc,
stop_discord_rpc, stop_discord_rpc,
log_discord_rpc, log_discord_rpc,
close_application,
list_memory_files,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+85 -31
View File
@@ -86,6 +86,8 @@ impl ContextWarning {
/// Get the context window limit (in tokens) for a given model /// Get the context window limit (in tokens) for a given model
fn get_context_window_limit(model: &str) -> u64 { fn get_context_window_limit(model: &str) -> u64 {
match model { match model {
// Claude 4.6 family - 200K standard (1M beta available via header)
"claude-opus-4-6" => 200_000,
// Claude 4.5 family - 200K standard context // Claude 4.5 family - 200K standard context
"claude-opus-4-5-20251101" "claude-opus-4-5-20251101"
| "claude-sonnet-4-5-20250929" | "claude-sonnet-4-5-20250929"
@@ -154,6 +156,16 @@ pub struct UsageStats {
// Achievement tracking // Achievement tracking
#[serde(skip)] #[serde(skip)]
pub achievements: AchievementProgress, pub achievements: AchievementProgress,
// Track current in-flight request for cost estimation on interrupt
#[serde(skip)]
pub current_request_input: Option<String>,
#[serde(skip)]
pub current_request_output_chars: u64,
#[serde(skip)]
pub current_request_thinking_chars: u64,
#[serde(skip)]
pub current_request_tools: Vec<String>,
} }
impl UsageStats { impl UsageStats {
@@ -163,13 +175,26 @@ impl UsageStats {
stats stats
} }
pub fn add_usage(&mut self, input_tokens: u64, output_tokens: u64, model: &str) { pub fn add_usage(
&mut self,
input_tokens: u64,
output_tokens: u64,
model: &str,
cache_creation_tokens: Option<u64>,
cache_read_tokens: Option<u64>,
) {
self.total_input_tokens += input_tokens; self.total_input_tokens += input_tokens;
self.total_output_tokens += output_tokens; self.total_output_tokens += output_tokens;
self.session_input_tokens += input_tokens; self.session_input_tokens += input_tokens;
self.session_output_tokens += output_tokens; self.session_output_tokens += output_tokens;
let cost = calculate_cost(input_tokens, output_tokens, model); let cost = calculate_cost(
input_tokens,
output_tokens,
model,
cache_creation_tokens,
cache_read_tokens,
);
self.total_cost_usd += cost; self.total_cost_usd += cost;
self.session_cost_usd += cost; self.session_cost_usd += cost;
@@ -439,6 +464,10 @@ impl UsageStats {
potential_cache_hits: self.potential_cache_hits, potential_cache_hits: self.potential_cache_hits,
potential_cache_savings_tokens: self.potential_cache_savings_tokens, potential_cache_savings_tokens: self.potential_cache_savings_tokens,
achievements: AchievementProgress::new(), // Dummy for copy achievements: AchievementProgress::new(), // Dummy for copy
current_request_input: None, // Don't copy tracking fields
current_request_output_chars: 0,
current_request_thinking_chars: 0,
current_request_tools: Vec::new(),
}; };
check_achievements(&stats_copy, &mut self.achievements) check_achievements(&stats_copy, &mut self.achievements)
} }
@@ -462,9 +491,19 @@ fn is_consecutive_day(prev_date: &str, current_date: &str) -> bool {
// Pricing as of February 2026 // Pricing as of February 2026
// https://platform.claude.com/docs/en/about-claude/models/overview // https://platform.claude.com/docs/en/about-claude/models/overview
pub fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 { // Cache pricing: https://platform.claude.com/docs/en/build-with-claude/prompt-caching
pub fn calculate_cost(
input_tokens: u64,
output_tokens: u64,
model: &str,
cache_creation_tokens: Option<u64>,
cache_read_tokens: Option<u64>,
) -> f64 {
let (input_price_per_million, output_price_per_million) = match model { let (input_price_per_million, output_price_per_million) = match model {
// Current generation (Claude 4.5) // Current generation (Claude 4.6)
"claude-opus-4-6" => (5.0, 25.0),
// Previous generation (Claude 4.5)
"claude-opus-4-5-20251101" => (5.0, 25.0), "claude-opus-4-5-20251101" => (5.0, 25.0),
"claude-sonnet-4-5-20250929" => (3.0, 15.0), "claude-sonnet-4-5-20250929" => (3.0, 15.0),
"claude-haiku-4-5-20251001" => (1.0, 5.0), "claude-haiku-4-5-20251001" => (1.0, 5.0),
@@ -487,10 +526,25 @@ pub fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64
_ => (3.0, 15.0), _ => (3.0, 15.0),
}; };
// Regular input/output tokens
let input_cost = (input_tokens as f64 / 1_000_000.0) * input_price_per_million; let input_cost = (input_tokens as f64 / 1_000_000.0) * input_price_per_million;
let output_cost = (output_tokens as f64 / 1_000_000.0) * output_price_per_million; let output_cost = (output_tokens as f64 / 1_000_000.0) * output_price_per_million;
input_cost + output_cost // Cache write tokens (cache creation) cost 1.25x the base input price
let cache_write_cost = if let Some(cache_creation) = cache_creation_tokens {
(cache_creation as f64 / 1_000_000.0) * input_price_per_million * 1.25
} else {
0.0
};
// Cache read tokens cost 0.1x (10%) the base input price
let cache_read_cost = if let Some(cache_read) = cache_read_tokens {
(cache_read as f64 / 1_000_000.0) * input_price_per_million * 0.1
} else {
0.0
};
input_cost + output_cost + cache_write_cost + cache_read_cost
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -564,7 +618,7 @@ pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<()
let persisted = PersistedStats::from(stats); let persisted = PersistedStats::from(stats);
println!("Saving stats: {:?}", persisted); tracing::info!("Saving stats: {:?}", persisted);
store.set( store.set(
"lifetime_stats", "lifetime_stats",
@@ -572,32 +626,32 @@ pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<()
); );
store.save().map_err(|e| e.to_string())?; store.save().map_err(|e| e.to_string())?;
println!("Stats saved successfully"); tracing::info!("Stats saved successfully");
Ok(()) Ok(())
} }
/// Load lifetime stats from persistent store /// Load lifetime stats from persistent store
pub async fn load_stats(app: &tauri::AppHandle) -> Option<PersistedStats> { pub async fn load_stats(app: &tauri::AppHandle) -> Option<PersistedStats> {
println!("Loading stats from store..."); tracing::info!("Loading stats from store...");
let store = match app.store("stats.json") { let store = match app.store("stats.json") {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
println!("Failed to open stats store: {}", e); tracing::error!("Failed to open stats store: {}", e);
return None; return None;
} }
}; };
if let Some(stats_value) = store.get("lifetime_stats") { if let Some(stats_value) = store.get("lifetime_stats") {
println!("Found lifetime stats in store: {:?}", stats_value); tracing::info!("Found lifetime stats in store: {:?}", stats_value);
if let Ok(persisted) = serde_json::from_value::<PersistedStats>(stats_value.clone()) { if let Ok(persisted) = serde_json::from_value::<PersistedStats>(stats_value.clone()) {
println!("Loaded lifetime stats successfully"); tracing::info!("Loaded lifetime stats successfully");
return Some(persisted); return Some(persisted);
} else { } else {
println!("Failed to parse lifetime stats"); tracing::error!("Failed to parse lifetime stats");
} }
} else { } else {
println!("No lifetime stats found in store"); tracing::info!("No lifetime stats found in store");
} }
None None
@@ -609,7 +663,7 @@ mod tests {
#[test] #[test]
fn test_cost_calculation_sonnet() { fn test_cost_calculation_sonnet() {
let cost = calculate_cost(1000, 2000, "claude-sonnet-4-20250514"); let cost = calculate_cost(1000, 2000, "claude-sonnet-4-20250514", None, None);
// 1000 input * $3/M = $0.003 // 1000 input * $3/M = $0.003
// 2000 output * $15/M = $0.030 // 2000 output * $15/M = $0.030
// Total = $0.033 // Total = $0.033
@@ -618,7 +672,7 @@ mod tests {
#[test] #[test]
fn test_cost_calculation_opus() { fn test_cost_calculation_opus() {
let cost = calculate_cost(1000, 2000, "claude-opus-4-20250514"); let cost = calculate_cost(1000, 2000, "claude-opus-4-20250514", None, None);
// 1000 input * $15/M = $0.015 // 1000 input * $15/M = $0.015
// 2000 output * $75/M = $0.150 // 2000 output * $75/M = $0.150
// Total = $0.165 // Total = $0.165
@@ -627,7 +681,7 @@ mod tests {
#[test] #[test]
fn test_cost_calculation_opus_45() { fn test_cost_calculation_opus_45() {
let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101"); let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101", None, None);
// Opus 4.5 pricing: $5/MTok input, $25/MTok output // Opus 4.5 pricing: $5/MTok input, $25/MTok output
// 1000 input tokens = $0.005, 2000 output tokens = $0.05 // 1000 input tokens = $0.005, 2000 output tokens = $0.05
// Total = $0.055 // Total = $0.055
@@ -636,7 +690,7 @@ mod tests {
#[test] #[test]
fn test_cost_calculation_haiku() { fn test_cost_calculation_haiku() {
let cost = calculate_cost(1000, 2000, "claude-3-5-haiku-20241022"); let cost = calculate_cost(1000, 2000, "claude-3-5-haiku-20241022", None, None);
// 1000 input * $1/M = $0.001 // 1000 input * $1/M = $0.001
// 2000 output * $5/M = $0.010 // 2000 output * $5/M = $0.010
// Total = $0.011 // Total = $0.011
@@ -645,14 +699,14 @@ mod tests {
#[test] #[test]
fn test_cost_calculation_unknown_defaults_to_sonnet() { fn test_cost_calculation_unknown_defaults_to_sonnet() {
let cost = calculate_cost(1000, 2000, "some-unknown-model"); let cost = calculate_cost(1000, 2000, "some-unknown-model", None, None);
// Should default to Sonnet pricing // Should default to Sonnet pricing
assert!((cost - 0.033).abs() < 0.0001); assert!((cost - 0.033).abs() < 0.0001);
} }
#[test] #[test]
fn test_cost_calculation_legacy_sonnet() { fn test_cost_calculation_legacy_sonnet() {
let cost = calculate_cost(1000, 2000, "claude-3-5-sonnet-20241022"); let cost = calculate_cost(1000, 2000, "claude-3-5-sonnet-20241022", None, None);
// Same as Sonnet 4 pricing // Same as Sonnet 4 pricing
assert!((cost - 0.033).abs() < 0.0001); assert!((cost - 0.033).abs() < 0.0001);
} }
@@ -660,7 +714,7 @@ mod tests {
#[test] #[test]
fn test_usage_stats_accumulation() { fn test_usage_stats_accumulation() {
let mut stats = UsageStats::new(); let mut stats = UsageStats::new();
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514"); stats.add_usage(1000, 2000, "claude-sonnet-4-20250514", None, None);
assert_eq!(stats.total_input_tokens, 1000); assert_eq!(stats.total_input_tokens, 1000);
assert_eq!(stats.total_output_tokens, 2000); assert_eq!(stats.total_output_tokens, 2000);
@@ -672,8 +726,8 @@ mod tests {
#[test] #[test]
fn test_usage_stats_multiple_accumulations() { fn test_usage_stats_multiple_accumulations() {
let mut stats = UsageStats::new(); let mut stats = UsageStats::new();
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514"); stats.add_usage(1000, 1000, "claude-sonnet-4-20250514", None, None);
stats.add_usage(500, 500, "claude-sonnet-4-20250514"); stats.add_usage(500, 500, "claude-sonnet-4-20250514", None, None);
assert_eq!(stats.total_input_tokens, 1500); assert_eq!(stats.total_input_tokens, 1500);
assert_eq!(stats.total_output_tokens, 1500); assert_eq!(stats.total_output_tokens, 1500);
@@ -684,17 +738,17 @@ mod tests {
#[test] #[test]
fn test_usage_stats_model_updated() { fn test_usage_stats_model_updated() {
let mut stats = UsageStats::new(); let mut stats = UsageStats::new();
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514"); stats.add_usage(1000, 1000, "claude-sonnet-4-20250514", None, None);
assert_eq!(stats.model, Some("claude-sonnet-4-20250514".to_string())); assert_eq!(stats.model, Some("claude-sonnet-4-20250514".to_string()));
stats.add_usage(500, 500, "claude-opus-4-20250514"); stats.add_usage(500, 500, "claude-opus-4-20250514", None, None);
assert_eq!(stats.model, Some("claude-opus-4-20250514".to_string())); assert_eq!(stats.model, Some("claude-opus-4-20250514".to_string()));
} }
#[test] #[test]
fn test_session_reset() { fn test_session_reset() {
let mut stats = UsageStats::new(); let mut stats = UsageStats::new();
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514"); stats.add_usage(1000, 2000, "claude-sonnet-4-20250514", None, None);
stats.reset_session(); stats.reset_session();
assert_eq!(stats.total_input_tokens, 1000); assert_eq!(stats.total_input_tokens, 1000);
@@ -921,7 +975,7 @@ mod tests {
#[test] #[test]
fn test_usage_stats_serialization() { fn test_usage_stats_serialization() {
let mut stats = UsageStats::new(); let mut stats = UsageStats::new();
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514"); stats.add_usage(1000, 2000, "claude-sonnet-4-20250514", None, None);
stats.increment_messages(); stats.increment_messages();
// UsageStats should be serializable (for events) // UsageStats should be serializable (for events)
@@ -950,7 +1004,7 @@ mod tests {
#[test] #[test]
fn test_stats_update_event_serialization() { fn test_stats_update_event_serialization() {
let mut stats = UsageStats::new(); let mut stats = UsageStats::new();
stats.add_usage(100, 200, "claude-sonnet-4-20250514"); stats.add_usage(100, 200, "claude-sonnet-4-20250514", None, None);
let event = StatsUpdateEvent { stats }; let event = StatsUpdateEvent { stats };
let json = serde_json::to_string(&event).expect("Failed to serialize"); let json = serde_json::to_string(&event).expect("Failed to serialize");
@@ -1004,7 +1058,7 @@ mod tests {
#[test] #[test]
fn test_context_tracking_update() { fn test_context_tracking_update() {
let mut stats = UsageStats::new(); let mut stats = UsageStats::new();
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514"); stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514", None, None);
assert_eq!(stats.context_tokens_used, 50_000); assert_eq!(stats.context_tokens_used, 50_000);
assert_eq!(stats.context_window_limit, 200_000); assert_eq!(stats.context_window_limit, 200_000);
@@ -1014,8 +1068,8 @@ mod tests {
#[test] #[test]
fn test_context_tracking_accumulates() { fn test_context_tracking_accumulates() {
let mut stats = UsageStats::new(); let mut stats = UsageStats::new();
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514"); stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514", None, None);
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514"); stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514", None, None);
assert_eq!(stats.context_tokens_used, 100_000); assert_eq!(stats.context_tokens_used, 100_000);
assert!((stats.context_utilisation_percent - 50.0).abs() < 0.1); assert!((stats.context_utilisation_percent - 50.0).abs() < 0.1);
@@ -1079,7 +1133,7 @@ mod tests {
#[test] #[test]
fn test_context_reset_on_session_reset() { fn test_context_reset_on_session_reset() {
let mut stats = UsageStats::new(); let mut stats = UsageStats::new();
stats.add_usage(100_000, 20_000, "claude-sonnet-4-20250514"); stats.add_usage(100_000, 20_000, "claude-sonnet-4-20250514", None, None);
assert!(stats.context_tokens_used > 0); assert!(stats.context_tokens_used > 0);
assert!(stats.context_utilisation_percent > 0.0); assert!(stats.context_utilisation_percent > 0.0);
+3 -3
View File
@@ -77,8 +77,8 @@ impl TempFileManager {
for file_path in files { for file_path in files {
if file_path.exists() { if file_path.exists() {
if let Err(e) = fs::remove_file(&file_path) { if let Err(e) = fs::remove_file(&file_path) {
eprintln!( tracing::warn!(
"Warning: Failed to remove temp file {:?}: {}", "Failed to remove temp file {:?}: {}",
file_path, e file_path, e
); );
} }
@@ -115,7 +115,7 @@ impl TempFileManager {
let path = entry.path(); let path = entry.path();
if path.is_file() && !tracked_files.contains(&path) { if path.is_file() && !tracked_files.contains(&path) {
if let Err(e) = fs::remove_file(&path) { if let Err(e) = fs::remove_file(&path) {
eprintln!("Warning: Failed to remove orphaned file {:?}: {}", path, e); tracing::warn!("Failed to remove orphaned file {:?}: {}", path, e);
} else { } else {
cleaned_count += 1; cleaned_count += 1;
} }
-20
View File
@@ -4,8 +4,6 @@ use tauri::{
AppHandle, Manager, AppHandle, Manager,
}; };
use crate::config::HikariConfig;
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
let show_item = MenuItem::with_id(app, "show", "Show Hikari", true, None::<&str>)?; let show_item = MenuItem::with_id(app, "show", "Show Hikari", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
@@ -48,21 +46,3 @@ pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
Ok(()) Ok(())
} }
pub fn should_minimize_to_tray(app: &AppHandle) -> bool {
let config_path = app
.path()
.app_config_dir()
.ok()
.map(|p| p.join("hikari-config.json"));
if let Some(path) = config_path {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(config) = serde_json::from_str::<HikariConfig>(&content) {
return config.minimize_to_tray;
}
}
}
false
}
+41 -1
View File
@@ -4,6 +4,10 @@ use serde::{Deserialize, Serialize};
pub struct UsageInfo { pub struct UsageInfo {
pub input_tokens: u64, pub input_tokens: u64,
pub output_tokens: u64, pub output_tokens: u64,
#[serde(default)]
pub cache_creation_input_tokens: Option<u64>,
#[serde(default)]
pub cache_read_input_tokens: Option<u64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
@@ -193,14 +197,21 @@ pub struct OutputEvent {
pub conversation_id: Option<String>, pub conversation_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<MessageCost>, pub cost: Option<MessageCost>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_tool_use_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionPromptEvent { pub struct PermissionPromptEventItem {
pub id: String, pub id: String,
pub tool_name: String, pub tool_name: String,
pub tool_input: serde_json::Value, pub tool_input: serde_json::Value,
pub description: String, pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionPromptEvent {
pub permissions: Vec<PermissionPromptEventItem>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>, pub conversation_id: Option<String>,
} }
@@ -244,6 +255,33 @@ pub struct UserQuestionEvent {
pub conversation_id: Option<String>, pub conversation_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentStartEvent {
pub tool_use_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
pub description: String,
pub subagent_type: String,
pub started_at: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_tool_use_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentEndEvent {
pub tool_use_id: String,
pub ended_at: u64,
pub is_error: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub num_turns: Option<u32>,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -365,6 +403,7 @@ mod tests {
tool_name: None, tool_name: None,
conversation_id: None, conversation_id: None,
cost: None, cost: None,
parent_tool_use_id: None,
}; };
let serialized = serde_json::to_string(&event).unwrap(); let serialized = serde_json::to_string(&event).unwrap();
@@ -384,6 +423,7 @@ mod tests {
output_tokens: 50, output_tokens: 50,
cost_usd: 0.005, cost_usd: 0.005,
}), }),
parent_tool_use_id: None,
}; };
let serialized = serde_json::to_string(&event).unwrap(); let serialized = serde_json::to_string(&event).unwrap();
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -48,15 +48,15 @@ $notifier.Show($toast)
match output { match output {
Ok(result) => { Ok(result) => {
if result.status.success() { if result.status.success() {
println!("WSL notification sent successfully"); tracing::info!("WSL notification sent successfully");
return Ok(()); return Ok(());
} else { } else {
let stderr = String::from_utf8_lossy(&result.stderr); let stderr = String::from_utf8_lossy(&result.stderr);
println!("PowerShell toast failed: {}", stderr); tracing::error!("PowerShell toast failed: {}", stderr);
} }
} }
Err(e) => { Err(e) => {
println!("Failed to run PowerShell: {}", e); tracing::error!("Failed to run PowerShell: {}", e);
} }
} }
@@ -74,7 +74,7 @@ $notifier.Show($toast)
if let Ok(result) = notify_result { if let Ok(result) = notify_result {
if result.status.success() { if result.status.success() {
println!("Notification sent via wsl-notify-send"); tracing::info!("Notification sent via wsl-notify-send");
return Ok(()); return Ok(());
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop", "productName": "hikari-desktop",
"version": "1.3.0", "version": "1.4.0",
"identifier": "com.naomi.hikari-desktop", "identifier": "com.naomi.hikari-desktop",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
+22 -4
View File
@@ -37,6 +37,12 @@ async function changeDirectory(path: string): Promise<void> {
// Capture conversation history before disconnecting // Capture conversation history before disconnecting
const conversationHistory = claudeStore.getConversationHistory(); const conversationHistory = claudeStore.getConversationHistory();
// Get currently granted tools and config auto-granted tools
const activeConversation = get(conversationsStore.activeConversation);
const grantedTools = activeConversation ? Array.from(activeConversation.grantedTools) : [];
const config = configStore.getConfig();
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
await invoke("stop_claude", { conversationId }); await invoke("stop_claude", { conversationId });
// Wait for clean shutdown // Wait for clean shutdown
@@ -50,12 +56,15 @@ async function changeDirectory(path: string): Promise<void> {
conversationId, conversationId,
options: { options: {
working_dir: validatedPath, working_dir: validatedPath,
model: config.model || null,
api_key: config.api_key || null,
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools,
}, },
}); });
// Update Discord RPC when reconnecting after directory change // Update Discord RPC when reconnecting after directory change
const config = configStore.getConfig();
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) { if (activeConversation) {
await updateDiscordRpc( await updateDiscordRpc(
activeConversation.name, activeConversation.name,
@@ -102,6 +111,12 @@ async function startNewConversation(): Promise<void> {
conversationId, conversationId,
}); });
// Get granted tools before interrupting
const activeConversation = get(conversationsStore.activeConversation);
const grantedTools = activeConversation ? Array.from(activeConversation.grantedTools) : [];
const config = configStore.getConfig();
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
claudeStore.addLine("system", "Starting new conversation..."); claudeStore.addLine("system", "Starting new conversation...");
characterState.setState("thinking"); characterState.setState("thinking");
@@ -115,12 +130,15 @@ async function startNewConversation(): Promise<void> {
conversationId, conversationId,
options: { options: {
working_dir: workingDir, working_dir: workingDir,
model: config.model || null,
api_key: config.api_key || null,
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools,
}, },
}); });
// Update Discord RPC when starting new conversation // Update Discord RPC when starting new conversation
const config = configStore.getConfig();
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) { if (activeConversation) {
await updateDiscordRpc( await updateDiscordRpc(
activeConversation.name, activeConversation.name,
+328
View File
@@ -0,0 +1,328 @@
<script lang="ts">
import { SvelteMap } from "svelte/reactivity";
import { invoke } from "@tauri-apps/api/core";
import { claudeStore } from "$lib/stores/claude";
import { agentStore, getAgentsForConversation } from "$lib/stores/agents";
import type { AgentInfo } from "$lib/types/agents";
import { onMount, onDestroy } from "svelte";
interface Props {
isOpen: boolean;
onClose: () => void;
}
const { isOpen, onClose }: Props = $props();
let now = $state(Date.now());
let timerInterval: ReturnType<typeof setInterval> | null = null;
// We need a reactive subscription to agents for the active conversation
let agents: AgentInfo[] = $state([]);
let agentsUnsubscribe: (() => void) | null = null;
// Track active conversation reactively
let currentConversationId = $state<string | null>("");
const conversationIdUnsubscribe = claudeStore.activeConversationId.subscribe((id) => {
currentConversationId = id;
});
$effect(() => {
// Re-subscribe when conversation changes
if (agentsUnsubscribe) {
agentsUnsubscribe();
}
if (currentConversationId) {
const store = getAgentsForConversation(currentConversationId);
agentsUnsubscribe = store.subscribe((value) => {
agents = value;
});
} else {
agents = [];
}
});
const runningAgents = $derived(agents.filter((a) => a.status === "running"));
const completedAgents = $derived(agents.filter((a) => a.status === "completed"));
const erroredAgents = $derived(agents.filter((a) => a.status === "errored"));
// Organize agents into a tree structure based on parent_tool_use_id
const agentTree = $derived.by(() => {
const topLevel = agents.filter((a) => !a.parentToolUseId);
const childrenMap = new SvelteMap<string, AgentInfo[]>();
// Group children by their parent
agents.forEach((agent) => {
if (agent.parentToolUseId) {
const siblings = childrenMap.get(agent.parentToolUseId) || [];
siblings.push(agent);
childrenMap.set(agent.parentToolUseId, siblings);
}
});
return { topLevel, childrenMap };
});
onMount(() => {
timerInterval = setInterval(() => {
now = Date.now();
}, 1000);
});
onDestroy(() => {
if (timerInterval) clearInterval(timerInterval);
if (agentsUnsubscribe) agentsUnsubscribe();
conversationIdUnsubscribe();
});
function formatDuration(startedAt: number, endedAt?: number): string {
const end = endedAt || now;
const durationMs = end - startedAt;
const seconds = Math.floor(durationMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
}
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
}
return `${seconds}s`;
}
function getSubagentTypeLabel(type: string): string {
const labels: Record<string, string> = {
Explore: "Explorer",
"general-purpose": "General",
Plan: "Planner",
Bash: "Shell",
};
return labels[type] || type;
}
function getStatusBadgeClass(status: string): string {
switch (status) {
case "running":
return "bg-blue-500/20 text-blue-400 border-blue-500/30";
case "completed":
return "bg-green-500/20 text-green-400 border-green-500/30";
case "errored":
return "bg-red-500/20 text-red-400 border-red-500/30";
default:
return "bg-gray-500/20 text-gray-400 border-gray-500/30";
}
}
async function handleKillAll() {
if (!currentConversationId) return;
try {
await invoke("interrupt_claude", { conversationId: currentConversationId });
} catch (error) {
console.error("Failed to kill Claude process:", error);
}
}
function handleClearCompleted() {
if (currentConversationId) {
agentStore.clearCompleted(currentConversationId);
}
}
// Flatten the tree for rendering with depth information
const flattenedAgents = $derived.by(() => {
const result: { agent: AgentInfo; depth: number }[] = [];
const { topLevel, childrenMap } = agentTree;
function addAgentAndChildren(agent: AgentInfo, depth: number) {
result.push({ agent, depth });
const children = childrenMap.get(agent.toolUseId);
if (children) {
children.forEach((child) => addAgentAndChildren(child, depth + 1));
}
}
topLevel.forEach((agent) => addAgentAndChildren(agent, 0));
return result;
});
</script>
{#if isOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40" onclick={onClose}></div>
<div
class="fixed top-12 right-0 bottom-0 w-80 bg-[var(--bg-primary)] border-l border-[var(--border-color)] shadow-xl z-50 flex flex-col overflow-hidden"
>
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-[var(--border-color)]">
<div class="flex items-center gap-2">
<svg
class="w-5 h-5 text-[var(--accent-primary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
<h3 class="text-sm font-semibold text-[var(--text-primary)]">Agent Monitor</h3>
{#if runningAgents.length > 0}
<span
class="px-1.5 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400 animate-pulse"
>
{runningAgents.length} running
</span>
{/if}
</div>
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close agent monitor"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Action buttons -->
<div class="flex gap-2 px-4 py-2 border-b border-[var(--border-color)]">
<button
onclick={handleKillAll}
disabled={runningAgents.length === 0}
class="flex-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title="Kills the entire Claude Code process to stop all agents"
>
Kill All
</button>
<button
onclick={handleClearCompleted}
disabled={completedAgents.length === 0 && erroredAgents.length === 0}
class="flex-1 px-2 py-1 text-xs bg-[var(--bg-secondary)] hover:bg-[var(--bg-hover,var(--bg-secondary))] text-[var(--text-secondary)] rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Clear Finished
</button>
</div>
<!-- Agent list -->
<div class="flex-1 overflow-y-auto p-4 space-y-2">
{#if agents.length === 0}
<div
class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)] text-sm"
>
<svg
class="w-8 h-8 mb-2 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
<p>No agents detected yet</p>
<p class="text-xs mt-1 opacity-70">
Agents will appear here when Claude uses the Task tool
</p>
</div>
{:else}
{#each flattenedAgents as { agent, depth } (agent.toolUseId)}
<div
class="p-3 rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] {agent.status ===
'running'
? 'border-l-2 border-l-blue-500'
: agent.status === 'errored'
? 'border-l-2 border-l-red-500'
: 'border-l-2 border-l-green-500'}"
style="margin-left: {depth * 12}px; width: calc(100% - {depth * 12}px);"
>
<!-- Agent header -->
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-1.5">
{#if depth > 0}
<svg
class="w-3 h-3 text-[var(--text-secondary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
{/if}
<span
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
agent.status
)}"
>
{getSubagentTypeLabel(agent.subagentType)}
</span>
</div>
<span
class="text-[10px] {agent.status === 'running'
? 'text-blue-400'
: 'text-[var(--text-secondary)]'}"
>
{#if agent.durationMs !== undefined}
{Math.floor(agent.durationMs / 1000)}s
{:else}
{formatDuration(agent.startedAt, agent.endedAt)}
{/if}
{#if agent.status === "running"}
<span class="inline-block w-1 h-1 bg-blue-400 rounded-full animate-pulse ml-1"
></span>
{/if}
</span>
</div>
<!-- Agent description -->
<p class="text-xs text-[var(--text-primary)] truncate" title={agent.description}>
{agent.description}
</p>
<!-- Status indicator -->
<div class="mt-1 flex items-center gap-1">
{#if agent.status === "running"}
<span class="text-[10px] text-blue-400">Running...</span>
{:else if agent.status === "completed"}
<span class="text-[10px] text-green-400">Completed</span>
{:else}
<span class="text-[10px] text-red-400">Errored / Killed</span>
{/if}
</div>
</div>
{/each}
{/if}
</div>
<!-- Footer summary -->
{#if agents.length > 0}
<div
class="px-4 py-2 border-t border-[var(--border-color)] text-[10px] text-[var(--text-secondary)]"
>
{agents.length} total &middot;
{runningAgents.length} running &middot;
{completedAgents.length} completed &middot;
{erroredAgents.length} errored
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,116 @@
<script lang="ts">
interface Props {
isOpen: boolean;
hasActiveConversation: boolean;
onClose: () => void;
onMinimize: () => void;
onCancel: () => void;
}
const { isOpen, hasActiveConversation, onClose, onMinimize, onCancel }: Props = $props();
function handleKeydown(event: KeyboardEvent) {
if (!isOpen) return;
if (event.key === "Escape") {
event.preventDefault();
onCancel();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onCancel}
role="button"
tabindex="0"
onkeydown={(e) => e.key === " " && onCancel()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-md w-full"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="confirm-title"
aria-describedby="confirm-message"
tabindex="-1"
>
<div class="p-6">
<div class="flex items-start gap-4">
<div
class="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center flex-shrink-0"
>
<svg
class="w-6 h-6 text-yellow-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div class="flex-1">
<h3 id="confirm-title" class="text-lg font-semibold text-[var(--text-primary)] mb-1">
Close Hikari Desktop?
</h3>
<p id="confirm-message" class="text-sm text-[var(--text-secondary)]">
{#if hasActiveConversation}
You have an active conversation with Claude. Are you sure you want to close the
application? Your conversation history will be saved, but any in-progress tasks will
be interrupted.
{:else}
Are you sure you want to close the application?
{/if}
</p>
</div>
</div>
<div class="flex gap-3 mt-6 justify-end">
<button
onclick={onCancel}
class="px-4 py-2 text-sm font-medium text-gray-300 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Cancel
</button>
<button
onclick={onMinimize}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
Minimize to Tray
</button>
<button
onclick={onClose}
class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
>
Close Application
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
</style>
+4
View File
@@ -5,6 +5,7 @@
import { characterState, characterInfo } from "$lib/stores/character"; import { characterState, characterInfo } from "$lib/stores/character";
import { isStreamerMode } from "$lib/stores/config"; import { isStreamerMode } from "$lib/stores/config";
import { handleNewUserMessage } from "$lib/notifications/rules"; import { handleNewUserMessage } from "$lib/notifications/rules";
import { setSkipNextGreeting } from "$lib/tauri";
import type { CharacterState, CharacterStateInfo } from "$lib/types/states"; import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
interface Props { interface Props {
@@ -127,6 +128,9 @@
const conversationId = get(claudeStore.activeConversationId); const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return; if (!conversationId) return;
// Set flag to preserve stats/permissions (don't treat next connect as new session)
setSkipNextGreeting(true);
await invoke("interrupt_claude", { conversationId }); await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Interrupted"); claudeStore.addLine("system", "Interrupted");
characterState.setState("idle"); characterState.setState("idle");
+24 -19
View File
@@ -26,7 +26,6 @@
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false, always_on_top: false,
minimize_to_tray: false,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null, character_panel_width: null,
font_size: 14, font_size: 14,
@@ -52,6 +51,7 @@
budget_action: "warn", budget_action: "warn",
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true,
}); });
let showCustomThemeEditor = $state(false); let showCustomThemeEditor = $state(false);
@@ -81,16 +81,20 @@
const availableModels = [ const availableModels = [
{ value: "", label: "Default (from ~/.claude)" }, { value: "", label: "Default (from ~/.claude)" },
// Current generation (Claude 4.5) // Current generation (Claude 4.6)
{ value: "claude-opus-4-6", label: "Claude Opus 4.6 (Most Capable)" },
// Previous generation (Claude 4.5)
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5 (Recommended)" }, { value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5 (Recommended)" },
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" }, { value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" },
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5 (Most Capable)" }, { value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5" },
// Previous generation (Claude 4) // Previous generation (Claude 4.x)
{ value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" }, { value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" },
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" }, { value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" }, { value: "claude-opus-4-20250514", label: "Claude Opus 4" },
// Legacy (Claude 3.x) // Legacy (Claude 3.x)
{ value: "claude-3-7-sonnet-20250219", label: "Claude 3.7 Sonnet" }, { value: "claude-3-7-sonnet-20250219", label: "Claude 3.7 Sonnet" },
{ value: "claude-3-5-sonnet-20241022", label: "Claude 3.5 Sonnet (Oct 2024)" },
{ value: "claude-3-5-sonnet-20240620", label: "Claude 3.5 Sonnet (Jun 2024)" },
{ value: "claude-3-haiku-20240307", label: "Claude 3 Haiku (Cheapest)" }, { value: "claude-3-haiku-20240307", label: "Claude 3 Haiku (Cheapest)" },
]; ];
@@ -700,6 +704,22 @@
Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset
</p> </p>
</div> </div>
<!-- Show Thinking Blocks Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.show_thinking_blocks}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
/>
<span class="text-sm text-[var(--text-primary)]">Show Extended Thinking Blocks</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Display Claude's extended thinking process in the conversation. Thinking blocks can be
expanded/collapsed to see reasoning details.
</p>
</div>
</section> </section>
<!-- Window Section --> <!-- Window Section -->
@@ -724,21 +744,6 @@
</p> </p>
</div> </div>
<!-- Minimize to Tray Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.minimize_to_tray}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Minimize to system tray</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Hide to tray instead of closing when you click the X button
</p>
</div>
<!-- Update Checks Toggle --> <!-- Update Checks Toggle -->
<div class="mb-4"> <div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer"> <label class="flex items-center gap-3 cursor-pointer">
+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>
+16 -4
View File
@@ -17,6 +17,7 @@
} from "$lib/stores/historyRestore"; } from "$lib/stores/historyRestore";
import MessageModeSelector from "$lib/components/MessageModeSelector.svelte"; import MessageModeSelector from "$lib/components/MessageModeSelector.svelte";
import SlashCommandMenu from "$lib/components/SlashCommandMenu.svelte"; import SlashCommandMenu from "$lib/components/SlashCommandMenu.svelte";
import SystemClock from "$lib/components/SystemClock.svelte";
import { getCurrentMode } from "$lib/stores/messageMode"; import { getCurrentMode } from "$lib/stores/messageMode";
import { formatMessageWithMode } from "$lib/types/messageMode"; import { formatMessageWithMode } from "$lib/types/messageMode";
import { import {
@@ -338,23 +339,32 @@ User: ${formattedMessage}`;
throw new Error("No active conversation"); throw new Error("No active conversation");
} }
// Get current working directory before reconnecting // Get current working directory and granted tools before reconnecting
const workingDir = await invoke<string>("get_working_directory", { conversationId }); const workingDir = await invoke<string>("get_working_directory", { conversationId });
const activeConversation = get(conversationsStore.activeConversation);
const grantedTools = activeConversation
? Array.from(activeConversation.grantedTools)
: [];
const config = configStore.getConfig();
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
// Set the flag to skip greeting on next connection // Set the flag to skip greeting on next connection
setSkipNextGreeting(true); setSkipNextGreeting(true);
// Reconnect to Claude // Reconnect to Claude with preserved permissions
await invoke("start_claude", { await invoke("start_claude", {
conversationId, conversationId,
options: { options: {
working_dir: workingDir, working_dir: workingDir,
model: config.model || null,
api_key: config.api_key || null,
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools,
}, },
}); });
// Update Discord RPC when reconnecting // Update Discord RPC when reconnecting
const config = configStore.getConfig();
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) { if (activeConversation) {
await updateDiscordRpc( await updateDiscordRpc(
activeConversation.name, activeConversation.name,
@@ -905,6 +915,8 @@ User: ${formattedMessage}`;
</svg> </svg>
<span>Clipboard</span> <span>Clipboard</span>
</button> </button>
<SystemClock />
</div> </div>
<div class="input-row"> <div class="input-row">
@@ -0,0 +1,458 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { readTextFile } from "@tauri-apps/plugin-fs";
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 {
const content = await readTextFile(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>
+201 -106
View File
@@ -1,25 +1,24 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { claudeStore, hasPermissionPending } from "$lib/stores/claude"; import { SvelteSet } from "svelte/reactivity";
import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character"; import { characterState } from "$lib/stores/character";
import type { PermissionRequest } from "$lib/types/messages"; import type { PermissionRequest } from "$lib/types/messages";
import { updateDiscordRpc } from "$lib/tauri"; import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
import { conversationsStore } from "$lib/stores/conversations"; import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config"; import { configStore } from "$lib/stores/config";
let isVisible = $state(false); let permissions: PermissionRequest[] = $state([]);
let permission: PermissionRequest | null = $state(null); let selectedPermissions = new SvelteSet<string>();
let grantedToolsList: string[] = $state([]); let grantedToolsList: string[] = $state([]);
let workingDirectory = $state(""); let workingDirectory = $state("");
hasPermissionPending.subscribe((pending) => { conversationsStore.pendingPermissions.subscribe((perms) => {
isVisible = pending; permissions = perms;
}); // When new permissions arrive, select all by default
if (perms.length > 0) {
claudeStore.pendingPermission.subscribe((perm) => { selectedPermissions = new SvelteSet(perms.map((p) => p.id));
permission = perm;
if (perm) {
characterState.setState("permission"); characterState.setState("permission");
} }
}); });
@@ -33,77 +32,103 @@
}); });
async function handleApproveAndReconnect() { async function handleApproveAndReconnect() {
if (permission) { const selectedPerms = permissions.filter((p) => selectedPermissions.has(p.id));
// Capture conversation history before clearing/reconnecting
const conversationHistory = claudeStore.getConversationHistory();
const approvedTool = permission.tool;
const toolInput = permission.input;
claudeStore.grantTool(approvedTool); if (selectedPerms.length === 0) {
const newGrantedTools = [...grantedToolsList, approvedTool]; claudeStore.addLine("system", "No permissions selected to approve");
claudeStore.addLine(
"system",
`Permission granted for: ${approvedTool}. Reconnecting with context...`
);
claudeStore.clearPermission(); claudeStore.clearPermission();
characterState.setTemporaryState("idle", 1000);
return;
}
// Stop current session and reconnect with new permissions // Capture conversation history before clearing/reconnecting
try { const conversationHistory = claudeStore.getConversationHistory();
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("stop_claude", { conversationId }); // Grant all selected tools
const newlyGrantedTools: string[] = [];
for (const perm of selectedPerms) {
if (!grantedToolsList.includes(perm.tool)) {
claudeStore.grantTool(perm.tool);
newlyGrantedTools.push(perm.tool);
}
}
// Small delay to ensure clean shutdown const newGrantedTools = [...grantedToolsList, ...newlyGrantedTools];
await new Promise((resolve) => setTimeout(resolve, 500)); const toolNames = selectedPerms.map((p) => p.tool).join(", ");
await invoke("start_claude", { claudeStore.addLine(
conversationId, "system",
options: { `Permission granted for ${selectedPerms.length} tool(s): ${toolNames}. Reconnecting with context...`
working_dir: workingDirectory || "/home/naomi", );
allowed_tools: newGrantedTools, claudeStore.clearPermission();
},
});
// Update Discord RPC when reconnecting after permission grant // Stop current session and reconnect with new permissions
const config = configStore.getConfig(); try {
const activeConversation = get(conversationsStore.activeConversation); const conversationId = get(claudeStore.activeConversationId);
if (activeConversation) { if (!conversationId) {
await updateDiscordRpc( throw new Error("No active conversation");
activeConversation.name, }
config.model || "claude",
activeConversation.startedAt
);
}
// Wait for connection to establish // Prevent stats reset on reconnection
await new Promise((resolve) => setTimeout(resolve, 1000)); setSkipNextGreeting(true);
// Send conversation context to restore state await invoke("stop_claude", { conversationId });
if (conversationHistory) {
const contextMessage = `[CONTEXT RESTORATION] // Small delay to ensure clean shutdown
I just granted you permission to use the ${approvedTool} tool. Here's our conversation so far: await new Promise((resolve) => setTimeout(resolve, 500));
const config = configStore.getConfig();
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDirectory || "/home/naomi",
model: config.model || null,
api_key: config.api_key || null,
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: newGrantedTools,
},
});
// Update Discord RPC when reconnecting after permission grant
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
// Wait for connection to establish
await new Promise((resolve) => setTimeout(resolve, 1000));
// Send conversation context to restore state
if (conversationHistory) {
const blockedActions = selectedPerms
.map((p) => `- ${p.tool} with input:\n${JSON.stringify(p.input, null, 2)}`)
.join("\n\n");
const contextMessage = `[CONTEXT RESTORATION]
I just granted you permission to use ${selectedPerms.length} tool(s): ${toolNames}. Here's our conversation so far:
${conversationHistory} ${conversationHistory}
The last action that was blocked was: ${approvedTool} with input: The actions that were blocked:
${JSON.stringify(toolInput, null, 2)} ${blockedActions}
Please continue where we left off and retry that action now that you have permission.`; Please continue where we left off and retry those actions now that you have permission.`;
await invoke("send_prompt", { await invoke("send_prompt", {
conversationId, conversationId,
message: contextMessage, message: contextMessage,
}); });
}
} catch (error) {
console.error("Failed to reconnect:", error);
claudeStore.addLine("error", `Reconnect failed: ${error}`);
} }
characterState.setTemporaryState("success", 2000);
} catch (error) {
console.error("Failed to reconnect:", error);
claudeStore.addLine("error", `Reconnect failed: ${error}`);
} }
characterState.setTemporaryState("success", 2000);
} }
function handleDismiss() { function handleDismiss() {
@@ -124,8 +149,24 @@ Please continue where we left off and retry that action now that you have permis
return grantedToolsList.includes(toolName); return grantedToolsList.includes(toolName);
} }
function togglePermission(toolRequestId: string) {
if (selectedPermissions.has(toolRequestId)) {
selectedPermissions.delete(toolRequestId);
} else {
selectedPermissions.add(toolRequestId);
}
}
function selectAll() {
selectedPermissions = new SvelteSet(permissions.map((p) => p.id));
}
function selectNone() {
selectedPermissions = new SvelteSet();
}
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
if (!isVisible || !permission) return; if (permissions.length === 0) return;
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
@@ -139,72 +180,126 @@ Please continue where we left off and retry that action now that you have permis
<svelte:window onkeydown={handleKeydown} /> <svelte:window onkeydown={handleKeydown} />
{#if isVisible && permission} {#if permissions.length > 0}
<div <div
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm" class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-[60] backdrop-blur-sm"
> >
<div <div
class="permission-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl" class="permission-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-2xl w-full mx-4 shadow-2xl max-h-[90vh] overflow-y-auto"
> >
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center"> <div class="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center">
<span class="text-xl">🔐</span> <span class="text-xl">🔐</span>
</div> </div>
<div> <div class="flex-1">
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Permission Blocked</h2> <h2 class="text-lg font-semibold text-[var(--text-primary)]">
<p class="text-sm text-[var(--text-secondary)]">Hikari tried to use a restricted tool</p> {permissions.length === 1
? "Permission Required"
: `${permissions.length} Permissions Required`}
</h2>
<p class="text-sm text-[var(--text-secondary)]">
Hikari tried to use {permissions.length === 1
? "a restricted tool"
: "restricted tools"}
</p>
</div>
<div class="flex gap-2 text-xs">
<button
onclick={selectAll}
class="px-2 py-1 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded transition-colors"
>
Select All
</button>
<button
onclick={selectNone}
class="px-2 py-1 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded transition-colors"
>
Select None
</button>
</div> </div>
</div> </div>
<div class="mb-4 px-3 py-2 bg-amber-500/10 border border-amber-500/30 rounded-md"> <div class="mb-4 px-3 py-2 bg-amber-500/10 border border-amber-500/30 rounded-md">
<p class="text-sm text-amber-300"> <p class="text-sm text-amber-300">
This action was automatically blocked. Approve to allow this tool for future requests. {permissions.length === 1
? "This action was automatically blocked. Select which permissions to grant."
: "These actions were automatically blocked. Select which permissions to grant."}
</p> </p>
</div> </div>
<div class="mb-4"> <div class="space-y-3 mb-6">
<div class="text-sm text-[var(--text-secondary)] mb-1">Tool</div> {#each permissions as perm (perm.id)}
<div <div
class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--accent-primary)] font-mono flex items-center justify-between" class="border border-[var(--border-color)] rounded-lg p-4 cursor-pointer transition-colors {selectedPermissions.has(
> perm.id
<span>{permission.tool}</span> )
{#if isToolAlreadyGranted(permission.tool)} ? 'bg-green-500/10 border-green-500/30'
<span class="text-xs text-green-400 bg-green-500/20 px-2 py-0.5 rounded" : 'bg-[var(--bg-secondary)] hover:bg-[var(--bg-secondary)]/80'}"
>Already Granted</span role="button"
> tabindex="0"
{/if} onclick={() => togglePermission(perm.id)}
</div> onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
togglePermission(perm.id);
}
}}
>
<div class="flex items-start gap-3">
<div class="mt-1">
<input
type="checkbox"
checked={selectedPermissions.has(perm.id)}
onchange={() => togglePermission(perm.id)}
class="w-4 h-4 accent-green-500"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
<span class="text-[var(--accent-primary)] font-mono text-sm font-medium">
{perm.tool}
</span>
{#if isToolAlreadyGranted(perm.tool)}
<span class="text-xs text-green-400 bg-green-500/20 px-2 py-0.5 rounded">
Already Granted
</span>
{/if}
</div>
<div class="text-sm text-[var(--text-secondary)] mb-2">
{perm.description}
</div>
{#if Object.keys(perm.input).length > 0}
<details class="text-xs">
<summary
class="cursor-pointer text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
View details
</summary>
<pre
class="mt-2 px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] overflow-x-auto max-h-32">{formatInput(
perm.input
)}</pre>
</details>
{/if}
</div>
</div>
</div>
{/each}
</div> </div>
<div class="mb-4">
<div class="text-sm text-[var(--text-secondary)] mb-1">Description</div>
<div class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--text-primary)]">
{permission.description}
</div>
</div>
{#if Object.keys(permission.input).length > 0}
<div class="mb-6">
<div class="text-sm text-[var(--text-secondary)] mb-1">Details</div>
<pre
class="px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] text-xs overflow-x-auto max-h-32">{formatInput(
permission.input
)}</pre>
</div>
{/if}
<div class="flex gap-3"> <div class="flex gap-3">
<button <button
onclick={handleDismiss} onclick={handleDismiss}
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium" class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
> >
Dismiss Dismiss All
</button> </button>
<button <button
onclick={handleApproveAndReconnect} onclick={handleApproveAndReconnect}
class="flex-1 px-4 py-2 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded-lg transition-colors font-medium" disabled={selectedPermissions.size === 0}
class="flex-1 px-4 py-2 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
> >
Allow & Reconnect Approve Selected ({selectedPermissions.size})
</button> </button>
</div> </div>
</div> </div>
+54 -2
View File
@@ -21,16 +21,19 @@
import HelpPanel from "./HelpPanel.svelte"; import HelpPanel from "./HelpPanel.svelte";
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte"; import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
import { achievementProgress } from "$lib/stores/achievements"; import { achievementProgress } from "$lib/stores/achievements";
import { runningAgentCount } from "$lib/stores/agents";
import SessionHistoryPanel from "./SessionHistoryPanel.svelte"; import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
import GitPanel from "./GitPanel.svelte"; import GitPanel from "./GitPanel.svelte";
import ProfilePanel from "./ProfilePanel.svelte"; import ProfilePanel from "./ProfilePanel.svelte";
import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
import { conversationsStore } from "$lib/stores/conversations"; import { conversationsStore } from "$lib/stores/conversations";
import { import {
generateContextInjection, generateContextInjection,
createSummary, createSummary,
sanitizeForJson, sanitizeForJson,
} from "$lib/utils/conversationUtils"; } from "$lib/utils/conversationUtils";
import { updateDiscordRpc } from "$lib/tauri"; import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
import { debugConsoleStore } from "$lib/stores/debugConsole";
const DISCORD_URL = "https://chat.nhcarrigan.com"; const DISCORD_URL = "https://chat.nhcarrigan.com";
const DONATE_URL = "https://donate.nhcarrigan.com"; const DONATE_URL = "https://donate.nhcarrigan.com";
@@ -48,8 +51,10 @@
let showSessionHistory = $state(false); let showSessionHistory = $state(false);
let showGitPanel = $state(false); let showGitPanel = $state(false);
let showProfile = $state(false); let showProfile = $state(false);
let showAgentMonitor = $state(false);
let isSummarising = $state(false); let isSummarising = $state(false);
const progress = $derived($achievementProgress); const progress = $derived($achievementProgress);
const activeAgentCount = $derived($runningAgentCount);
let currentConfig: HikariConfig = $state({ let currentConfig: HikariConfig = $state({
model: null, model: null,
api_key: null, api_key: null,
@@ -65,7 +70,6 @@
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null, character_panel_width: null,
font_size: 14, font_size: 14,
minimize_to_tray: false,
streamer_mode: false, streamer_mode: false,
streamer_hide_paths: false, streamer_hide_paths: false,
compact_mode: false, compact_mode: false,
@@ -88,6 +92,7 @@
budget_action: "warn", budget_action: "warn",
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true,
}); });
let streamerModeActive = $state(false); let streamerModeActive = $state(false);
@@ -190,6 +195,9 @@
throw new Error("No active conversation"); throw new Error("No active conversation");
} }
await invoke("stop_claude", { conversationId }); await invoke("stop_claude", { conversationId });
// Clear granted permissions when user explicitly disconnects
claudeStore.revokeAllTools();
} catch (error) { } catch (error) {
console.error("Failed to stop Claude:", error); console.error("Failed to stop Claude:", error);
} }
@@ -248,6 +256,9 @@
: sanitizedContent; : sanitizedContent;
// Step 1: Disconnect from Claude to reset context // Step 1: Disconnect from Claude to reset context
// Prevent stats reset on reconnection
setSkipNextGreeting(true);
if (connectionStatus === "connected") { if (connectionStatus === "connected") {
await invoke("stop_claude", { conversationId: activeId }); await invoke("stop_claude", { conversationId: activeId });
} }
@@ -460,6 +471,29 @@
/> />
</svg> </svg>
</button> </button>
<button
onclick={() => (showAgentMonitor = !showAgentMonitor)}
class="p-1 text-gray-500 icon-trans-hover relative {showAgentMonitor
? 'text-[var(--trans-pink)]'
: ''}"
title="Agent Monitor"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
{#if activeAgentCount > 0}
<span
class="absolute -top-1 -right-1 bg-blue-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px] animate-pulse"
>
{activeAgentCount}
</span>
{/if}
</button>
<button <button
onclick={() => (showStats = !showStats)} onclick={() => (showStats = !showStats)}
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}" class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
@@ -474,6 +508,20 @@
/> />
</svg> </svg>
</button> </button>
<button
onclick={() => debugConsoleStore.toggle()}
class="p-1 text-gray-500 icon-trans-hover"
title="Debug Console (Ctrl+`)"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</button>
<button <button
onclick={configStore.openSidebar} onclick={configStore.openSidebar}
class="p-1 text-gray-500 icon-trans-hover" class="p-1 text-gray-500 icon-trans-hover"
@@ -632,3 +680,7 @@
{#if showProfile} {#if showProfile}
<ProfilePanel onClose={() => (showProfile = false)} /> <ProfilePanel onClose={() => (showProfile = false)} />
{/if} {/if}
{#if showAgentMonitor}
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
{/if}
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
let currentTime = $state("");
function updateTime() {
const now = new Date();
// Format date as "1 January 2026"
const day = now.getDate();
const month = now.toLocaleString("en-GB", { month: "long" });
const year = now.getFullYear();
// Format time as HH:MM:SS (24-hour)
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
const seconds = String(now.getSeconds()).padStart(2, "0");
currentTime = `${day} ${month} ${year}, ${hours}:${minutes}:${seconds}`;
}
// Update immediately on mount
updateTime();
// Update every second
const interval = setInterval(updateTime, 1000);
// Cleanup on component destroy
$effect(() => {
return () => {
clearInterval(interval);
};
});
</script>
<div class="system-clock">
<svg
class="clock-icon"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span class="clock-text">{currentTime}</span>
</div>
<style>
.system-clock {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
font-size: 0.85rem;
font-family: var(--font-mono, monospace);
transition: all 0.2s;
margin-left: auto;
}
.system-clock:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.clock-icon {
flex-shrink: 0;
opacity: 0.7;
}
.clock-text {
white-space: nowrap;
}
</style>
+149
View File
@@ -0,0 +1,149 @@
/**
* SystemClock Component Tests
*
* Note: This file tests the time formatting logic used by the SystemClock component.
* Full component rendering tests are challenging with Svelte 5 + @testing-library/svelte
* due to SSR/CSR compatibility issues. The component itself is simple and visually
* testable - it displays the current date and time, updating every second.
*
* What this component does:
* - Displays date in British format: "7 February 2026"
* - Displays time in 24-hour format: "14:35:42"
* - Updates every second via setInterval
* - Cleans up interval on unmount via $effect
*
* Manual testing checklist:
* - [ ] Clock appears above the Send button
* - [ ] Time updates every second
* - [ ] Date format is "DD Month YYYY"
* - [ ] Time format is "HH:MM:SS" (24-hour)
* - [ ] Hover effect works (border turns accent colour)
*/
import { describe, it, expect } from "vitest";
// Helper function that mirrors the component's formatting logic
function formatDateTime(date: Date): string {
const day = date.getDate();
const month = date.toLocaleString("en-GB", { month: "long" });
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${day} ${month} ${year}, ${hours}:${minutes}:${seconds}`;
}
describe("SystemClock date/time formatting", () => {
it("formats date in British format (DD Month YYYY)", () => {
// Use local timezone (not UTC) since the component uses local time
const date = new Date(2026, 1, 7, 14, 35, 42); // Feb 7, 2026 14:35:42 local
const formatted = formatDateTime(date);
expect(formatted).toContain("7 February 2026");
});
it("formats time in 24-hour format (HH:MM:SS)", () => {
const date = new Date(2026, 1, 7, 14, 35, 42);
const formatted = formatDateTime(date);
// Should have the pattern HH:MM:SS
expect(formatted).toMatch(/\d{2}:\d{2}:\d{2}/);
expect(formatted).toContain("14:35:42");
});
it("combines date and time with comma separator", () => {
const date = new Date(2026, 1, 7, 14, 35, 42);
const formatted = formatDateTime(date);
expect(formatted).toBe("7 February 2026, 14:35:42");
});
it("pads single-digit hours, minutes, and seconds with zeros", () => {
const date = new Date(2026, 1, 7, 3, 5, 8);
const formatted = formatDateTime(date);
// Should have leading zeros: 03:05:08, not 3:5:8
expect(formatted).toContain("03:05:08");
});
it("handles different months correctly", () => {
const date = new Date(2026, 11, 25, 12, 0, 0); // December is month 11
const formatted = formatDateTime(date);
expect(formatted).toContain("25 December 2026");
});
it("handles year changes correctly", () => {
const date = new Date(2027, 0, 1, 0, 0, 0); // January is month 0
const formatted = formatDateTime(date);
expect(formatted).toContain("1 January 2027");
expect(formatted).toContain("00:00:00");
});
it("handles midnight correctly", () => {
const date = new Date(2026, 1, 7, 0, 0, 0);
const formatted = formatDateTime(date);
expect(formatted).toContain("00:00:00");
});
it("handles noon correctly", () => {
const date = new Date(2026, 1, 7, 12, 0, 0);
const formatted = formatDateTime(date);
// 24-hour format, so noon is 12:00:00, not 00:00:00
expect(formatted).toContain("12:00:00");
});
it("handles end of day correctly", () => {
const date = new Date(2026, 1, 7, 23, 59, 59);
const formatted = formatDateTime(date);
expect(formatted).toContain("23:59:59");
});
it("handles month boundaries correctly", () => {
// Last day of January
const jan31 = new Date(2026, 0, 31, 23, 59, 59);
expect(formatDateTime(jan31)).toContain("31 January 2026");
// First day of February
const feb1 = new Date(2026, 1, 1, 0, 0, 0);
expect(formatDateTime(feb1)).toContain("1 February 2026");
});
it("handles leap year February correctly", () => {
// 2024 is a leap year
const feb29 = new Date(2024, 1, 29, 12, 0, 0);
const formatted = formatDateTime(feb29);
expect(formatted).toContain("29 February 2024");
});
it("handles all 12 months correctly", () => {
const months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
months.forEach((month, index) => {
const date = new Date(2026, index, 15, 12, 0, 0);
const formatted = formatDateTime(date);
expect(formatted).toContain(month);
});
});
});
+85 -49
View File
@@ -4,9 +4,10 @@
import ConversationTabs from "./ConversationTabs.svelte"; import ConversationTabs from "./ConversationTabs.svelte";
import Markdown from "./Markdown.svelte"; import Markdown from "./Markdown.svelte";
import HighlightedText from "./HighlightedText.svelte"; import HighlightedText from "./HighlightedText.svelte";
import ThinkingBlock from "./ThinkingBlock.svelte";
import { searchState, searchQuery } from "$lib/stores/search"; import { searchState, searchQuery } from "$lib/stores/search";
import { clipboardStore } from "$lib/stores/clipboard"; import { clipboardStore } from "$lib/stores/clipboard";
import { shouldHidePaths, maskPaths } from "$lib/stores/config"; import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
let terminalElement: HTMLDivElement; let terminalElement: HTMLDivElement;
let shouldAutoScroll = true; let shouldAutoScroll = true;
@@ -24,6 +25,11 @@
hidePaths = value; hidePaths = value;
}); });
let showThinking = true;
showThinkingBlocks.subscribe((value) => {
showThinking = value;
});
claudeStore.terminalLines.subscribe((value) => { claudeStore.terminalLines.subscribe((value) => {
lines = value; lines = value;
}); });
@@ -84,6 +90,8 @@
return "terminal-tool"; return "terminal-tool";
case "error": case "error":
return "terminal-error"; return "terminal-error";
case "thinking":
return "terminal-thinking";
default: default:
return "terminal-default"; return "terminal-default";
} }
@@ -209,58 +217,86 @@
</div> </div>
{:else} {:else}
{#each lines as line (line.id)} {#each lines as line (line.id)}
<div class="terminal-line mb-2 {getLineClass(line.type)} relative group"> {#if line.type === "thinking"}
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span> {#if showThinking}
{#if line.cost && line.cost.costUsd > 0} <ThinkingBlock content={line.content} timestamp={line.timestamp} />
<span
class="terminal-cost text-xs mr-2"
title="Input: {line.cost.inputTokens} | Output: {line.cost.outputTokens}"
>
${line.cost.costUsd < 0.01
? line.cost.costUsd.toFixed(4)
: line.cost.costUsd.toFixed(3)}
</span>
{/if} {/if}
{#if getLinePrefix(line.type)} {:else}
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span> <div
{/if} class="terminal-line mb-2 {getLineClass(line.type)} relative group"
{#if line.toolName} style={line.parentToolUseId
<span class="terminal-tool-name mr-2">[{line.toolName}]</span> ? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
{/if} : ""}
{#if line.type === "assistant" || line.type === "user"} >
<div class="message-content-wrapper"> <span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
<Markdown {#if line.parentToolUseId}
<span class="text-xs mr-2 opacity-60" title="Message from subagent">
<svg
class="inline-block w-3 h-3 -mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</span>
{/if}
{#if line.cost && line.cost.costUsd > 0}
<span
class="terminal-cost text-xs mr-2"
title="Input: {line.cost.inputTokens} | Output: {line.cost.outputTokens}"
>
${line.cost.costUsd < 0.01
? line.cost.costUsd.toFixed(4)
: line.cost.costUsd.toFixed(3)}
</span>
{/if}
{#if getLinePrefix(line.type)}
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
{/if}
{#if line.toolName}
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if}
{#if line.type === "assistant" || line.type === "user"}
<div class="message-content-wrapper">
<Markdown
content={maskPaths(line.content, hidePaths)}
searchQuery={currentSearchQuery}
/>
<button
class="copy-message-btn opacity-0 group-hover:opacity-100 transition-opacity"
onclick={() => handleCopyMessage(line.id, line.content)}
title="Copy message"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
</button>
</div>
{:else}
<HighlightedText
content={maskPaths(line.content, hidePaths)} content={maskPaths(line.content, hidePaths)}
searchQuery={currentSearchQuery} searchQuery={currentSearchQuery}
/> />
<button {/if}
class="copy-message-btn opacity-0 group-hover:opacity-100 transition-opacity" </div>
onclick={() => handleCopyMessage(line.id, line.content)} {/if}
title="Copy message"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
</button>
</div>
{:else}
<HighlightedText
content={maskPaths(line.content, hidePaths)}
searchQuery={currentSearchQuery}
/>
{/if}
</div>
{/each} {/each}
{/if} {/if}
</div> </div>
+130
View File
@@ -0,0 +1,130 @@
<script lang="ts">
interface Props {
content: string;
timestamp: Date;
}
let { content, timestamp }: Props = $props();
let isExpanded = $state(false);
function toggleExpanded() {
isExpanded = !isExpanded;
}
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
}
</script>
<div class="thinking-block">
<button class="thinking-header" onclick={toggleExpanded} type="button">
<span class="thinking-timestamp">{formatTime(timestamp)}</span>
<svg
class="thinking-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
width="16"
height="16"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
<span class="thinking-label">Extended Thinking</span>
<svg
class="chevron"
class:expanded={isExpanded}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
width="14"
height="14"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{#if isExpanded}
<div class="thinking-content">
{content}
</div>
{/if}
</div>
<style>
.thinking-block {
margin-bottom: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
background: var(--bg-secondary);
opacity: 0.85;
}
.thinking-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
cursor: pointer;
color: var(--text-secondary);
font-size: 0.875rem;
transition: all 0.2s;
}
.thinking-header:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
.thinking-timestamp {
font-family: monospace;
font-size: 0.75rem;
opacity: 0.7;
}
.thinking-icon {
flex-shrink: 0;
}
.thinking-label {
flex: 1;
text-align: left;
font-style: italic;
}
.chevron {
flex-shrink: 0;
transition: transform 0.2s;
}
.chevron.expanded {
transform: rotate(180deg);
}
.thinking-content {
padding: 0.75rem;
border-top: 1px solid var(--border-color);
color: var(--text-secondary);
font-family: monospace;
font-size: 0.875rem;
font-style: italic;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
</style>
+9 -2
View File
@@ -5,7 +5,7 @@
import { claudeStore, hasQuestionPending } from "$lib/stores/claude"; import { claudeStore, hasQuestionPending } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character"; import { characterState } from "$lib/stores/character";
import type { UserQuestionEvent } from "$lib/types/messages"; import type { UserQuestionEvent } from "$lib/types/messages";
import { updateDiscordRpc } from "$lib/tauri"; import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
import { conversationsStore } from "$lib/stores/conversations"; import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config"; import { configStore } from "$lib/stores/config";
@@ -89,20 +89,27 @@
claudeStore.clearQuestion(); claudeStore.clearQuestion();
try { try {
// Prevent stats reset on reconnection
setSkipNextGreeting(true);
await invoke("stop_claude", { conversationId }); await invoke("stop_claude", { conversationId });
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
const config = configStore.getConfig();
await invoke("start_claude", { await invoke("start_claude", {
conversationId, conversationId,
options: { options: {
working_dir: workingDirectory || "/home/naomi", working_dir: workingDirectory || "/home/naomi",
model: config.model || null,
api_key: config.api_key || null,
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: grantedToolsList, allowed_tools: grantedToolsList,
}, },
}); });
// Update Discord RPC when reconnecting after answering question // Update Discord RPC when reconnecting after answering question
const config = configStore.getConfig();
const activeConversation = get(conversationsStore.activeConversation); const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) { if (activeConversation) {
await updateDiscordRpc( await updateDiscordRpc(
+121
View File
@@ -0,0 +1,121 @@
import { writable, derived } from "svelte/store";
import type { AgentInfo } from "$lib/types/agents";
// Map of conversation ID -> agents in that conversation
const agentsByConversation = writable<Record<string, AgentInfo[]>>({});
function createAgentStore() {
return {
subscribe: agentsByConversation.subscribe,
addAgent(conversationId: string, agent: AgentInfo) {
agentsByConversation.update((state) => {
const existing = state[conversationId] || [];
return {
...state,
[conversationId]: [...existing, agent],
};
});
},
updateAgentId(conversationId: string, toolUseId: string, agentId: string) {
agentsByConversation.update((state) => {
const agents = state[conversationId];
if (!agents) return state;
const agentIndex = agents.findIndex((a) => a.toolUseId === toolUseId);
if (agentIndex === -1) return state;
const updated = [...agents];
updated[agentIndex] = {
...updated[agentIndex],
agentId,
};
return {
...state,
[conversationId]: updated,
};
});
},
endAgent(conversationId: string, toolUseId: string, endedAt: number, isError: boolean) {
agentsByConversation.update((state) => {
const agents = state[conversationId];
if (!agents) return state;
const agentIndex = agents.findIndex((a) => a.toolUseId === toolUseId);
if (agentIndex === -1) return state;
const updated = [...agents];
const agent = updated[agentIndex];
const durationMs = endedAt - agent.startedAt;
updated[agentIndex] = {
...agent,
endedAt,
status: isError ? "errored" : "completed",
durationMs,
};
return {
...state,
[conversationId]: updated,
};
});
},
markAllErrored(conversationId: string) {
agentsByConversation.update((state) => {
const agents = state[conversationId];
if (!agents) return state;
const now = Date.now();
const updated = agents.map((agent) =>
agent.status === "running"
? { ...agent, endedAt: now, status: "errored" as const }
: agent
);
return {
...state,
[conversationId]: updated,
};
});
},
clearCompleted(conversationId: string) {
agentsByConversation.update((state) => {
const agents = state[conversationId];
if (!agents) return state;
return {
...state,
[conversationId]: agents.filter((a) => a.status === "running"),
};
});
},
clearConversation(conversationId: string) {
agentsByConversation.update((state) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Unused destructured value
const { [conversationId]: _, ...rest } = state;
return rest;
});
},
};
}
export const agentStore = createAgentStore();
export function getAgentsForConversation(conversationId: string) {
return derived(agentsByConversation, ($state) => $state[conversationId] || []);
}
export const runningAgentCount = derived(agentsByConversation, ($state) => {
let count = 0;
for (const agents of Object.values($state)) {
count += agents.filter((a) => a.status === "running").length;
}
return count;
});
+4 -1
View File
@@ -101,7 +101,10 @@ export const claudeStore = {
export const hasPermissionPending = derived( export const hasPermissionPending = derived(
claudeStore.activeConversation, claudeStore.activeConversation,
($conversation) => $conversation?.pendingPermission !== null ($conversation) =>
$conversation?.pendingPermissions !== null &&
$conversation?.pendingPermissions !== undefined &&
$conversation.pendingPermissions.length > 0
); );
export const hasQuestionPending = derived( export const hasQuestionPending = derived(
+329 -2
View File
@@ -14,6 +14,7 @@ import {
type Theme, type Theme,
type CustomThemeColors, type CustomThemeColors,
} from "./config"; } from "./config";
import { invoke } from "@tauri-apps/api/core";
// Mock Tauri APIs // Mock Tauri APIs
vi.mock("@tauri-apps/api/core", () => ({ vi.mock("@tauri-apps/api/core", () => ({
@@ -167,7 +168,6 @@ describe("config store", () => {
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false, always_on_top: false,
minimize_to_tray: true,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: 300, character_panel_width: 300,
font_size: 14, font_size: 14,
@@ -193,6 +193,7 @@ describe("config store", () => {
budget_action: "warn", budget_action: "warn",
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true,
}; };
expect(config.model).toBe("claude-sonnet-4"); expect(config.model).toBe("claude-sonnet-4");
@@ -213,7 +214,6 @@ describe("config store", () => {
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false, always_on_top: false,
minimize_to_tray: false,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null, character_panel_width: null,
font_size: 14, font_size: 14,
@@ -239,6 +239,7 @@ describe("config store", () => {
budget_action: "warn", budget_action: "warn",
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true,
}; };
expect(config.model).toBeNull(); expect(config.model).toBeNull();
@@ -489,4 +490,330 @@ describe("config store", () => {
expect(typeof configStore.saveError.subscribe).toBe("function"); expect(typeof configStore.saveError.subscribe).toBe("function");
}); });
}); });
describe("Race Condition Tests", () => {
beforeEach(async () => {
// Setup mock to return a default config for load_config
const mockInvokeImpl = vi.mocked(invoke);
mockInvokeImpl.mockResolvedValue({
model: null,
api_key: null,
custom_instructions: null,
mcp_servers_json: null,
auto_granted_tools: [],
theme: "dark",
greeting_enabled: false,
greeting_custom_prompt: null,
notifications_enabled: false,
notification_volume: 0.7,
always_on_top: false,
update_checks_enabled: false,
character_panel_width: null,
font_size: 14,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
profile_name: null,
profile_avatar_path: null,
profile_bio: null,
custom_theme_colors: {
bg_primary: null,
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
},
budget_enabled: false,
session_token_budget: null,
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
discord_rpc_enabled: false,
});
// Load initial config
await configStore.loadConfig();
vi.clearAllMocks();
});
it("handles rapid sequential config updates correctly", async () => {
// This test validates the fix for the config race condition that caused data loss
const mockInvokeImpl = vi.mocked(invoke);
const invokeCalls: Array<{ command: string; config: HikariConfig }> = [];
mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => {
if (command === "save_config" && args && typeof args === "object" && "config" in args) {
invokeCalls.push({ command, config: args.config as HikariConfig });
// Simulate small delay in saving
await new Promise((resolve) => setTimeout(resolve, 10));
}
return null;
});
// Perform rapid updates
await Promise.all([
configStore.updateConfig({ font_size: 16 }),
configStore.updateConfig({ theme: "light" }),
configStore.updateConfig({ compact_mode: true }),
]);
// All three updates should have been saved
expect(invokeCalls.length).toBe(3);
// Get final config
const finalConfig = configStore.getConfig();
// Final config should have all updates
// Note: The last update wins for each field, but all fields should be preserved
expect(finalConfig.compact_mode).toBe(true);
});
it("preserves previous field values during concurrent updates", async () => {
// Set initial values
await configStore.updateConfig({
font_size: 16,
theme: "dark",
compact_mode: false,
streamer_mode: false,
});
vi.clearAllMocks();
const mockInvokeImpl = vi.mocked(invoke);
const invokeCalls: Array<{ command: string; config: HikariConfig }> = [];
mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => {
if (command === "save_config" && args && typeof args === "object" && "config" in args) {
invokeCalls.push({ command, config: args.config as HikariConfig });
await new Promise((resolve) => setTimeout(resolve, 5));
}
return null;
});
// Update different fields concurrently
await Promise.all([
configStore.updateConfig({ font_size: 18 }),
configStore.updateConfig({ theme: "light" }),
configStore.updateConfig({ compact_mode: true }),
]);
// Check that each save included all previous config values
invokeCalls.forEach((call) => {
// Each save should have a complete config, not just the updated field
expect(call.config).toHaveProperty("font_size");
expect(call.config).toHaveProperty("theme");
expect(call.config).toHaveProperty("compact_mode");
expect(call.config).toHaveProperty("streamer_mode");
expect(call.config).toHaveProperty("model");
expect(call.config).toHaveProperty("api_key");
});
});
it("handles update during save operation", async () => {
const mockInvokeImpl = vi.mocked(invoke);
let firstSaveStarted = false;
let firstSaveCompleted = false;
mockInvokeImpl.mockImplementation(async (command: string) => {
if (command === "save_config") {
if (!firstSaveStarted) {
firstSaveStarted = true;
// Simulate slow save
await new Promise((resolve) => setTimeout(resolve, 50));
firstSaveCompleted = true;
} else {
// Second save starts while first is in progress
expect(firstSaveStarted).toBe(true);
// First save might not be complete yet (race condition scenario)
}
}
return null;
});
// Start first update
const firstUpdate = configStore.updateConfig({ font_size: 16 });
// Wait a bit then start second update whilst first is still saving
await new Promise((resolve) => setTimeout(resolve, 10));
const secondUpdate = configStore.updateConfig({ theme: "light" });
// Wait for both to complete
await Promise.all([firstUpdate, secondUpdate]);
// Both should complete successfully without errors
expect(firstSaveCompleted).toBe(true);
});
it("getConfig returns most recently set configuration", async () => {
await configStore.updateConfig({ font_size: 14 });
expect(configStore.getConfig().font_size).toBe(14);
await configStore.updateConfig({ font_size: 16 });
expect(configStore.getConfig().font_size).toBe(16);
await configStore.updateConfig({ font_size: 18 });
expect(configStore.getConfig().font_size).toBe(18);
});
it("updates do not lose data from previous operations", async () => {
// Set multiple fields
await configStore.updateConfig({
font_size: 16,
theme: "dark",
compact_mode: true,
streamer_mode: true,
model: "claude-sonnet-4",
});
// Update just one field
await configStore.updateConfig({ theme: "light" });
// Other fields should be preserved
const config = configStore.getConfig();
expect(config.theme).toBe("light");
expect(config.font_size).toBe(16);
expect(config.compact_mode).toBe(true);
expect(config.streamer_mode).toBe(true);
expect(config.model).toBe("claude-sonnet-4");
});
it("auto granted tools are not lost during other updates", async () => {
// Add some tools
await configStore.addAutoGrantedTool("Read");
await configStore.addAutoGrantedTool("Write");
expect(configStore.getConfig().auto_granted_tools).toContain("Read");
expect(configStore.getConfig().auto_granted_tools).toContain("Write");
// Update another field
await configStore.updateConfig({ theme: "light" });
// Tools should still be there
expect(configStore.getConfig().auto_granted_tools).toContain("Read");
expect(configStore.getConfig().auto_granted_tools).toContain("Write");
});
it("custom theme colors persist across other config updates", async () => {
const customColors: CustomThemeColors = {
bg_primary: "#1a1a2e",
bg_secondary: "#16213e",
bg_terminal: "#0f0f23",
accent_primary: "#e94560",
accent_secondary: "#533483",
text_primary: "#eaeaea",
text_secondary: "#a0a0a0",
border_color: "#333355",
};
await configStore.setCustomThemeColors(customColors);
// Update another field
await configStore.updateConfig({ font_size: 18 });
// Colors should still be there
const config = configStore.getConfig();
expect(config.custom_theme_colors.bg_primary).toBe("#1a1a2e");
expect(config.custom_theme_colors.accent_primary).toBe("#e94560");
});
it("handles save errors gracefully without losing data", async () => {
const mockInvokeImpl = vi.mocked(invoke);
// Set initial config
await configStore.updateConfig({ font_size: 14 });
// Make next save fail
mockInvokeImpl.mockRejectedValueOnce(new Error("Save failed"));
// Try to update - should throw
await expect(configStore.updateConfig({ theme: "light" })).rejects.toThrow();
// Original config should still be accessible
expect(configStore.getConfig().font_size).toBe(14);
});
});
describe("Config Persistence Tests", () => {
it("loadConfig retrieves saved configuration", async () => {
const mockConfig: HikariConfig = {
model: "claude-sonnet-4",
api_key: "test-key",
custom_instructions: "Be helpful",
mcp_servers_json: "{}",
auto_granted_tools: ["Read", "Write"],
theme: "light",
greeting_enabled: false,
greeting_custom_prompt: null,
notifications_enabled: false,
notification_volume: 0.5,
always_on_top: true,
update_checks_enabled: false,
character_panel_width: 400,
font_size: 18,
streamer_mode: true,
streamer_hide_paths: true,
compact_mode: true,
profile_name: "Test User",
profile_avatar_path: "/test/avatar.png",
profile_bio: "Test bio",
custom_theme_colors: {
bg_primary: null,
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
},
budget_enabled: true,
session_token_budget: 100000,
session_cost_budget: 1.5,
budget_action: "block",
budget_warning_threshold: 0.9,
discord_rpc_enabled: false,
show_thinking_blocks: true,
};
const mockInvokeImpl = vi.mocked(invoke);
mockInvokeImpl.mockResolvedValueOnce(mockConfig);
await configStore.loadConfig();
const loadedConfig = configStore.getConfig();
expect(loadedConfig.model).toBe("claude-sonnet-4");
expect(loadedConfig.theme).toBe("light");
expect(loadedConfig.font_size).toBe(18);
expect(loadedConfig.auto_granted_tools).toEqual(["Read", "Write"]);
});
it("saveConfig persists configuration to backend", async () => {
const mockInvokeImpl = vi.mocked(invoke);
const savedConfigs: HikariConfig[] = [];
mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => {
if (command === "save_config" && args && typeof args === "object" && "config" in args) {
savedConfigs.push(args.config as HikariConfig);
}
return null;
});
const configToSave: Partial<HikariConfig> = {
model: "claude-sonnet-4",
theme: "dark",
font_size: 16,
};
await configStore.updateConfig(configToSave);
expect(savedConfigs.length).toBeGreaterThan(0);
const lastSaved = savedConfigs[savedConfigs.length - 1];
expect(lastSaved.model).toBe("claude-sonnet-4");
expect(lastSaved.theme).toBe("dark");
expect(lastSaved.font_size).toBe(16);
});
});
}); });
+25 -23
View File
@@ -27,7 +27,6 @@ export interface HikariConfig {
notifications_enabled: boolean; notifications_enabled: boolean;
notification_volume: number; notification_volume: number;
always_on_top: boolean; always_on_top: boolean;
minimize_to_tray: boolean;
update_checks_enabled: boolean; update_checks_enabled: boolean;
character_panel_width: number | null; character_panel_width: number | null;
font_size: number; font_size: number;
@@ -46,6 +45,8 @@ export interface HikariConfig {
budget_warning_threshold: number; budget_warning_threshold: number;
// Discord RPC settings // Discord RPC settings
discord_rpc_enabled: boolean; discord_rpc_enabled: boolean;
// Thinking blocks settings
show_thinking_blocks: boolean;
} }
const defaultConfig: HikariConfig = { const defaultConfig: HikariConfig = {
@@ -60,7 +61,6 @@ const defaultConfig: HikariConfig = {
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false, always_on_top: false,
minimize_to_tray: false,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null, character_panel_width: null,
font_size: 14, font_size: 14,
@@ -86,6 +86,7 @@ const defaultConfig: HikariConfig = {
budget_action: "warn", budget_action: "warn",
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true,
}; };
function createConfigStore() { function createConfigStore() {
@@ -94,6 +95,14 @@ function createConfigStore() {
const isSidebarOpen = writable<boolean>(false); const isSidebarOpen = writable<boolean>(false);
const saveError = writable<string | null>(null); const saveError = writable<string | null>(null);
// Internal function to get current config synchronously
function getCurrentConfig(): HikariConfig {
let currentConfig: HikariConfig = defaultConfig;
const unsubscribe = config.subscribe((c) => (currentConfig = c));
unsubscribe();
return currentConfig;
}
async function loadConfig() { async function loadConfig() {
isLoading.set(true); isLoading.set(true);
try { try {
@@ -121,8 +130,7 @@ function createConfigStore() {
} }
async function updateConfig(updates: Partial<HikariConfig>) { async function updateConfig(updates: Partial<HikariConfig>) {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
const newConfig = { ...currentConfig, ...updates }; const newConfig = { ...currentConfig, ...updates };
await saveConfig(newConfig); await saveConfig(newConfig);
} }
@@ -147,15 +155,13 @@ function createConfigStore() {
updates.custom_theme_colors = customColors; updates.custom_theme_colors = customColors;
} }
await updateConfig(updates); await updateConfig(updates);
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
applyTheme(theme, currentConfig.custom_theme_colors); applyTheme(theme, currentConfig.custom_theme_colors);
}, },
setCustomThemeColors: async (colors: CustomThemeColors) => { setCustomThemeColors: async (colors: CustomThemeColors) => {
await updateConfig({ custom_theme_colors: colors }); await updateConfig({ custom_theme_colors: colors });
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
if (currentConfig.theme === "custom") { if (currentConfig.theme === "custom") {
applyCustomThemeColors(colors); applyCustomThemeColors(colors);
} }
@@ -168,16 +174,14 @@ function createConfigStore() {
}, },
increaseFontSize: async () => { increaseFontSize: async () => {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2); const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2);
await updateConfig({ font_size: newSize }); await updateConfig({ font_size: newSize });
applyFontSize(newSize); applyFontSize(newSize);
}, },
decreaseFontSize: async () => { decreaseFontSize: async () => {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2); const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2);
await updateConfig({ font_size: newSize }); await updateConfig({ font_size: newSize });
applyFontSize(newSize); applyFontSize(newSize);
@@ -189,8 +193,7 @@ function createConfigStore() {
}, },
addAutoGrantedTool: async (tool: string) => { addAutoGrantedTool: async (tool: string) => {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
if (!currentConfig.auto_granted_tools.includes(tool)) { if (!currentConfig.auto_granted_tools.includes(tool)) {
const newTools = [...currentConfig.auto_granted_tools, tool]; const newTools = [...currentConfig.auto_granted_tools, tool];
await updateConfig({ auto_granted_tools: newTools }); await updateConfig({ auto_granted_tools: newTools });
@@ -198,27 +201,22 @@ function createConfigStore() {
}, },
removeAutoGrantedTool: async (tool: string) => { removeAutoGrantedTool: async (tool: string) => {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
const newTools = currentConfig.auto_granted_tools.filter((t) => t !== tool); const newTools = currentConfig.auto_granted_tools.filter((t) => t !== tool);
await updateConfig({ auto_granted_tools: newTools }); await updateConfig({ auto_granted_tools: newTools });
}, },
getConfig: (): HikariConfig => { getConfig: (): HikariConfig => {
let currentConfig: HikariConfig = defaultConfig; return getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
return currentConfig;
}, },
toggleStreamerMode: async () => { toggleStreamerMode: async () => {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
await updateConfig({ streamer_mode: !currentConfig.streamer_mode }); await updateConfig({ streamer_mode: !currentConfig.streamer_mode });
}, },
toggleCompactMode: async () => { toggleCompactMode: async () => {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
await updateConfig({ compact_mode: !currentConfig.compact_mode }); await updateConfig({ compact_mode: !currentConfig.compact_mode });
}, },
@@ -302,6 +300,10 @@ export const shouldHidePaths = derived(
configStore.config, configStore.config,
($config) => $config.streamer_mode && $config.streamer_hide_paths ($config) => $config.streamer_mode && $config.streamer_hide_paths
); );
export const showThinkingBlocks = derived(
configStore.config,
($config) => $config.show_thinking_blocks
);
/** /**
* Masks file paths in text when streamer mode with hide paths is enabled. * Masks file paths in text when streamer mode with hide paths is enabled.
+41 -9
View File
@@ -28,7 +28,7 @@ export interface Conversation {
characterState: CharacterState; characterState: CharacterState;
isProcessing: boolean; isProcessing: boolean;
grantedTools: Set<string>; grantedTools: Set<string>;
pendingPermission: PermissionRequest | null; pendingPermissions: PermissionRequest[];
pendingQuestion: UserQuestionEvent | null; pendingQuestion: UserQuestionEvent | null;
scrollPosition: number; scrollPosition: number;
createdAt: Date; createdAt: Date;
@@ -66,7 +66,7 @@ function createConversationsStore() {
characterState: "idle", characterState: "idle",
isProcessing: false, isProcessing: false,
grantedTools: new Set(), grantedTools: new Set(),
pendingPermission: null, pendingPermissions: [],
pendingQuestion: null, pendingQuestion: null,
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll) scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
createdAt: new Date(), createdAt: new Date(),
@@ -120,7 +120,11 @@ function createConversationsStore() {
); );
const pendingPermission = derived( const pendingPermission = derived(
activeConversation, activeConversation,
($conv) => $conv?.pendingPermission || null ($conv) => $conv?.pendingPermissions[0] || null
);
const pendingPermissions = derived(
activeConversation,
($conv) => $conv?.pendingPermissions || []
); );
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null); const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1); const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
@@ -133,6 +137,7 @@ function createConversationsStore() {
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe }, currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
terminalLines: { subscribe: terminalLines.subscribe }, terminalLines: { subscribe: terminalLines.subscribe },
pendingPermission: { subscribe: pendingPermission.subscribe }, pendingPermission: { subscribe: pendingPermission.subscribe },
pendingPermissions: { subscribe: pendingPermissions.subscribe },
pendingQuestion: { subscribe: pendingQuestion.subscribe }, pendingQuestion: { subscribe: pendingQuestion.subscribe },
isProcessing: { subscribe: isProcessing.subscribe }, isProcessing: { subscribe: isProcessing.subscribe },
grantedTools: { subscribe: grantedTools.subscribe }, grantedTools: { subscribe: grantedTools.subscribe },
@@ -190,7 +195,7 @@ function createConversationsStore() {
conversations.update((convs) => { conversations.update((convs) => {
const conv = convs.get(activeId); const conv = convs.get(activeId);
if (conv) { if (conv) {
conv.pendingPermission = request; conv.pendingPermissions.push(request);
conv.lastActivityAt = new Date(); conv.lastActivityAt = new Date();
} }
return convs; return convs;
@@ -203,7 +208,7 @@ function createConversationsStore() {
conversations.update((convs) => { conversations.update((convs) => {
const conv = convs.get(activeId); const conv = convs.get(activeId);
if (conv) { if (conv) {
conv.pendingPermission = null; conv.pendingPermissions = [];
conv.lastActivityAt = new Date(); conv.lastActivityAt = new Date();
} }
return convs; return convs;
@@ -213,7 +218,7 @@ function createConversationsStore() {
conversations.update((convs) => { conversations.update((convs) => {
const conv = convs.get(conversationId); const conv = convs.get(conversationId);
if (conv) { if (conv) {
conv.pendingPermission = request; conv.pendingPermissions.push(request);
conv.lastActivityAt = new Date(); conv.lastActivityAt = new Date();
} }
return convs; return convs;
@@ -223,7 +228,30 @@ function createConversationsStore() {
conversations.update((convs) => { conversations.update((convs) => {
const conv = convs.get(conversationId); const conv = convs.get(conversationId);
if (conv) { if (conv) {
conv.pendingPermission = null; conv.pendingPermissions = [];
conv.lastActivityAt = new Date();
}
return convs;
});
},
removePermission: (id: string) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingPermissions = conv.pendingPermissions.filter((p) => p.id !== id);
conv.lastActivityAt = new Date();
}
return convs;
});
},
removePermissionForConversation: (conversationId: string, id: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.pendingPermissions = conv.pendingPermissions.filter((p) => p.id !== id);
conv.lastActivityAt = new Date(); conv.lastActivityAt = new Date();
} }
return convs; return convs;
@@ -435,7 +463,8 @@ function createConversationsStore() {
type: TerminalLine["type"], type: TerminalLine["type"],
content: string, content: string,
toolName?: string, toolName?: string,
cost?: TerminalLine["cost"] cost?: TerminalLine["cost"],
parentToolUseId?: string
) => { ) => {
ensureInitialized(); ensureInitialized();
const activeId = get(activeConversationId); const activeId = get(activeConversationId);
@@ -448,6 +477,7 @@ function createConversationsStore() {
timestamp: new Date(), timestamp: new Date(),
toolName, toolName,
cost, cost,
parentToolUseId,
}; };
conversations.update((convs) => { conversations.update((convs) => {
@@ -469,7 +499,8 @@ function createConversationsStore() {
type: TerminalLine["type"], type: TerminalLine["type"],
content: string, content: string,
toolName?: string, toolName?: string,
cost?: TerminalLine["cost"] cost?: TerminalLine["cost"],
parentToolUseId?: string
) => { ) => {
ensureInitialized(); ensureInitialized();
@@ -480,6 +511,7 @@ function createConversationsStore() {
timestamp: new Date(), timestamp: new Date(),
toolName, toolName,
cost, cost,
parentToolUseId,
}; };
conversations.update((convs) => { conversations.update((convs) => {
+154
View File
@@ -0,0 +1,154 @@
import { writable, derived } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
export type LogLevel = "debug" | "info" | "warn" | "error";
export interface LogEntry {
id: string;
timestamp: Date;
level: LogLevel;
message: string;
source: "frontend" | "backend";
}
interface DebugConsoleState {
logs: LogEntry[];
isOpen: boolean;
maxLogs: number;
filterLevel: LogLevel | "all";
autoScroll: boolean;
}
const MAX_LOGS = 1000; // Circular buffer size
function createDebugConsoleStore() {
const { subscribe, update } = writable<DebugConsoleState>({
logs: [],
isOpen: false,
maxLogs: MAX_LOGS,
filterLevel: "all",
autoScroll: true,
});
let logCounter = 0;
function addLog(level: LogLevel, message: string, source: "frontend" | "backend") {
update((state) => {
const newLog: LogEntry = {
id: `log-${Date.now()}-${logCounter++}`,
timestamp: new Date(),
level,
message,
source,
};
const updatedLogs = [...state.logs, newLog];
// Implement circular buffer - remove oldest if exceeding max
if (updatedLogs.length > state.maxLogs) {
updatedLogs.shift();
}
return { ...state, logs: updatedLogs };
});
}
// Override console methods to capture frontend logs
const originalConsole = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
debug: console.debug,
};
function setupConsoleCapture() {
console.log = (...args: unknown[]) => {
originalConsole.log(...args);
addLog("info", args.map((arg) => String(arg)).join(" "), "frontend");
};
console.info = (...args: unknown[]) => {
originalConsole.info(...args);
addLog("info", args.map((arg) => String(arg)).join(" "), "frontend");
};
console.warn = (...args: unknown[]) => {
originalConsole.warn(...args);
addLog("warn", args.map((arg) => String(arg)).join(" "), "frontend");
};
console.error = (...args: unknown[]) => {
originalConsole.error(...args);
addLog("error", args.map((arg) => String(arg)).join(" "), "frontend");
};
console.debug = (...args: unknown[]) => {
originalConsole.debug(...args);
addLog("debug", args.map((arg) => String(arg)).join(" "), "frontend");
};
// Capture unhandled errors
window.addEventListener("error", (event) => {
addLog(
"error",
`[Unhandled Error] ${event.message} at ${event.filename}:${event.lineno}:${event.colno}`,
"frontend"
);
});
// Capture unhandled promise rejections
window.addEventListener("unhandledrejection", (event) => {
addLog("error", `[Unhandled Promise Rejection] ${event.reason}`, "frontend");
});
}
function restoreConsole() {
console.log = originalConsole.log;
console.info = originalConsole.info;
console.warn = originalConsole.warn;
console.error = originalConsole.error;
console.debug = originalConsole.debug;
}
// Listen for backend logs
async function setupBackendLogsListener() {
await listen<{ level: LogLevel; message: string }>("debug:log", (event) => {
addLog(event.payload.level, event.payload.message, "backend");
});
}
return {
subscribe,
toggle: () => update((state) => ({ ...state, isOpen: !state.isOpen })),
open: () => update((state) => ({ ...state, isOpen: true })),
close: () => update((state) => ({ ...state, isOpen: false })),
clear: () => update((state) => ({ ...state, logs: [] })),
setFilterLevel: (level: LogLevel | "all") =>
update((state) => ({ ...state, filterLevel: level })),
setAutoScroll: (enabled: boolean) => update((state) => ({ ...state, autoScroll: enabled })),
setupConsoleCapture,
restoreConsole,
setupBackendLogsListener,
};
}
export const debugConsoleStore = createDebugConsoleStore();
// Derived store for filtered logs
export const filteredLogs = derived(debugConsoleStore, ($debugConsole) => {
if ($debugConsole.filterLevel === "all") {
return $debugConsole.logs;
}
const levelPriority: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
const minPriority = levelPriority[$debugConsole.filterLevel];
return $debugConsole.logs.filter((log) => levelPriority[log.level] >= minPriority);
});
+67
View File
@@ -285,6 +285,73 @@ describe("snippetsStore", () => {
expect(get(snippetsStore.selectedCategory)).toBeNull(); expect(get(snippetsStore.selectedCategory)).toBeNull();
}); });
}); });
describe("filteredSnippets", () => {
it("returns all snippets when no category selected", async () => {
const mockSnippets: Snippet[] = [
{
id: "snippet-1",
name: "Git Status",
content: "git status",
category: "Git",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
{
id: "snippet-2",
name: "Docker PS",
content: "docker ps",
category: "Docker",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
];
setMockInvokeResult("list_snippets", mockSnippets);
setMockInvokeResult("get_snippet_categories", ["Git", "Docker"]);
await snippetsStore.loadSnippets();
snippetsStore.setSelectedCategory(null);
const filtered = get(snippetsStore.filteredSnippets);
expect(filtered).toHaveLength(2);
});
it("filters snippets by selected category", async () => {
const mockSnippets: Snippet[] = [
{
id: "snippet-1",
name: "Git Status",
content: "git status",
category: "Git",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
{
id: "snippet-2",
name: "Docker PS",
content: "docker ps",
category: "Docker",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
];
setMockInvokeResult("list_snippets", mockSnippets);
setMockInvokeResult("get_snippet_categories", ["Git", "Docker"]);
await snippetsStore.loadSnippets();
snippetsStore.setSelectedCategory("Git");
const filtered = get(snippetsStore.filteredSnippets);
expect(filtered).toHaveLength(1);
expect(filtered[0].category).toBe("Git");
});
});
}); });
describe("snippet ID generation", () => { describe("snippet ID generation", () => {
+3 -1
View File
@@ -10,7 +10,9 @@ export type BudgetType = "token" | "cost";
// Model pricing (per million tokens) - keep in sync with stats.rs // Model pricing (per million tokens) - keep in sync with stats.rs
// Source: https://platform.claude.com/docs/en/about-claude/models/overview // Source: https://platform.claude.com/docs/en/about-claude/models/overview
export const MODEL_PRICING: Record<string, { input: number; output: number }> = { export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
// Current generation (Claude 4.5) // Current generation (Claude 4.6)
"claude-opus-4-6": { input: 5.0, output: 25.0 },
// Previous generation (Claude 4.5)
"claude-opus-4-5-20251101": { input: 5.0, output: 25.0 }, "claude-opus-4-5-20251101": { input: 5.0, output: 25.0 },
"claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 }, "claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 },
"claude-haiku-4-5-20251001": { input: 1.0, output: 5.0 }, "claude-haiku-4-5-20251001": { input: 1.0, output: 5.0 },
+94 -28
View File
@@ -12,6 +12,8 @@ import type {
UserQuestionEvent, UserQuestionEvent,
} from "$lib/types/messages"; } from "$lib/types/messages";
import type { CharacterState } from "$lib/types/states"; import type { CharacterState } from "$lib/types/states";
import type { AgentStartPayload, AgentEndPayload } from "$lib/types/agents";
import { agentStore } from "$lib/stores/agents";
import { import {
initializeNotificationRules, initializeNotificationRules,
cleanupNotificationRules, cleanupNotificationRules,
@@ -95,6 +97,7 @@ interface OutputPayload {
output_tokens: number; output_tokens: number;
cost_usd: number; cost_usd: number;
}; };
parent_tool_use_id?: string;
} }
interface ConnectionPayload { interface ConnectionPayload {
@@ -175,6 +178,12 @@ export async function initializeTauriListeners() {
} else if (status === "disconnected") { } else if (status === "disconnected") {
const targetConversationId = conversation_id || get(claudeStore.activeConversationId); const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
// Mark all running agents as errored on disconnect, but not during reconnects
// (permission prompts trigger reconnects and agents may complete before reconnect)
if (!skipNextGreeting && targetConversationId) {
agentStore.markAllErrored(targetConversationId);
}
// Only remove from connected set if we're not about to reconnect // Only remove from connected set if we're not about to reconnect
if (!skipNextGreeting && targetConversationId) { if (!skipNextGreeting && targetConversationId) {
connectedConversations.delete(targetConversationId); connectedConversations.delete(targetConversationId);
@@ -247,7 +256,8 @@ export async function initializeTauriListeners() {
unlisteners.push(stateUnlisten); unlisteners.push(stateUnlisten);
const outputUnlisten = await listen<OutputPayload>("claude:output", (event) => { const outputUnlisten = await listen<OutputPayload>("claude:output", (event) => {
const { line_type, content, tool_name, conversation_id, cost } = event.payload; const { line_type, content, tool_name, conversation_id, cost, parent_tool_use_id } =
event.payload;
// Convert snake_case cost to camelCase for TypeScript // Convert snake_case cost to camelCase for TypeScript
const costData = cost const costData = cost
@@ -262,18 +272,20 @@ export async function initializeTauriListeners() {
if (conversation_id) { if (conversation_id) {
claudeStore.addLineToConversation( claudeStore.addLineToConversation(
conversation_id, conversation_id,
line_type as "user" | "assistant" | "system" | "tool" | "error", line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
content, content,
tool_name || undefined, tool_name || undefined,
costData costData,
parent_tool_use_id
); );
} else { } else {
// Fallback to active conversation if no conversation_id provided // Fallback to active conversation if no conversation_id provided
claudeStore.addLine( claudeStore.addLine(
line_type as "user" | "assistant" | "system" | "tool" | "error", line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
content, content,
tool_name || undefined, tool_name || undefined,
costData costData,
parent_tool_use_id
); );
} }
}); });
@@ -316,35 +328,89 @@ export async function initializeTauriListeners() {
}); });
unlisteners.push(cwdUnlisten); unlisteners.push(cwdUnlisten);
console.log("[Tauri Listener] Setting up claude:permission listener");
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => { const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
const { id, tool_name, tool_input, description, conversation_id } = event.payload; const { permissions, conversation_id } = event.payload;
// Store permission request for the specific conversation console.log(
if (conversation_id) { `[Permission] Event received: ${permissions.length} permission(s) for conversation ${conversation_id || "active"}`,
claudeStore.requestPermissionForConversation(conversation_id, { { permissions, conversation_id }
id, );
tool: tool_name,
description, // Store each permission request for the specific conversation
input: tool_input, for (const permission of permissions) {
}); const { id, tool_name, tool_input, description } = permission;
claudeStore.addLineToConversation(
conversation_id, if (conversation_id) {
"system", claudeStore.requestPermissionForConversation(conversation_id, {
`Permission requested for: ${tool_name}` id,
); tool: tool_name,
} else { description,
// Fallback to active conversation if no conversation_id input: tool_input,
claudeStore.requestPermission({ });
id, claudeStore.addLineToConversation(
tool: tool_name, conversation_id,
description, "system",
input: tool_input, `Permission requested for: ${tool_name}`
}); );
claudeStore.addLine("system", `Permission requested for: ${tool_name}`); } else {
// Fallback to active conversation if no conversation_id
claudeStore.requestPermission({
id,
tool: tool_name,
description,
input: tool_input,
});
claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
}
} }
}); });
unlisteners.push(permissionUnlisten); unlisteners.push(permissionUnlisten);
const agentStartUnlisten = await listen<AgentStartPayload>("claude:agent-start", (event) => {
const {
tool_use_id,
agent_id,
description,
subagent_type,
started_at,
conversation_id,
parent_tool_use_id,
} = event.payload;
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (targetConversationId) {
agentStore.addAgent(targetConversationId, {
toolUseId: tool_use_id,
agentId: agent_id,
description,
subagentType: subagent_type,
startedAt: started_at,
status: "running",
parentToolUseId: parent_tool_use_id,
});
}
});
unlisteners.push(agentStartUnlisten);
const agentUpdateUnlisten = await listen<{
conversationId: string;
toolUseId: string;
agentId: string;
}>("claude:agent-update", (event) => {
const { conversationId, toolUseId, agentId } = event.payload;
agentStore.updateAgentId(conversationId, toolUseId, agentId);
});
unlisteners.push(agentUpdateUnlisten);
const agentEndUnlisten = await listen<AgentEndPayload>("claude:agent-end", (event) => {
const { tool_use_id, ended_at, is_error, conversation_id } = event.payload;
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (targetConversationId) {
agentStore.endAgent(targetConversationId, tool_use_id, ended_at, is_error);
}
});
unlisteners.push(agentEndUnlisten);
const questionUnlisten = await listen<UserQuestionEvent>("claude:question", (event) => { const questionUnlisten = await listen<UserQuestionEvent>("claude:question", (event) => {
const questionEvent = event.payload; const questionEvent = event.payload;
+32
View File
@@ -0,0 +1,32 @@
export type AgentStatus = "running" | "completed" | "errored";
export interface AgentInfo {
toolUseId: string;
agentId?: string;
description: string;
subagentType: string;
startedAt: number;
endedAt?: number;
status: AgentStatus;
parentToolUseId?: string;
durationMs?: number;
}
export interface AgentStartPayload {
tool_use_id: string;
agent_id?: string;
description: string;
subagent_type: string;
started_at: number;
conversation_id?: string;
parent_tool_use_id?: string;
}
export interface AgentEndPayload {
tool_use_id: string;
ended_at: number;
is_error: boolean;
conversation_id?: string;
duration_ms?: number;
num_turns?: number;
}
+8 -2
View File
@@ -1,6 +1,6 @@
export interface TerminalLine { export interface TerminalLine {
id: string; id: string;
type: "user" | "assistant" | "system" | "tool" | "error"; type: "user" | "assistant" | "system" | "tool" | "error" | "thinking";
content: string; content: string;
timestamp: Date; timestamp: Date;
toolName?: string; toolName?: string;
@@ -10,6 +10,8 @@ export interface TerminalLine {
outputTokens: number; outputTokens: number;
costUsd: number; costUsd: number;
}; };
// Indicates if this message is from a subagent
parentToolUseId?: string;
} }
export interface SystemInitMessage { export interface SystemInitMessage {
@@ -124,11 +126,15 @@ export interface PermissionRequest {
input: Record<string, unknown>; input: Record<string, unknown>;
} }
export interface PermissionPromptEvent { export interface PermissionPromptEventItem {
id: string; id: string;
tool_name: string; tool_name: string;
tool_input: Record<string, unknown>; tool_input: Record<string, unknown>;
description: string; description: string;
}
export interface PermissionPromptEvent {
permissions: PermissionPromptEventItem[];
conversation_id?: string; conversation_id?: string;
} }
+175
View File
@@ -135,6 +135,151 @@ describe("stateMapper", () => {
}; };
expect(mapMessageToState(message)).toBeNull(); expect(mapMessageToState(message)).toBeNull();
}); });
it("returns typing for unknown tool", () => {
const message: ClaudeStreamMessage = {
type: "assistant",
message: {
content: [
{
type: "tool_use",
id: "tool-1",
name: "SomeUnknownTool",
input: {},
},
],
model: "claude-3",
stop_reason: "tool_use",
},
};
expect(mapMessageToState(message)).toBe("typing");
});
it("returns thinking for thinking content block", () => {
const message: ClaudeStreamMessage = {
type: "assistant",
message: {
content: [{ type: "thinking", thinking: "Analyzing the problem..." }],
model: "claude-3",
stop_reason: "end_turn",
},
};
expect(mapMessageToState(message)).toBe("thinking");
});
it("returns null for assistant message with no recognizable content", () => {
const message: ClaudeStreamMessage = {
type: "assistant",
message: {
content: [],
model: "claude-3",
stop_reason: "end_turn",
},
};
expect(mapMessageToState(message)).toBeNull();
});
it("returns thinking for thinking_delta stream event", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
delta: {
type: "thinking_delta",
thinking: "Thinking...",
},
},
};
expect(mapMessageToState(message)).toBe("thinking");
});
it("returns typing for text_delta stream event", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
delta: {
type: "text_delta",
text: "Hello",
},
},
};
expect(mapMessageToState(message)).toBe("typing");
});
it("returns thinking for thinking content_block_start", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "thinking",
thinking: "",
},
},
};
expect(mapMessageToState(message)).toBe("thinking");
});
it("returns typing for text content_block_start", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "text",
text: "",
},
},
};
expect(mapMessageToState(message)).toBe("typing");
});
it("returns correct state for tool_use content_block_start", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "tool-1",
name: "Read",
input: {},
},
},
};
expect(mapMessageToState(message)).toBe("searching");
});
it("returns null for stream_event with unrecognized type", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "message_start",
},
};
expect(mapMessageToState(message)).toBeNull();
});
it("returns null for result with unknown subtype", () => {
const message = {
type: "result",
subtype: "unknown_type",
} as unknown as ClaudeStreamMessage;
expect(mapMessageToState(message)).toBeNull();
});
it("returns null for unknown message type", () => {
const message = {
type: "unknown_type",
} as unknown as ClaudeStreamMessage;
expect(mapMessageToState(message)).toBeNull();
});
}); });
describe("extractTextFromMessage", () => { describe("extractTextFromMessage", () => {
@@ -192,6 +337,36 @@ describe("stateMapper", () => {
}; };
expect(extractTextFromMessage(message)).toBe("Completed successfully"); expect(extractTextFromMessage(message)).toBe("Completed successfully");
}); });
it("returns null for result without result field", () => {
const message: ClaudeStreamMessage = {
type: "result",
subtype: "success",
};
expect(extractTextFromMessage(message)).toBeNull();
});
it("returns null for stream_event without delta text", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "text",
text: "",
},
},
};
expect(extractTextFromMessage(message)).toBeNull();
});
it("returns null for unknown message type", () => {
const message = {
type: "unknown",
} as unknown as ClaudeStreamMessage;
expect(extractTextFromMessage(message)).toBeNull();
});
}); });
describe("extractToolInfo", () => { describe("extractToolInfo", () => {
+2
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import "../app.css"; import "../app.css";
import DebugConsole from "$lib/components/DebugConsole.svelte";
let { children } = $props(); let { children } = $props();
@@ -14,4 +15,5 @@
<div id="app"> <div id="app">
{@render children()} {@render children()}
<DebugConsole />
</div> </div>
+79
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { import {
initializeTauriListeners, initializeTauriListeners,
@@ -8,6 +9,7 @@
initializeDiscordRpc, initializeDiscordRpc,
stopDiscordRpc, stopDiscordRpc,
updateDiscordRpc, updateDiscordRpc,
setSkipNextGreeting,
} from "$lib/tauri"; } from "$lib/tauri";
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config"; import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications"; import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
@@ -30,12 +32,17 @@
import AchievementNotification from "$lib/components/AchievementNotification.svelte"; import AchievementNotification from "$lib/components/AchievementNotification.svelte";
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte"; import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
import UpdateNotification from "$lib/components/UpdateNotification.svelte"; import UpdateNotification from "$lib/components/UpdateNotification.svelte";
import CloseAppConfirmModal from "$lib/components/CloseAppConfirmModal.svelte";
import MemoryBrowserPanel from "$lib/components/MemoryBrowserPanel.svelte";
import { debugConsoleStore } from "$lib/stores/debugConsole";
let initialized = false; let initialized = false;
let updateNotification: UpdateNotification | undefined = $state(undefined); let updateNotification: UpdateNotification | undefined = $state(undefined);
let achievementPanelOpen = $state(false); let achievementPanelOpen = $state(false);
let currentCharacterState: CharacterState = $state("idle"); let currentCharacterState: CharacterState = $state("idle");
let compactModeActive = $state(false); let compactModeActive = $state(false);
let closeConfirmModalOpen = $state(false);
let hasActiveConversation = $state(false);
// Editor state // Editor state
const isEditorVisible = editorStore.isEditorVisible; const isEditorVisible = editorStore.isEditorVisible;
@@ -229,6 +236,13 @@
return; return;
} }
// Ctrl+` - Toggle debug console
if (event.ctrlKey && event.key === "`") {
event.preventDefault();
debugConsoleStore.toggle();
return;
}
// Ctrl+E - Toggle editor panel (only when connected) // Ctrl+E - Toggle editor panel (only when connected)
if (event.ctrlKey && event.key === "e") { if (event.ctrlKey && event.key === "e") {
event.preventDefault(); event.preventDefault();
@@ -278,6 +292,9 @@
const conversationId = get(claudeStore.activeConversationId); const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return; if (!conversationId) return;
// Set flag to preserve stats/permissions (don't treat next connect as new session)
setSkipNextGreeting(true);
await invoke("interrupt_claude", { conversationId }); await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted"); claudeStore.addLine("system", "Process interrupted");
} catch (error) { } catch (error) {
@@ -338,6 +355,50 @@
} }
} }
async function handleCloseRequest() {
// Check if there's an active conversation with Claude running
const activeId = get(claudeStore.activeConversationId);
if (activeId) {
try {
const isRunning = await invoke<boolean>("is_claude_running", {
conversationId: activeId,
});
hasActiveConversation = isRunning;
} catch (error) {
console.error("Failed to check Claude status:", error);
hasActiveConversation = false;
}
} else {
hasActiveConversation = false;
}
// Always show confirmation modal
closeConfirmModalOpen = true;
}
async function handleConfirmClose() {
closeConfirmModalOpen = false;
try {
await invoke("close_application");
} catch (error) {
console.error("Failed to close application:", error);
}
}
async function handleMinimizeToTray() {
closeConfirmModalOpen = false;
try {
const window = getCurrentWindow();
await window.hide();
} catch (error) {
console.error("Failed to minimize to tray:", error);
}
}
function handleCancelClose() {
closeConfirmModalOpen = false;
}
onMount(async () => { onMount(async () => {
if (!initialized) { if (!initialized) {
initialized = true; initialized = true;
@@ -383,6 +444,16 @@
// Initialize Discord RPC // Initialize Discord RPC
await initializeDiscordRpc(); await initializeDiscordRpc();
// Listen for window close requests
const unlisten = await listen("window-close-requested", () => {
handleCloseRequest();
});
// Store the unlisten function for cleanup
window.addEventListener("beforeunload", () => {
unlisten();
});
} }
}); });
@@ -443,12 +514,20 @@
<PermissionModal /> <PermissionModal />
<UserQuestionModal /> <UserQuestionModal />
<ConfigSidebar /> <ConfigSidebar />
<MemoryBrowserPanel />
<AchievementNotification /> <AchievementNotification />
<AchievementsPanel <AchievementsPanel
bind:isOpen={achievementPanelOpen} bind:isOpen={achievementPanelOpen}
onClose={() => (achievementPanelOpen = false)} onClose={() => (achievementPanelOpen = false)}
/> />
<UpdateNotification bind:this={updateNotification} /> <UpdateNotification bind:this={updateNotification} />
<CloseAppConfirmModal
isOpen={closeConfirmModalOpen}
{hasActiveConversation}
onClose={handleConfirmClose}
onMinimize={handleMinimizeToTray}
onCancel={handleCancelClose}
/>
</div> </div>
{/if} {/if}
-1
View File
@@ -39,7 +39,6 @@ vi.mock("@tauri-apps/api/core", () => ({
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null, character_panel_width: null,
font_size: 14, font_size: 14,
minimize_to_tray: false,
streamer_mode: false, streamer_mode: false,
streamer_hide_paths: false, streamer_hide_paths: false,
compact_mode: false, compact_mode: false,