Compare commits

...

9 Commits

Author SHA1 Message Date
hikari b1a45ed00e feat: add high contrast mode for accessibility
- Add HighContrast variant to Theme enum in Rust backend
- Add high-contrast CSS theme with pure black/white for max contrast
- Use bright saturated colors for syntax highlighting
- Add High Contrast button to theme selector with accessibility tooltip
- Follows WCAG guidelines for contrast ratios

Closes #20
2026-01-25 13:44:52 -08:00
hikari 457722dc3a feat: add minimize to system tray option
Add ability to minimize Hikari to the system tray when closing the window
instead of fully exiting. When enabled, clicking the close button hides
the window and shows a tray icon with "Show Hikari" and "Quit" options.

- Add tray module with system tray setup and menu handling
- Add minimize_to_tray config option in settings
- Handle window close event to hide instead of close when enabled
- Add tray icon click handler to restore window
2026-01-25 13:38:30 -08:00
naomi 852a4d6661 feat: add native clipboard support for screenshot paste (#67)
CI / Lint & Test (push) Successful in 14m34s
CI / Build Linux (push) Successful in 18m19s
CI / Build Windows (cross-compile) (push) Successful in 27m57s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
## Summary
- Adds Tauri clipboard-manager plugin to read images from native clipboard
- Falls back to native clipboard when WebView clipboard API returns empty (fixes screenshot paste)
- Allows sending messages with just attachments (no text required)
- Logs attached files to output with 📎 emoji

## Test plan
- [ ] Build and run the app natively on Windows
- [ ] Copy a screenshot (Win+Shift+S) and paste in the chat input
- [ ] Verify the screenshot appears as an attachment preview
- [ ] Send the attachment and verify Claude receives the file path
- [ ] Test sending a message with only an attachment (no text)
- [ ] Verify the 📎 log line shows the attached filename

**Note:** Paste will not work in WSLg dev environment due to clipboard isolation - needs native Windows build to test.

 This PR was created with help from Hikari~ 🌸

Co-authored-by: Hikari <hikari@nhcarrigan.com>
Reviewed-on: #67
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-25 13:08:38 -08:00
naomi bbeff7ae2e release: v0.3.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 49s
CI / Lint & Test (push) Successful in 14m32s
CI / Build Linux (push) Successful in 15m53s
CI / Build Windows (cross-compile) (push) Successful in 25m32s
2026-01-23 19:08:50 -08:00
naomi 3f30997f0e feat: another wave of features (#61)
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
CI / Lint & Test (push) Has been cancelled
## Explanation

This PR bundles several user-facing improvements and feature additions for the v0.3.0 release, including quality-of-life improvements to the UI, new slash commands, better state persistence, and auto-update checking.

## Included Changes

- **Resizable chat input** with drag handle (#58 partial)
- **Arrow key navigation fix** - cursor keys now navigate text when user has typed input (#58)
- **Scroll position persistence** per conversation tab
- **/skill command** for invoking Claude Code skills (#57)
- **Stats persistence fix** - stats now persist across session changes, only reset on disconnect (#59)
- **Auto-update checker** on startup (#17)
- **Resizable character panel** with full-height sprites (#10)
- **Font size and zoom settings** with keyboard shortcuts (Ctrl++/Ctrl+-/Ctrl+0) (#19)

## Closes

Closes #10, #17, #19, #57, #58, #59

## Attestations

- [x] I have read and agree to the Code of Conduct
- [x] I have read and agree to the Community Guidelines
- [x] My contribution complies with the Contributor Covenant
- [x] I have run the linter and resolved any errors
- [x] My pull request uses an appropriate title, matching the conventional commit standards
- [x] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request
- [x] All new and existing tests pass locally with my changes
- [x] Code coverage remains at or above the configured threshold

## Documentation

N/A - Internal app features

## Versioning

Minor - My pull request introduces new non-breaking features.

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

Co-authored-by: Hikari <hikari@nhcarrigan.com>
Reviewed-on: #61
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-23 19:07:22 -08:00
naomi 06810537a9 feat: add AskUserQuestion tool support (#60)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 53s
CI / Lint & Test (push) Successful in 14m12s
CI / Build Linux (push) Successful in 16m41s
CI / Build Windows (cross-compile) (push) Successful in 27m0s
## Summary

Implements support for Claude's `AskUserQuestion` tool, allowing Claude to ask the user questions with multiple choice options during a conversation.

## Changes

- Add `UserQuestionEvent` and `QuestionOption` types (Rust and TypeScript)
- Detect `AskUserQuestion` in permission denials and emit `claude:question` event
- Create `UserQuestionModal` component with option selection and custom answer input
- Use stop/reconnect approach (same as `PermissionModal`) since Claude API doesn't accept tool_result for permission-denied tools
- Add `pendingQuestion` to conversation store and `hasQuestionPending` derived store

## Technical Notes

We discovered that Claude Code's permission denial system doesn't allow sending tool results back directly - the API rejects them with "unexpected tool_use_id found in tool_result blocks". The solution was to use the same stop/reconnect pattern that permissions use: stop the session, reconnect with context, and include the user's answer in the context restoration message.

## Test Plan

- [x] Build compiles without errors (Rust + TypeScript)
- [x] Question modal appears when Claude uses `AskUserQuestion`
- [x] Can select options and submit answer
- [x] Answer is properly restored to Claude after reconnect

Closes #51

---

 This PR was created with help from Hikari~ 🌸

Co-authored-by: Hikari <hikari@nhcarrigan.com>
Reviewed-on: #60
2026-01-23 14:11:18 -08:00
hikari 94991796be feat: batch of fixes and features (#56)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 57s
CI / Lint & Test (push) Successful in 14m14s
CI / Build Linux (push) Successful in 16m45s
CI / Build Windows (cross-compile) (push) Successful in 26m50s
## Summary

This PR includes a batch of bug fixes and new features:

### Bug Fixes
- **Links in chat history now open in default browser** instead of navigating within the app
  - Closes #54
- **Allow spaces in tab names** - space key no longer acts like enter when renaming tabs
  - Closes #52

### New Features
- **`/cd` command** - Change the working directory of an active tab with context preservation
  - Closes #55
- **`/search` command** - Search and highlight matches within the conversation
  - Closes #32

## Test Plan
- [ ] Click a link in chat history and verify it opens in the default browser
- [ ] Rename a tab and verify spaces can be typed
- [ ] Use `/cd <path>` and verify the directory changes while preserving conversation context
- [ ] Use `/search <query>` and verify matches are highlighted in yellow
- [ ] Use `/search` with no args to clear the search highlighting

 This PR was created with help from Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #56
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-01-23 11:59:21 -08:00
naomi 947e56ef41 feat: naomi did too much at once (#53)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 53s
CI / Lint & Test (push) Successful in 14m10s
CI / Build Linux (push) Successful in 16m47s
CI / Build Windows (cross-compile) (push) Successful in 26m36s
- feat: add slash commands
- feat: toggle window always on top
- fix: save settings button closes settings panel
- feat: input history (both text and commands)
- feat: add keyboard shortcuts
- feat: add confirmation modal when closing connected tabs
- fix: better text colours in light mode
- fix: handle multiple tabs requesting permission

Closes #6
Closes #13
Closes #21
Closes #28

Reviewed-on: #53
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-21 17:38:36 -08:00
naomi 9fe4e8a48a feat: add markdown renderer and code block highlighting (#50)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 56s
CI / Lint & Test (push) Successful in 14m14s
CI / Build Linux (push) Successful in 16m45s
CI / Build Windows (cross-compile) (push) Successful in 26m51s
### Explanation

_No response_

### Issue

Closes #33 Closes #31

### 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: #50
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-21 11:28:09 -08:00
57 changed files with 5335 additions and 435 deletions
+1
View File
@@ -2,6 +2,7 @@ build/
.svelte-kit/
dist/
src-tauri/target/
src-tauri/gen/
node_modules/
.pnpm-store/
pnpm-lock.yaml
+74
View File
@@ -0,0 +1,74 @@
# Hikari Desktop v0.3.0 Release Notes
## New Features
### AskUserQuestion Tool Support (#51)
- Claude can now ask you questions with multiple choice options during conversations
- A dedicated modal appears with answer choices and support for custom responses
- Answers are seamlessly integrated back into the conversation context
### Slash Commands
- `/cd <path>` - Change the working directory while preserving conversation context (#55)
- `/search <query>` - Search and highlight matches within the conversation (#32)
- `/skill <name> [data]` - Invoke Claude Code skills from `~/.claude/skills/` (#57)
- `/new`, `/clear`, `/rename`, `/help` commands for conversation management (#6)
### Auto-Update Checker (#17)
- Automatically checks for new releases on startup
- Notification appears when a newer version is available
- Can be disabled in settings
### Font Size & Zoom (#19)
- Adjust font size with keyboard shortcuts: `Ctrl++`, `Ctrl+-`, `Ctrl+0`
- Font size slider in settings (10-24px range)
- Preference persists between sessions
### Resizable Character Panel (#10)
- Drag the divider between the character panel and terminal to resize
- Panel width is saved and restored on app restart
- Sprite now uses full height for better proportions
### Input History Navigation (#13)
- Use up/down arrows to navigate through previous messages and commands
- Arrow keys only navigate history when input is empty - otherwise they move the cursor (#58)
### Keyboard Shortcuts (#21)
- `Ctrl+N` - New conversation
- `Ctrl+W` - Close current tab
- `Ctrl+Tab` / `Ctrl+Shift+Tab` - Switch between tabs
- `Ctrl+L` - Clear conversation
- `Ctrl+,` - Open settings
### Always On Top Toggle (#28)
- Pin the window to stay above other applications
- Toggle in settings
## Improvements
### UI/UX
- Resizable chat input with drag handle (expands upward)
- Send button properly aligned with input field
- Markdown rendering with syntax-highlighted code blocks (#31, #33)
- Light mode text colors improved for better readability
- Scroll position persists per conversation tab when switching
- Confirmation modal when closing connected tabs
- Links in chat now open in default browser (#54)
- Spaces allowed when renaming tabs (#52)
### State Management
- Stats (tokens, cost) persist across session changes and only reset on disconnect (#59)
- Multiple tabs can now request permissions simultaneously without conflicts
## Closed Issues
#6, #10, #13, #17, #19, #21, #28, #31, #32, #33, #51, #52, #54, #55, #57, #58, #59
+6 -3
View File
@@ -1,6 +1,6 @@
{
"name": "hikari-desktop",
"version": "0.2.0",
"version": "0.3.0",
"description": "",
"type": "module",
"scripts": {
@@ -24,12 +24,15 @@
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2",
"@tauri-apps/plugin-shell": "^2.3.4",
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-os": "^2"
"highlight.js": "^11.11.1",
"marked": "^17.0.1"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
+29
View File
@@ -11,6 +11,9 @@ importers:
'@tauri-apps/api':
specifier: ^2
version: 2.9.1
'@tauri-apps/plugin-clipboard-manager':
specifier: ^2.3.2
version: 2.3.2
'@tauri-apps/plugin-dialog':
specifier: ^2
version: 2.6.0
@@ -29,6 +32,12 @@ importers:
'@tauri-apps/plugin-store':
specifier: ^2
version: 2.4.2
highlight.js:
specifier: ^11.11.1
version: 11.11.1
marked:
specifier: ^17.0.1
version: 17.0.1
devDependencies:
'@eslint/js':
specifier: ^9.39.2
@@ -729,6 +738,9 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-clipboard-manager@2.3.2':
resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==}
'@tauri-apps/plugin-dialog@2.6.0':
resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
@@ -1185,6 +1197,10 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -1372,6 +1388,11 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@17.0.1:
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
engines: {node: '>= 20'}
hasBin: true
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
@@ -2271,6 +2292,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.9.6
'@tauri-apps/cli-win32-x64-msvc': 2.9.6
'@tauri-apps/plugin-clipboard-manager@2.3.2':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/plugin-dialog@2.6.0':
dependencies:
'@tauri-apps/api': 2.9.1
@@ -2780,6 +2805,8 @@ snapshots:
has-flag@4.0.0: {}
highlight.js@11.11.1: {}
html-encoding-sniffer@6.0.0:
dependencies:
'@exodus/bytes': 1.8.0
@@ -2949,6 +2976,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
marked@17.0.1: {}
mdn-data@2.12.2: {}
min-indent@1.0.1: {}
+737 -8
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "hikari-desktop"
version = "0.2.0"
version = "0.3.0"
description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"]
edition = "2021"
@@ -13,7 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
tauri-plugin-shell = "2"
@@ -25,7 +25,10 @@ uuid = { version = "1", features = ["v4"] }
tauri-plugin-store = "2.4.2"
tauri-plugin-notification = "2"
tauri-plugin-os = "2"
tauri-plugin-http = "2"
tauri-plugin-clipboard-manager = "2"
tempfile = "3"
semver = "1"
chrono = { version = "0.4.43", features = ["serde"] }
[target.'cfg(windows)'.dependencies]
+4 -1
View File
@@ -13,6 +13,9 @@
"notification:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify"
"notification:allow-notify",
"clipboard-manager:default",
"clipboard-manager:allow-read-image",
"core:tray:default"
]
}
+78 -53
View File
@@ -1,6 +1,6 @@
use chrono::{DateTime, Datelike, Timelike, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use chrono::{DateTime, Utc, Timelike, Datelike};
use tauri_plugin_store::StoreExt;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
@@ -12,9 +12,9 @@ pub enum AchievementId {
TokenMaster, // 1,000,000 tokens
// Code Generation
HelloWorld, // First code block
CodeWizard, // 100 code blocks
ThousandBlocks, // 1,000 code blocks
HelloWorld, // First code block
CodeWizard, // 100 code blocks
ThousandBlocks, // 1,000 code blocks
// File Operations
FileManipulator, // 10 files edited
@@ -22,23 +22,23 @@ pub enum AchievementId {
// Conversation milestones
ConversationStarter, // 10 messages
ChattyKathy, // 100 messages
Conversationalist, // 1,000 messages
ChattyKathy, // 100 messages
Conversationalist, // 1,000 messages
// Tool usage
Toolsmith, // 5 different tools
ToolMaster, // 10 different tools
Toolsmith, // 5 different tools
ToolMaster, // 10 different tools
// Time-based achievements
EarlyBird, // Started session 5-7 AM
NightOwl, // Coding after midnight
AllNighter, // Worked 2-5 AM
WeekendWarrior, // Coding on weekend
EarlyBird, // Started session 5-7 AM
NightOwl, // Coding after midnight
AllNighter, // Worked 2-5 AM
WeekendWarrior, // Coding on weekend
DedicatedDeveloper, // 30 days in a row
// Search and exploration
Explorer, // 50 searches
MasterSearcher, // 500 searches
Explorer, // 50 searches
MasterSearcher, // 500 searches
// Session achievements
QuickSession, // Productive session < 5 min
@@ -47,36 +47,36 @@ pub enum AchievementId {
MarathonSession, // 5+ hour session
// Special achievements
FirstMessage, // First message sent
FirstTool, // First tool used
FirstCodeBlock, // First code generated
FirstFileEdit, // First file edit
Polyglot, // 5+ languages in one session
SpeedCoder, // 10 code blocks in 10 minutes
FirstMessage, // First message sent
FirstTool, // First tool used
FirstCodeBlock, // First code generated
FirstFileEdit, // First file edit
Polyglot, // 5+ languages in one session
SpeedCoder, // 10 code blocks in 10 minutes
ClaudeConnoisseur, // Used all Claude models
MarathonCoder, // 10k tokens in one session
MarathonCoder, // 10k tokens in one session
// Relationship & Greetings
GoodMorning, // Say "good morning"
GoodNight, // Say "good night" or "goodnight"
ThankYou, // Say "thank you" or "thanks"
LoveYou, // Say "love you" or "ily"
GoodMorning, // Say "good morning"
GoodNight, // Say "good night" or "goodnight"
ThankYou, // Say "thank you" or "thanks"
LoveYou, // Say "love you" or "ily"
// Personality & Fun
EmojiUser, // Use an emoji in a message
QuestionMaster, // Use "?" in 20 messages
CapsLock, // Send a message in ALL CAPS
EmojiUser, // Use an emoji in a message
QuestionMaster, // Use "?" in 20 messages
CapsLock, // Send a message in ALL CAPS
PleaseAndThankYou, // Use "please" in messages
// Git & Development
GitGuru, // Use git commands 10 times
TestWriter, // Create test files
Debugger, // Fix bugs (messages with "fix", "bug", "error")
GitGuru, // Use git commands 10 times
TestWriter, // Create test files
Debugger, // Fix bugs (messages with "fix", "bug", "error")
// Tool Mastery
BashMaster, // Use Bash tool 50 times
FileExplorer, // Use Read tool 100 times
SearchExpert, // Use Grep tool 50 times
BashMaster, // Use Bash tool 50 times
FileExplorer, // Use Read tool 100 times
SearchExpert, // Use Grep tool 50 times
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -509,15 +509,20 @@ pub fn check_message_achievements(
newly_unlocked.push(AchievementId::GoodMorning);
}
if (message_lower.contains("good night") || message_lower.contains("goodnight"))
&& progress.unlock(AchievementId::GoodNight) {
&& progress.unlock(AchievementId::GoodNight)
{
newly_unlocked.push(AchievementId::GoodNight);
}
if (message_lower.contains("thank you") || message_lower.contains("thanks") || message_lower.contains("thx"))
&& progress.unlock(AchievementId::ThankYou) {
if (message_lower.contains("thank you")
|| message_lower.contains("thanks")
|| message_lower.contains("thx"))
&& progress.unlock(AchievementId::ThankYou)
{
newly_unlocked.push(AchievementId::ThankYou);
}
if (message_lower.contains("love you") || message_lower.contains("ily"))
&& progress.unlock(AchievementId::LoveYou) {
&& progress.unlock(AchievementId::LoveYou)
{
newly_unlocked.push(AchievementId::LoveYou);
}
@@ -525,9 +530,11 @@ pub fn check_message_achievements(
if message.chars().any(|c| c as u32 >= 0x1F300) && progress.unlock(AchievementId::EmojiUser) {
newly_unlocked.push(AchievementId::EmojiUser);
}
if message == message.to_uppercase() && message.len() > 5
if message == message.to_uppercase()
&& message.len() > 5
&& message.chars().any(|c| c.is_alphabetic())
&& progress.unlock(AchievementId::CapsLock) {
&& progress.unlock(AchievementId::CapsLock)
{
newly_unlocked.push(AchievementId::CapsLock);
}
if message_lower.contains("please") && progress.unlock(AchievementId::PleaseAndThankYou) {
@@ -535,8 +542,11 @@ pub fn check_message_achievements(
}
// Git & Development patterns in messages
if (message_lower.contains("fix") || message_lower.contains("bug") || message_lower.contains("error"))
&& progress.unlock(AchievementId::Debugger) {
if (message_lower.contains("fix")
|| message_lower.contains("bug")
|| message_lower.contains("error"))
&& progress.unlock(AchievementId::Debugger)
{
newly_unlocked.push(AchievementId::Debugger);
}
@@ -550,10 +560,12 @@ pub fn check_achievements(
) -> Vec<AchievementId> {
let mut newly_unlocked = Vec::new();
println!("Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
println!(
"Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
stats.messages_exchanged,
stats.total_input_tokens + stats.total_output_tokens,
stats.code_blocks_generated);
stats.code_blocks_generated
);
println!("Currently unlocked: {:?}", progress.unlocked);
// Token milestones
@@ -617,7 +629,8 @@ pub fn check_achievements(
// Search and exploration
let search_tools = ["Glob", "Grep", "search", "Task"];
let search_count: u64 = search_tools.iter()
let search_count: u64 = search_tools
.iter()
.filter_map(|tool| stats.tools_usage.get(*tool))
.sum();
if search_count >= 50 && progress.unlock(AchievementId::Explorer) {
@@ -629,7 +642,10 @@ pub fn check_achievements(
// Session duration achievements
let session_secs = stats.session_duration_seconds;
if session_secs < 300 && stats.session_messages_exchanged >= 5 && progress.unlock(AchievementId::QuickSession) {
if session_secs < 300
&& stats.session_messages_exchanged >= 5
&& progress.unlock(AchievementId::QuickSession)
{
newly_unlocked.push(AchievementId::QuickSession);
}
if session_secs >= 1800 && progress.unlock(AchievementId::FocusedWork) {
@@ -716,7 +732,9 @@ pub fn check_achievements(
// Weekend warrior
use chrono::Weekday;
if (weekday == Weekday::Sat || weekday == Weekday::Sun) && progress.unlock(AchievementId::WeekendWarrior) {
if (weekday == Weekday::Sat || weekday == Weekday::Sun)
&& progress.unlock(AchievementId::WeekendWarrior)
{
newly_unlocked.push(AchievementId::WeekendWarrior);
}
}
@@ -733,16 +751,21 @@ pub struct AchievementUnlockedEvent {
}
// Save achievements to persistent store
pub async fn save_achievements(app: &tauri::AppHandle, progress: &AchievementProgress) -> Result<(), String> {
let store = app.store("achievements.json")
.map_err(|e| e.to_string())?;
pub async fn save_achievements(
app: &tauri::AppHandle,
progress: &AchievementProgress,
) -> Result<(), String> {
let store = app.store("achievements.json").map_err(|e| e.to_string())?;
// Create a serializable version with just the unlocked achievement IDs
let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect();
println!("Saving achievements: {:?}", unlocked_list);
store.set("unlocked", serde_json::to_value(unlocked_list).map_err(|e| e.to_string())?);
store.set(
"unlocked",
serde_json::to_value(unlocked_list).map_err(|e| e.to_string())?,
);
store.save().map_err(|e| e.to_string())?;
println!("Achievements saved successfully");
@@ -766,7 +789,9 @@ pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
// Get unlocked achievements
if let Some(unlocked_value) = store.get("unlocked") {
println!("Found unlocked value in store: {:?}", unlocked_value);
if let Ok(unlocked_list) = serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone()) {
if let Ok(unlocked_list) =
serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone())
{
println!("Loaded {} achievements", unlocked_list.len());
for achievement_id in unlocked_list {
progress.unlocked.insert(achievement_id);
@@ -805,4 +830,4 @@ mod tests {
let newly = progress.take_newly_unlocked();
assert!(newly.is_empty());
}
}
}
+50 -16
View File
@@ -29,30 +29,40 @@ impl BridgeManager {
conversation_id: &str,
options: ClaudeStartOptions,
) -> Result<(), String> {
// Check if a bridge already exists for this conversation
if self.bridges.get(conversation_id).map(|b| b.is_running()).unwrap_or(false) {
// Check if a bridge already exists and is running for this conversation
if self
.bridges
.get(conversation_id)
.map(|b| b.is_running())
.unwrap_or(false)
{
return Err("Claude is already running for this conversation".to_string());
}
let app = self.app_handle.as_ref()
let app = self
.app_handle
.as_ref()
.ok_or_else(|| "App handle not set".to_string())?
.clone();
// Create a new bridge for this conversation
let mut bridge = WslBridge::new_with_conversation_id(conversation_id.to_string());
// Reuse existing bridge if it exists (preserves stats across reconnects)
// Only create a new bridge if one doesn't exist for this conversation
let bridge = self
.bridges
.entry(conversation_id.to_string())
.or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string()));
// Start the Claude process
bridge.start(app, options)?;
// Store the bridge
self.bridges.insert(conversation_id.to_string(), bridge);
Ok(())
}
pub fn stop_claude(&mut self, conversation_id: &str) -> Result<(), String> {
if let Some(bridge) = self.bridges.get_mut(conversation_id) {
let app = self.app_handle.as_ref()
let app = self
.app_handle
.as_ref()
.ok_or_else(|| "App handle not set".to_string())?;
bridge.stop(app);
Ok(())
@@ -63,7 +73,9 @@ impl BridgeManager {
pub fn interrupt_claude(&mut self, conversation_id: &str) -> Result<(), String> {
if let Some(bridge) = self.bridges.get_mut(conversation_id) {
let app = self.app_handle.as_ref()
let app = self
.app_handle
.as_ref()
.ok_or_else(|| "App handle not set".to_string())?;
bridge.interrupt(app)
} else {
@@ -79,20 +91,36 @@ impl BridgeManager {
}
}
pub fn send_tool_result(
&mut self,
conversation_id: &str,
tool_use_id: &str,
result: serde_json::Value,
) -> Result<(), String> {
if let Some(bridge) = self.bridges.get_mut(conversation_id) {
bridge.send_tool_result(tool_use_id, result)
} else {
Err("No Claude instance found for this conversation".to_string())
}
}
pub fn is_claude_running(&self, conversation_id: &str) -> bool {
self.bridges.get(conversation_id)
self.bridges
.get(conversation_id)
.map(|b| b.is_running())
.unwrap_or(false)
}
pub fn get_working_directory(&self, conversation_id: &str) -> Result<String, String> {
self.bridges.get(conversation_id)
self.bridges
.get(conversation_id)
.map(|b| b.get_working_directory().to_string())
.ok_or_else(|| "No Claude instance found for this conversation".to_string())
}
pub fn get_usage_stats(&self, conversation_id: &str) -> Result<UsageStats, String> {
self.bridges.get(conversation_id)
self.bridges
.get(conversation_id)
.map(|b| b.get_stats())
.ok_or_else(|| "No Claude instance found for this conversation".to_string())
}
@@ -115,8 +143,14 @@ impl BridgeManager {
#[allow(dead_code)]
pub fn get_active_conversations(&self) -> Vec<String> {
self.bridges.keys()
.filter(|id| self.bridges.get(*id).map(|b| b.is_running()).unwrap_or(false))
self.bridges
.keys()
.filter(|id| {
self.bridges
.get(*id)
.map(|b| b.is_running())
.unwrap_or(false)
})
.cloned()
.collect()
}
@@ -132,4 +166,4 @@ pub type SharedBridgeManager = Arc<Mutex<BridgeManager>>;
pub fn create_shared_bridge_manager() -> SharedBridgeManager {
Arc::new(Mutex::new(BridgeManager::new()))
}
}
+271 -15
View File
@@ -1,10 +1,13 @@
use std::path::PathBuf;
use tauri::{AppHandle, State};
use tauri_plugin_http::reqwest;
use tauri_plugin_store::StoreExt;
use crate::achievements::{get_achievement_info, load_achievements, AchievementUnlockedEvent};
use crate::bridge_manager::SharedBridgeManager;
use crate::config::{ClaudeStartOptions, HikariConfig};
use crate::stats::UsageStats;
use crate::bridge_manager::SharedBridgeManager;
use crate::achievements::{load_achievements, get_achievement_info, AchievementUnlockedEvent};
use crate::temp_manager::SharedTempFileManager;
const CONFIG_STORE_KEY: &str = "config";
@@ -71,23 +74,17 @@ pub async fn select_wsl_directory() -> Result<String, String> {
#[tauri::command]
pub async fn get_config(app: AppHandle) -> Result<HikariConfig, String> {
let store = app
.store("hikari-config.json")
.map_err(|e| e.to_string())?;
let store = app.store("hikari-config.json").map_err(|e| e.to_string())?;
match store.get(CONFIG_STORE_KEY) {
Some(value) => {
serde_json::from_value(value.clone()).map_err(|e| e.to_string())
}
Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()),
None => Ok(HikariConfig::default()),
}
}
#[tauri::command]
pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), String> {
let store = app
.store("hikari-config.json")
.map_err(|e| e.to_string())?;
let store = app.store("hikari-config.json").map_err(|e| e.to_string())?;
let value = serde_json::to_value(&config).map_err(|e| e.to_string())?;
store.set(CONFIG_STORE_KEY, value);
@@ -106,7 +103,63 @@ pub async fn get_usage_stats(
}
#[tauri::command]
pub async fn load_saved_achievements(app: AppHandle) -> Result<Vec<AchievementUnlockedEvent>, String> {
pub async fn validate_directory(
path: String,
current_dir: Option<String>,
) -> Result<String, String> {
use std::path::Path;
let path = Path::new(&path);
// Expand ~ to home directory
let expanded_path = if path.starts_with("~") {
if let Some(home) = std::env::var_os("HOME") {
let home_path = Path::new(&home);
if path == Path::new("~") {
home_path.to_path_buf()
} else {
home_path.join(path.strip_prefix("~").unwrap())
}
} else {
return Err("Could not determine home directory".to_string());
}
} else if path.is_relative() {
// Handle relative paths (., .., or any relative path) by resolving against current_dir
if let Some(ref cwd) = current_dir {
Path::new(cwd).join(path)
} else {
path.to_path_buf()
}
} else {
path.to_path_buf()
};
// Check if the path exists and is a directory
if !expanded_path.exists() {
return Err(format!(
"Directory does not exist: {}",
expanded_path.display()
));
}
if !expanded_path.is_dir() {
return Err(format!(
"Path is not a directory: {}",
expanded_path.display()
));
}
// Return the canonicalized (absolute) path
expanded_path
.canonicalize()
.map(|p| p.to_string_lossy().to_string())
.map_err(|e| format!("Failed to resolve path: {}", e))
}
#[tauri::command]
pub async fn load_saved_achievements(
app: AppHandle,
) -> Result<Vec<AchievementUnlockedEvent>, String> {
use chrono::Utc;
// Load achievements from persistent store
@@ -117,10 +170,213 @@ pub async fn load_saved_achievements(app: AppHandle) -> Result<Vec<AchievementUn
for achievement_id in &progress.unlocked {
let mut info = get_achievement_info(achievement_id);
info.unlocked_at = Some(Utc::now()); // We don't store timestamps, so just use now
events.push(AchievementUnlockedEvent {
achievement: info,
});
events.push(AchievementUnlockedEvent { achievement: info });
}
Ok(events)
}
#[tauri::command]
pub async fn answer_question(
bridge_manager: State<'_, SharedBridgeManager>,
conversation_id: String,
tool_use_id: String,
answers: serde_json::Value,
) -> Result<(), String> {
let mut manager = bridge_manager.lock();
manager.send_tool_result(&conversation_id, &tool_use_id, answers)
}
#[tauri::command]
pub async fn list_skills() -> Result<Vec<String>, String> {
use std::fs;
use std::path::Path;
// Get the home directory
let home =
std::env::var_os("HOME").ok_or_else(|| "Could not determine home directory".to_string())?;
let skills_dir = Path::new(&home).join(".claude").join("skills");
// If the skills directory doesn't exist, return empty list
if !skills_dir.exists() {
return Ok(Vec::new());
}
// Read the directory and collect skill names
let mut skills = Vec::new();
let entries =
fs::read_dir(&skills_dir).map_err(|e| format!("Failed to read skills directory: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
let path = entry.path();
// Only include directories that contain a SKILL.md file
if path.is_dir() {
let skill_file = path.join("SKILL.md");
if skill_file.exists() {
if let Some(name) = path.file_name() {
skills.push(name.to_string_lossy().to_string());
}
}
}
}
// Sort alphabetically
skills.sort();
Ok(skills)
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct UpdateInfo {
pub current_version: String,
pub latest_version: String,
pub has_update: bool,
pub release_url: String,
pub release_notes: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct GiteaRelease {
tag_name: String,
html_url: String,
body: Option<String>,
prerelease: bool,
}
#[tauri::command]
pub async fn check_for_updates() -> Result<UpdateInfo, String> {
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const RELEASES_API: &str =
"https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/hikari-desktop/releases";
// Fetch releases from Gitea API
let client = reqwest::Client::new();
let response = client
.get(RELEASES_API)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| format!("Failed to fetch releases: {}", e))?;
if !response.status().is_success() {
return Err(format!("API returned status: {}", response.status()));
}
let text = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
let releases: Vec<GiteaRelease> =
serde_json::from_str(&text).map_err(|e| format!("Failed to parse releases: {}", e))?;
// Find the latest non-prerelease, or fall back to latest prerelease
let latest = releases
.iter()
.find(|r| !r.prerelease)
.or_else(|| releases.first());
let latest = match latest {
Some(r) => r,
None => return Err("No releases found".to_string()),
};
// Parse version strings (remove 'v' prefix if present)
let current = semver::Version::parse(CURRENT_VERSION)
.map_err(|e| format!("Failed to parse current version: {}", e))?;
let latest_tag = latest.tag_name.trim_start_matches('v');
let latest_ver = semver::Version::parse(latest_tag)
.map_err(|e| format!("Failed to parse latest version: {}", e))?;
Ok(UpdateInfo {
current_version: CURRENT_VERSION.to_string(),
latest_version: latest.tag_name.clone(),
has_update: latest_ver > current,
release_url: latest.html_url.clone(),
release_notes: latest.body.clone(),
})
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SavedFileInfo {
pub path: String,
pub filename: String,
}
#[tauri::command]
pub async fn save_temp_file(
temp_manager: State<'_, SharedTempFileManager>,
conversation_id: String,
data: Vec<u8>,
filename: Option<String>,
) -> Result<SavedFileInfo, String> {
let mut manager = temp_manager.lock();
let path = manager.save_file(&conversation_id, &data, filename.as_deref())?;
let filename = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
Ok(SavedFileInfo {
path: path.to_string_lossy().to_string(),
filename,
})
}
#[tauri::command]
pub async fn register_temp_file(
temp_manager: State<'_, SharedTempFileManager>,
conversation_id: String,
file_path: String,
) -> Result<(), String> {
let mut manager = temp_manager.lock();
manager.register_file(&conversation_id, PathBuf::from(file_path));
Ok(())
}
#[tauri::command]
pub async fn get_temp_files(
temp_manager: State<'_, SharedTempFileManager>,
conversation_id: String,
) -> Result<Vec<String>, String> {
let manager = temp_manager.lock();
let files = manager.get_files_for_conversation(&conversation_id);
Ok(files.iter().map(|p| p.to_string_lossy().to_string()).collect())
}
#[tauri::command]
pub async fn cleanup_temp_files(
temp_manager: State<'_, SharedTempFileManager>,
conversation_id: String,
) -> Result<(), String> {
let mut manager = temp_manager.lock();
manager.cleanup_conversation(&conversation_id)
}
#[tauri::command]
pub async fn cleanup_all_temp_files(
temp_manager: State<'_, SharedTempFileManager>,
) -> Result<(), String> {
let mut manager = temp_manager.lock();
manager.cleanup_all()
}
#[tauri::command]
pub async fn cleanup_orphaned_temp_files(
temp_manager: State<'_, SharedTempFileManager>,
) -> Result<usize, String> {
let mut manager = temp_manager.lock();
manager.cleanup_orphaned_files()
}
#[tauri::command]
pub async fn get_file_size(file_path: String) -> Result<u64, String> {
let metadata = std::fs::metadata(&file_path)
.map_err(|e| format!("Failed to get file metadata: {}", e))?;
Ok(metadata.len())
}
+52 -1
View File
@@ -22,6 +22,9 @@ pub struct ClaudeStartOptions {
#[serde(default)]
pub skip_greeting: bool,
#[serde(default)]
pub resume_session_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -55,6 +58,21 @@ pub struct HikariConfig {
#[serde(default = "default_notification_volume")]
pub notification_volume: f32,
#[serde(default)]
pub always_on_top: bool,
#[serde(default = "default_update_checks_enabled")]
pub update_checks_enabled: bool,
#[serde(default)]
pub character_panel_width: Option<u32>,
#[serde(default = "default_font_size")]
pub font_size: u32,
#[serde(default)]
pub minimize_to_tray: bool,
}
impl Default for HikariConfig {
@@ -70,10 +88,19 @@ impl Default for HikariConfig {
greeting_custom_prompt: None,
notifications_enabled: true,
notification_volume: 0.7,
always_on_top: false,
update_checks_enabled: true,
character_panel_width: None,
font_size: 14,
minimize_to_tray: false,
}
}
}
fn default_update_checks_enabled() -> bool {
true
}
fn default_greeting_enabled() -> bool {
true
}
@@ -86,12 +113,18 @@ fn default_notification_volume() -> f32 {
0.7
}
fn default_font_size() -> u32 {
14
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Theme {
#[default]
Dark,
Light,
#[serde(rename = "high-contrast")]
HighContrast,
}
#[cfg(test)]
@@ -109,6 +142,11 @@ mod tests {
assert_eq!(config.theme, Theme::Dark);
assert!(config.greeting_enabled);
assert!(config.greeting_custom_prompt.is_none());
assert!(!config.always_on_top);
assert!(config.update_checks_enabled);
assert!(config.character_panel_width.is_none());
assert_eq!(config.font_size, 14);
assert!(!config.minimize_to_tray);
}
#[test]
@@ -124,6 +162,11 @@ mod tests {
greeting_custom_prompt: Some("Hello!".to_string()),
notifications_enabled: true,
notification_volume: 0.7,
always_on_top: true,
update_checks_enabled: true,
character_panel_width: Some(400),
font_size: 16,
minimize_to_tray: true,
};
let json = serde_json::to_string(&config).unwrap();
@@ -134,15 +177,23 @@ mod tests {
assert_eq!(deserialized.auto_granted_tools, config.auto_granted_tools);
assert_eq!(deserialized.theme, Theme::Light);
assert!(deserialized.greeting_enabled);
assert_eq!(deserialized.greeting_custom_prompt, Some("Hello!".to_string()));
assert_eq!(
deserialized.greeting_custom_prompt,
Some("Hello!".to_string())
);
}
#[test]
fn test_theme_serialization() {
let dark = Theme::Dark;
let light = Theme::Light;
let high_contrast = Theme::HighContrast;
assert_eq!(serde_json::to_string(&dark).unwrap(), "\"dark\"");
assert_eq!(serde_json::to_string(&light).unwrap(), "\"light\"");
assert_eq!(
serde_json::to_string(&high_contrast).unwrap(),
"\"high-contrast\""
);
}
}
+54 -5
View File
@@ -4,23 +4,29 @@ mod commands;
mod config;
mod notifications;
mod stats;
mod temp_manager;
mod tray;
mod types;
mod wsl_bridge;
mod wsl_notifications;
mod vbs_notification;
mod windows_toast;
mod wsl_bridge;
mod wsl_notifications;
use commands::*;
use notifications::*;
use bridge_manager::create_shared_bridge_manager;
use commands::load_saved_achievements;
use wsl_notifications::*;
use commands::*;
use notifications::*;
use tauri::Manager;
use temp_manager::create_shared_temp_manager;
use tray::{setup_tray, should_minimize_to_tray};
use vbs_notification::*;
use windows_toast::*;
use wsl_notifications::*;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let bridge_manager = create_shared_bridge_manager();
let temp_manager = create_shared_temp_manager().expect("Failed to create temp file manager");
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
@@ -29,10 +35,42 @@ pub fn run() {
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_clipboard_manager::init())
.manage(bridge_manager.clone())
.manage(temp_manager.clone())
.setup(move |app| {
// Initialize the app handle in the bridge manager
bridge_manager.lock().set_app_handle(app.handle().clone());
// Clean up any orphaned temp files from previous sessions
if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() {
if count > 0 {
println!("Cleaned up {} orphaned temp files", count);
}
}
// Set up system tray
if let Err(e) = setup_tray(app.handle()) {
eprintln!("Failed to set up system tray: {}", e);
}
// Handle window close event for minimize to tray
let main_window = app.get_webview_window("main").unwrap();
main_window.on_window_event({
let app_handle = app.handle().clone();
move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if should_minimize_to_tray(&app_handle) {
api.prevent_close();
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.hide();
}
}
}
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -47,12 +85,23 @@ pub fn run() {
save_config,
get_usage_stats,
load_saved_achievements,
answer_question,
send_windows_notification,
send_simple_notification,
send_windows_toast,
send_notify_send,
send_wsl_notification,
send_vbs_notification,
validate_directory,
list_skills,
check_for_updates,
save_temp_file,
register_temp_file,
get_temp_files,
cleanup_temp_files,
cleanup_all_temp_files,
cleanup_orphaned_temp_files,
get_file_size,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+8 -3
View File
@@ -1,5 +1,5 @@
use tauri::command;
use std::process::Command;
use tauri::command;
#[command]
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
@@ -10,7 +10,12 @@ pub async fn send_notify_send(title: String, body: String) -> Result<(), String>
.arg("--urgency=normal")
.arg("--app-name=Hikari Desktop")
.output()
.map_err(|e| format!("Failed to execute notify-send: {}. Make sure libnotify-bin is installed.", e))?;
.map_err(|e| {
format!(
"Failed to execute notify-send: {}. Make sure libnotify-bin is installed.",
e
)
})?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
@@ -93,4 +98,4 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(),
.map_err(|e| format!("Failed to send message: {}", e))?;
Ok(())
}
}
+6 -3
View File
@@ -1,7 +1,7 @@
use crate::achievements::{check_achievements, AchievementProgress};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Instant;
use crate::achievements::{AchievementProgress, check_achievements};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UsageStats {
@@ -89,7 +89,10 @@ impl UsageStats {
pub fn increment_tool_usage(&mut self, tool_name: &str) {
*self.tools_usage.entry(tool_name.to_string()).or_insert(0) += 1;
*self.session_tools_usage.entry(tool_name.to_string()).or_insert(0) += 1;
*self
.session_tools_usage
.entry(tool_name.to_string())
.or_insert(0) += 1;
}
pub fn get_session_duration(&mut self) -> u64 {
@@ -213,4 +216,4 @@ mod tests {
assert_eq!(stats.session_cost_usd, 0.0);
assert!(stats.total_cost_usd > 0.0);
}
}
}
+139
View File
@@ -0,0 +1,139 @@
use parking_lot::Mutex;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use uuid::Uuid;
const TEMP_DIR_NAME: &str = "hikari-uploads";
pub struct TempFileManager {
base_dir: PathBuf,
files: HashMap<String, Vec<PathBuf>>,
}
impl TempFileManager {
pub fn new() -> Result<Self, String> {
let base_dir = std::env::temp_dir().join(TEMP_DIR_NAME);
if !base_dir.exists() {
fs::create_dir_all(&base_dir)
.map_err(|e| format!("Failed to create temp directory: {}", e))?;
}
Ok(TempFileManager {
base_dir,
files: HashMap::new(),
})
}
#[allow(dead_code)]
pub fn get_base_dir(&self) -> &Path {
&self.base_dir
}
pub fn save_file(
&mut self,
conversation_id: &str,
data: &[u8],
original_filename: Option<&str>,
) -> Result<PathBuf, String> {
let unique_id = Uuid::new_v4();
let extension = original_filename
.and_then(|name| Path::new(name).extension())
.and_then(|ext| ext.to_str())
.unwrap_or("bin");
let filename = format!("{}_{}.{}", conversation_id, unique_id, extension);
let file_path = self.base_dir.join(&filename);
fs::write(&file_path, data)
.map_err(|e| format!("Failed to write temp file: {}", e))?;
self.files
.entry(conversation_id.to_string())
.or_default()
.push(file_path.clone());
Ok(file_path)
}
pub fn register_file(&mut self, conversation_id: &str, file_path: PathBuf) {
self.files
.entry(conversation_id.to_string())
.or_default()
.push(file_path);
}
pub fn get_files_for_conversation(&self, conversation_id: &str) -> Vec<PathBuf> {
self.files
.get(conversation_id)
.cloned()
.unwrap_or_default()
}
pub fn cleanup_conversation(&mut self, conversation_id: &str) -> Result<(), String> {
if let Some(files) = self.files.remove(conversation_id) {
for file_path in files {
if file_path.exists() {
if let Err(e) = fs::remove_file(&file_path) {
eprintln!(
"Warning: Failed to remove temp file {:?}: {}",
file_path, e
);
}
}
}
}
Ok(())
}
pub fn cleanup_all(&mut self) -> Result<(), String> {
let conversation_ids: Vec<String> = self.files.keys().cloned().collect();
for conversation_id in conversation_ids {
self.cleanup_conversation(&conversation_id)?;
}
Ok(())
}
pub fn cleanup_orphaned_files(&mut self) -> Result<usize, String> {
let mut cleaned_count = 0;
if !self.base_dir.exists() {
return Ok(0);
}
let tracked_files: std::collections::HashSet<PathBuf> =
self.files.values().flatten().cloned().collect();
let entries = fs::read_dir(&self.base_dir)
.map_err(|e| format!("Failed to read temp directory: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && !tracked_files.contains(&path) {
if let Err(e) = fs::remove_file(&path) {
eprintln!("Warning: Failed to remove orphaned file {:?}: {}", path, e);
} else {
cleaned_count += 1;
}
}
}
Ok(cleaned_count)
}
}
impl Default for TempFileManager {
fn default() -> Self {
Self::new().expect("Failed to create TempFileManager")
}
}
pub type SharedTempFileManager = Arc<Mutex<TempFileManager>>;
pub fn create_shared_temp_manager() -> Result<SharedTempFileManager, String> {
Ok(Arc::new(Mutex::new(TempFileManager::new()?)))
}
+68
View File
@@ -0,0 +1,68 @@
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager,
};
use crate::config::HikariConfig;
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
let show_item = MenuItem::with_id(app, "show", "Show Hikari", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
let _tray = TrayIconBuilder::with_id("main")
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.tooltip("Hikari - Claude Code Assistant")
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
"quit" => {
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
})
.build(app)?;
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
}
+18
View File
@@ -216,6 +216,24 @@ pub struct WorkingDirectoryEvent {
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuestionOption {
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserQuestionEvent {
pub id: String,
pub question: String,
pub header: Option<String>,
pub options: Vec<QuestionOption>,
pub multi_select: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
+6 -9
View File
@@ -1,7 +1,7 @@
use std::process::Command;
use std::io::Write;
use tempfile::NamedTempFile;
use std::process::Command;
use tauri::command;
use tempfile::NamedTempFile;
#[command]
pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> {
@@ -17,8 +17,8 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
);
// Create a temporary VBS file
let mut temp_file = NamedTempFile::new()
.map_err(|e| format!("Failed to create temp file: {}", e))?;
let mut temp_file =
NamedTempFile::new().map_err(|e| format!("Failed to create temp file: {}", e))?;
temp_file
.write_all(vbs_content.as_bytes())
@@ -40,10 +40,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
} else if temp_path.starts_with("/tmp/") {
// WSL temp files might be in a different location
// Try to use wslpath to convert
let output = Command::new("wslpath")
.arg("-w")
.arg(&temp_path)
.output();
let output = Command::new("wslpath").arg("-w").arg(&temp_path).output();
if let Ok(result) = output {
if result.status.success() {
@@ -71,4 +68,4 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
}
Ok(())
}
}
+4 -3
View File
@@ -2,7 +2,7 @@ use tauri::command;
#[cfg(target_os = "windows")]
use windows::{
core::{HSTRING, Result as WindowsResult},
core::{Result as WindowsResult, HSTRING},
Data::Xml::Dom::*,
UI::Notifications::*,
};
@@ -38,7 +38,8 @@ fn show_toast_notification(title: &str, body: &str) -> WindowsResult<()> {
let toast = ToastNotification::CreateToastNotification(&xml_doc)?;
// Create a toast notifier with an application ID
let notifier = ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from("Hikari Desktop"))?;
let notifier =
ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from("Hikari Desktop"))?;
// Show the notification
notifier.Show(&toast)?;
@@ -60,4 +61,4 @@ fn escape_xml(text: &str) -> String {
#[command]
pub async fn send_windows_toast(_title: String, _body: String) -> Result<(), String> {
Err("Windows toast notifications are only available on Windows".to_string())
}
}
+343 -97
View File
@@ -8,11 +8,15 @@ use tempfile::NamedTempFile;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use crate::config::ClaudeStartOptions;
use crate::stats::{UsageStats, StatsUpdateEvent};
use parking_lot::RwLock;
use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent, ConnectionEvent, SessionEvent, WorkingDirectoryEvent};
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
use crate::config::ClaudeStartOptions;
use crate::stats::{StatsUpdateEvent, UsageStats};
use crate::types::{
CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, OutputEvent,
PermissionPromptEvent, QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent,
WorkingDirectoryEvent,
};
use parking_lot::RwLock;
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
@@ -103,7 +107,6 @@ impl WslBridge {
}
}
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
if self.process.is_some() {
return Err("Process already running".to_string());
@@ -115,14 +118,21 @@ impl WslBridge {
tauri::async_runtime::spawn(async move {
println!("Loading saved achievements...");
let achievements = crate::achievements::load_achievements(&app_clone).await;
println!("Loaded {} unlocked achievements", achievements.unlocked.len());
println!(
"Loaded {} unlocked achievements",
achievements.unlocked.len()
);
stats.write().achievements = achievements;
});
let working_dir = &options.working_dir;
self.working_directory = working_dir.clone();
emit_connection_status(&app, ConnectionStatus::Connecting, self.conversation_id.clone());
emit_connection_status(
&app,
ConnectionStatus::Connecting,
self.conversation_id.clone(),
);
// Create temp file for MCP config if provided
let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json {
@@ -158,16 +168,19 @@ impl WslBridge {
let mut command = if is_wsl {
// Running inside WSL - call claude directly
// Try to find claude in common locations since GUI apps may not inherit shell PATH
let claude_path = find_claude_binary()
.ok_or_else(|| "Could not find claude binary. Is Claude Code installed?".to_string())?;
let claude_path = find_claude_binary().ok_or_else(|| {
"Could not find claude binary. Is Claude Code installed?".to_string()
})?;
eprintln!("[DEBUG] Found claude at: {}", claude_path);
eprintln!("[DEBUG] Working dir: {}", working_dir);
let mut cmd = Command::new(&claude_path);
cmd.args([
"--output-format", "stream-json",
"--input-format", "stream-json",
"--output-format",
"stream-json",
"--input-format",
"stream-json",
"--verbose",
]);
@@ -195,6 +208,13 @@ impl WslBridge {
cmd.args(["--mcp-config", mcp_path]);
}
// Add resume flag if session ID provided
if let Some(ref session_id) = options.resume_session_id {
if !session_id.is_empty() {
cmd.args(["--resume", session_id]);
}
}
cmd.current_dir(working_dir);
// Set API key as environment variable if specified
@@ -211,10 +231,7 @@ impl WslBridge {
let mut cmd = Command::new("wsl");
// Build the claude command with all arguments
let mut claude_cmd = format!(
"cd '{}' && ",
working_dir
);
let mut claude_cmd = format!("cd '{}' && ", working_dir);
// Set API key as environment variable if specified
if let Some(ref api_key) = options.api_key {
@@ -223,7 +240,9 @@ impl WslBridge {
}
}
claude_cmd.push_str("claude --output-format stream-json --input-format stream-json --verbose");
claude_cmd.push_str(
"claude --output-format stream-json --input-format stream-json --verbose",
);
// Add model if specified
if let Some(ref model) = options.model {
@@ -251,6 +270,13 @@ impl WslBridge {
claude_cmd.push_str(&format!(" --mcp-config '{}'", mcp_path));
}
// Add resume flag if session ID provided
if let Some(ref session_id) = options.resume_session_id {
if !session_id.is_empty() {
claude_cmd.push_str(&format!(" --resume '{}'", session_id));
}
}
// Use bash -lc to load login profile (ensures PATH includes claude)
cmd.args(["-e", "bash", "-lc", &claude_cmd]);
@@ -278,8 +304,8 @@ impl WslBridge {
self.stdin = stdin;
self.process = Some(child);
// Reset session stats when starting new session
self.stats.write().reset_session();
// Note: We no longer reset stats here - stats persist across reconnects
// Stats are only reset when explicitly disconnecting via stop()
// Load saved achievements
let app_handle = app.clone();
@@ -306,7 +332,11 @@ impl WslBridge {
});
}
emit_connection_status(&app, ConnectionStatus::Connected, self.conversation_id.clone());
emit_connection_status(
&app,
ConnectionStatus::Connected,
self.conversation_id.clone(),
);
Ok(())
}
@@ -331,7 +361,44 @@ impl WslBridge {
.write_all(format!("{}\n", json_line).as_bytes())
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
stdin.flush().map_err(|e| format!("Failed to flush stdin: {}", e))?;
stdin
.flush()
.map_err(|e| format!("Failed to flush stdin: {}", e))?;
Ok(())
}
pub fn send_tool_result(
&mut self,
tool_use_id: &str,
result: serde_json::Value,
) -> Result<(), String> {
let stdin = self.stdin.as_mut().ok_or("Process not running")?;
// The content should be a JSON string representation of the result
let content_str = serde_json::to_string(&result).map_err(|e| e.to_string())?;
let input = serde_json::json!({
"type": "user",
"message": {
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": content_str
}]
}
});
let json_line = serde_json::to_string(&input).map_err(|e| e.to_string())?;
stdin
.write_all(format!("{}\n", json_line).as_bytes())
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
stdin
.flush()
.map_err(|e| format!("Failed to flush stdin: {}", e))?;
Ok(())
}
@@ -352,7 +419,11 @@ impl WslBridge {
// The user will see what session was interrupted
// Emit disconnected status
emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone());
emit_connection_status(
app,
ConnectionStatus::Disconnected,
self.conversation_id.clone(),
);
Ok(())
} else {
@@ -368,7 +439,15 @@ impl WslBridge {
self.stdin = None;
self.session_id = None;
self.mcp_config_file = None; // Temp file is automatically deleted when dropped
emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone());
// Reset session stats on explicit disconnect
self.stats.write().reset_session();
emit_connection_status(
app,
ConnectionStatus::Disconnected,
self.conversation_id.clone(),
);
}
pub fn is_running(&self) -> bool {
@@ -382,7 +461,6 @@ impl WslBridge {
pub fn get_stats(&self) -> UsageStats {
self.stats.read().clone()
}
}
impl Default for WslBridge {
@@ -391,7 +469,12 @@ impl Default for WslBridge {
}
}
fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<RwLock<UsageStats>>, conversation_id: Option<String>) {
fn handle_stdout(
stdout: std::process::ChildStdout,
app: AppHandle,
stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>,
) {
let reader = BufReader::new(stdout);
for line in reader.lines() {
@@ -412,18 +495,25 @@ fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<R
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
}
fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle, conversation_id: Option<String>) {
fn handle_stderr(
stderr: std::process::ChildStderr,
app: AppHandle,
conversation_id: Option<String>,
) {
let reader = BufReader::new(stderr);
for line in reader.lines() {
match line {
Ok(line) if !line.is_empty() => {
let _ = app.emit("claude:output", OutputEvent {
line_type: "error".to_string(),
content: line,
tool_name: None,
conversation_id: conversation_id.clone(),
});
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "error".to_string(),
content: line,
tool_name: None,
conversation_id: conversation_id.clone(),
},
);
}
Err(_) => break,
_ => {}
@@ -431,24 +521,40 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle, conversation
}
}
fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>>, conversation_id: &Option<String>) -> Result<(), String> {
fn process_json_line(
line: &str,
app: &AppHandle,
stats: &Arc<RwLock<UsageStats>>,
conversation_id: &Option<String>,
) -> Result<(), String> {
let message: ClaudeMessage = serde_json::from_str(line)
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
match &message {
ClaudeMessage::System { subtype, session_id, cwd, .. } => {
ClaudeMessage::System {
subtype,
session_id,
cwd,
..
} => {
if subtype == "init" {
if let Some(id) = session_id {
let _ = app.emit("claude:session", SessionEvent {
session_id: id.clone(),
conversation_id: conversation_id.clone(),
});
let _ = app.emit(
"claude:session",
SessionEvent {
session_id: id.clone(),
conversation_id: conversation_id.clone(),
},
);
}
if let Some(dir) = cwd {
let _ = app.emit("claude:cwd", WorkingDirectoryEvent {
directory: dir.clone(),
conversation_id: conversation_id.clone(),
});
let _ = app.emit(
"claude:cwd",
WorkingDirectoryEvent {
directory: dir.clone(),
conversation_id: conversation_id.clone(),
},
);
}
emit_state_change(app, CharacterState::Idle, None, conversation_id.clone());
}
@@ -500,12 +606,15 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
}
let desc = format_tool_description(name, input);
let _ = app.emit("claude:output", OutputEvent {
line_type: "tool".to_string(),
content: desc,
tool_name: Some(name.clone()),
conversation_id: conversation_id.clone(),
});
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "tool".to_string(),
content: desc,
tool_name: Some(name.clone()),
conversation_id: conversation_id.clone(),
},
);
}
ContentBlock::Text { text } => {
// Count code blocks in the text
@@ -514,21 +623,27 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
stats.write().increment_code_blocks();
}
let _ = app.emit("claude:output", OutputEvent {
line_type: "assistant".to_string(),
content: text.clone(),
tool_name: None,
conversation_id: conversation_id.clone(),
});
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "assistant".to_string(),
content: text.clone(),
tool_name: None,
conversation_id: conversation_id.clone(),
},
);
}
ContentBlock::Thinking { thinking } => {
state = CharacterState::Thinking;
let _ = app.emit("claude:output", OutputEvent {
line_type: "system".to_string(),
content: format!("[Thinking] {}", thinking),
tool_name: None,
conversation_id: conversation_id.clone(),
});
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "system".to_string(),
content: format!("[Thinking] {}", thinking),
tool_name: None,
conversation_id: conversation_id.clone(),
},
);
}
_ => {}
}
@@ -563,7 +678,13 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
}
}
ClaudeMessage::Result { subtype, result, permission_denials, usage: _, .. } => {
ClaudeMessage::Result {
subtype,
result,
permission_denials,
usage: _,
..
} => {
let state = if subtype == "success" {
CharacterState::Success
} else {
@@ -584,9 +705,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
// Emit achievement events for any newly unlocked achievements
for achievement_id in &newly_unlocked {
let info = get_achievement_info(achievement_id);
let _ = app.emit("achievement:unlocked", AchievementUnlockedEvent {
achievement: info,
});
let _ = app.emit(
"achievement:unlocked",
AchievementUnlockedEvent { achievement: info },
);
}
// Save achievements after unlocking new ones
@@ -598,7 +720,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
// Use Tauri's async runtime instead of tokio::spawn
tauri::async_runtime::spawn(async move {
println!("Spawned save task for achievements");
if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress).await {
if let Err(e) =
crate::achievements::save_achievements(&app_handle, &achievements_progress)
.await
{
eprintln!("Failed to save achievements: {}", e);
} else {
println!("Achievement save task completed successfully");
@@ -615,31 +740,107 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
// Only emit error results - success content is already sent via Assistant message
if subtype != "success" {
if let Some(text) = result {
let _ = app.emit("claude:output", OutputEvent {
line_type: "error".to_string(),
content: text.clone(),
tool_name: None,
conversation_id: conversation_id.clone(),
});
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "error".to_string(),
content: text.clone(),
tool_name: None,
conversation_id: conversation_id.clone(),
},
);
}
}
// Check for permission denials and emit prompts for each
if let Some(denials) = permission_denials {
let mut has_regular_denials = false;
for denial in denials {
let description = format_tool_description(&denial.tool_name, &denial.tool_input);
let _ = app.emit("claude:permission", PermissionPromptEvent {
id: denial.tool_use_id.clone(),
tool_name: denial.tool_name.clone(),
tool_input: denial.tool_input.clone(),
description,
conversation_id: conversation_id.clone(),
});
// Special handling for AskUserQuestion tool
if denial.tool_name == "AskUserQuestion" {
if let Some(questions) = denial
.tool_input
.get("questions")
.and_then(|q| q.as_array())
{
// For now, handle the first question (most common case)
if let Some(first_question) = questions.first() {
let question_text = first_question
.get("question")
.and_then(|q| q.as_str())
.unwrap_or("Claude has a question for you")
.to_string();
let header = first_question
.get("header")
.and_then(|h| h.as_str())
.map(|s| s.to_string());
let multi_select = first_question
.get("multiSelect")
.and_then(|m| m.as_bool())
.unwrap_or(false);
let options: Vec<QuestionOption> = first_question
.get("options")
.and_then(|opts| opts.as_array())
.map(|opts| {
opts.iter()
.filter_map(|opt| {
let label =
opt.get("label").and_then(|l| l.as_str())?;
let description = opt
.get("description")
.and_then(|d| d.as_str())
.map(|s| s.to_string());
Some(QuestionOption {
label: label.to_string(),
description,
})
})
.collect()
})
.unwrap_or_default();
let _ = app.emit(
"claude:question",
UserQuestionEvent {
id: denial.tool_use_id.clone(),
question: question_text,
header,
options,
multi_select,
conversation_id: conversation_id.clone(),
},
);
}
}
} else {
has_regular_denials = true;
let description =
format_tool_description(&denial.tool_name, &denial.tool_input);
let _ = app.emit(
"claude:permission",
PermissionPromptEvent {
id: denial.tool_use_id.clone(),
tool_name: denial.tool_name.clone(),
tool_input: denial.tool_input.clone(),
description,
conversation_id: conversation_id.clone(),
},
);
}
}
// Show permission state if there were denials
if !denials.is_empty() {
emit_state_change(app, CharacterState::Permission, None, conversation_id.clone());
// Show permission state if there were any denials (questions or regular)
if has_regular_denials || !denials.is_empty() {
emit_state_change(
app,
CharacterState::Permission,
None,
conversation_id.clone(),
);
return Ok(());
}
}
@@ -652,7 +853,9 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
stats.write().increment_messages();
// Extract text content from the message
let message_text = message.content.iter()
let message_text = message
.content
.iter()
.filter_map(|block| match block {
crate::types::ContentBlock::Text { text } => Some(text.clone()),
_ => None,
@@ -682,9 +885,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
for achievement_id in &newly_unlocked {
println!("User message unlocked achievement: {:?}", achievement_id);
let info = get_achievement_info(achievement_id);
let _ = app.emit("achievement:unlocked", AchievementUnlockedEvent {
achievement: info,
});
let _ = app.emit(
"achievement:unlocked",
AchievementUnlockedEvent { achievement: info },
);
}
// Save achievements after unlocking new ones
@@ -693,7 +897,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
let app_handle = app.clone();
let achievements_progress = stats.read().achievements.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress).await {
if let Err(e) =
crate::achievements::save_achievements(&app_handle, &achievements_progress)
.await
{
eprintln!("Failed to save achievements: {}", e);
} else {
println!("Achievements saved after user message");
@@ -768,15 +975,36 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
}
}
fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option<String>, conversation_id: Option<String>) {
let _ = app.emit("claude:state", StateChangeEvent { state, tool_name, conversation_id });
fn emit_state_change(
app: &AppHandle,
state: CharacterState,
tool_name: Option<String>,
conversation_id: Option<String>,
) {
let _ = app.emit(
"claude:state",
StateChangeEvent {
state,
tool_name,
conversation_id,
},
);
}
fn emit_connection_status(app: &AppHandle, status: ConnectionStatus, conversation_id: Option<String>) {
let _ = app.emit("claude:connection", ConnectionEvent { status, conversation_id });
fn emit_connection_status(
app: &AppHandle,
status: ConnectionStatus,
conversation_id: Option<String>,
) {
let _ = app.emit(
"claude:connection",
ConnectionEvent {
status,
conversation_id,
},
);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -786,21 +1014,36 @@ mod tests {
assert!(matches!(get_tool_state("Read"), CharacterState::Searching));
assert!(matches!(get_tool_state("Glob"), CharacterState::Searching));
assert!(matches!(get_tool_state("Grep"), CharacterState::Searching));
assert!(matches!(get_tool_state("WebSearch"), CharacterState::Searching));
assert!(matches!(get_tool_state("WebFetch"), CharacterState::Searching));
assert!(matches!(
get_tool_state("WebSearch"),
CharacterState::Searching
));
assert!(matches!(
get_tool_state("WebFetch"),
CharacterState::Searching
));
}
#[test]
fn test_get_tool_state_coding_tools() {
assert!(matches!(get_tool_state("Edit"), CharacterState::Coding));
assert!(matches!(get_tool_state("Write"), CharacterState::Coding));
assert!(matches!(get_tool_state("NotebookEdit"), CharacterState::Coding));
assert!(matches!(
get_tool_state("NotebookEdit"),
CharacterState::Coding
));
}
#[test]
fn test_get_tool_state_mcp_tools() {
assert!(matches!(get_tool_state("mcp__github__create_issue"), CharacterState::Mcp));
assert!(matches!(get_tool_state("mcp__notion__search"), CharacterState::Mcp));
assert!(matches!(
get_tool_state("mcp__github__create_issue"),
CharacterState::Mcp
));
assert!(matches!(
get_tool_state("mcp__notion__search"),
CharacterState::Mcp
));
}
#[test]
@@ -810,7 +1053,10 @@ mod tests {
#[test]
fn test_get_tool_state_unknown() {
assert!(matches!(get_tool_state("SomeUnknownTool"), CharacterState::Typing));
assert!(matches!(
get_tool_state("SomeUnknownTool"),
CharacterState::Typing
));
assert!(matches!(get_tool_state("Bash"), CharacterState::Typing));
}
+1 -1
View File
@@ -81,4 +81,4 @@ $notifier.Show($toast)
// If all methods fail, return an error
Err("All WSL notification methods failed".to_string())
}
}
+7 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop",
"version": "0.2.0",
"version": "0.3.0",
"identifier": "com.naomi.hikari-desktop",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -22,6 +22,12 @@
],
"security": {
"csp": null
},
"trayIcon": {
"id": "main",
"iconPath": "icons/32x32.png",
"iconAsTemplate": false,
"tooltip": "Hikari - Claude Code Assistant"
}
},
"bundle": {
+52
View File
@@ -6,6 +6,7 @@
--bg-secondary: #16213e;
--bg-terminal: #0f0f1a;
--bg-hover: #2a2a4a;
--bg-code: #1e1e2e;
--accent-primary: #e94560;
--accent-secondary: #ff6b9d;
--text-primary: #ffffff;
@@ -18,6 +19,16 @@
--terminal-tool: #c084fc;
--terminal-tool-name: #ddd6fe;
--terminal-error: #f87171;
/* Syntax highlighting colors (dark) */
--hljs-keyword: #f472b6;
--hljs-string: #a3e635;
--hljs-number: #fbbf24;
--hljs-comment: #6b7280;
--hljs-function: #c084fc;
--hljs-type: #22d3ee;
--hljs-variable: #fb923c;
--hljs-meta: #94a3b8;
}
[data-theme="light"] {
@@ -25,6 +36,7 @@
--bg-secondary: #ffffff;
--bg-terminal: #f1f3f4;
--bg-hover: #e8e8e8;
--bg-code: #f5f5f5;
--accent-primary: #e94560;
--accent-secondary: #ff6b9d;
--text-primary: #1a1a2e;
@@ -37,6 +49,46 @@
--terminal-tool: #7c3aed;
--terminal-tool-name: #8b5cf6;
--terminal-error: #dc2626;
/* Syntax highlighting colors (light) */
--hljs-keyword: #d946ef;
--hljs-string: #16a34a;
--hljs-number: #d97706;
--hljs-comment: #9ca3af;
--hljs-function: #7c3aed;
--hljs-type: #0891b2;
--hljs-variable: #ea580c;
--hljs-meta: #64748b;
}
[data-theme="high-contrast"] {
--bg-primary: #000000;
--bg-secondary: #0a0a0a;
--bg-terminal: #000000;
--bg-hover: #1a1a1a;
--bg-code: #0a0a0a;
--accent-primary: #ff4d6d;
--accent-secondary: #ff85a1;
--text-primary: #ffffff;
--text-secondary: #e0e0e0;
--text-tertiary: #b0b0b0;
--border-color: #ffffff;
/* Terminal specific colors - bright and saturated */
--terminal-user: #00ffff;
--terminal-tool: #ff00ff;
--terminal-tool-name: #ffaaff;
--terminal-error: #ff5555;
/* Syntax highlighting colors (high contrast) */
--hljs-keyword: #ff66ff;
--hljs-string: #66ff66;
--hljs-number: #ffff00;
--hljs-comment: #aaaaaa;
--hljs-function: #ff99ff;
--hljs-type: #00ffff;
--hljs-variable: #ffaa00;
--hljs-meta: #cccccc;
}
html,
+280
View File
@@ -0,0 +1,280 @@
import { get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import { setSkipNextGreeting } from "$lib/tauri";
import { searchState } from "$lib/stores/search";
export interface SlashCommand {
name: string;
description: string;
usage: string;
execute: (args: string) => Promise<void> | void;
}
async function changeDirectory(path: string): Promise<void> {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
claudeStore.addLine("error", "No active conversation");
return;
}
if (!path.trim()) {
const currentDir = get(claudeStore.currentWorkingDirectory);
claudeStore.addLine("system", `Current directory: ${currentDir}`);
return;
}
try {
characterState.setState("thinking");
claudeStore.addLine("system", `Changing directory to: ${path}`);
const currentDir = get(claudeStore.currentWorkingDirectory);
const validatedPath = await invoke<string>("validate_directory", { path, currentDir });
// Capture conversation history before disconnecting
const conversationHistory = claudeStore.getConversationHistory();
await invoke("stop_claude", { conversationId });
// Wait for clean shutdown
await new Promise((resolve) => setTimeout(resolve, 500));
claudeStore.setWorkingDirectory(validatedPath);
setSkipNextGreeting(true);
await invoke("start_claude", {
conversationId,
options: {
working_dir: validatedPath,
},
});
// Wait for connection to establish
await new Promise((resolve) => setTimeout(resolve, 1000));
// Restore context if there was conversation history
if (conversationHistory) {
const contextMessage = `[CONTEXT RESTORATION]
I just changed the working directory from ${currentDir} to ${validatedPath}. Here's our conversation so far:
${conversationHistory}
Please continue where we left off. You are now operating in the new directory.`;
await invoke("send_prompt", {
conversationId,
message: contextMessage,
});
}
claudeStore.addLine("system", `Changed directory to: ${validatedPath}`);
characterState.setState("idle");
} catch (error) {
claudeStore.addLine("error", `Failed to change directory: ${error}`);
characterState.setTemporaryState("error", 3000);
}
}
async function startNewConversation(): Promise<void> {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
claudeStore.addLine("error", "No active conversation");
return;
}
try {
const workingDir = await invoke<string>("get_working_directory", {
conversationId,
});
claudeStore.addLine("system", "Starting new conversation...");
characterState.setState("thinking");
await invoke("interrupt_claude", { conversationId });
claudeStore.clearTerminal();
setSkipNextGreeting(true);
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDir,
},
});
claudeStore.addLine("system", "New conversation started!");
characterState.setState("idle");
} catch (error) {
claudeStore.addLine("error", `Failed to start new conversation: ${error}`);
characterState.setTemporaryState("error", 3000);
}
}
export const slashCommands: SlashCommand[] = [
{
name: "cd",
description: "Change the working directory",
usage: "/cd <path>",
execute: changeDirectory,
},
{
name: "clear",
description: "Clear the terminal display (keeps conversation context)",
usage: "/clear",
execute: () => {
claudeStore.clearTerminal();
claudeStore.addLine("system", "Terminal cleared");
},
},
{
name: "new",
description: "Start a fresh conversation (resets context)",
usage: "/new",
execute: startNewConversation,
},
{
name: "help",
description: "Show available slash commands",
usage: "/help",
execute: () => {
const helpText = slashCommands
.map((cmd) => ` ${cmd.usage.padEnd(12)} - ${cmd.description}`)
.join("\n");
claudeStore.addLine("system", `Available commands:\n${helpText}`);
},
},
{
name: "search",
description: "Search within the conversation (use /search to clear)",
usage: "/search [query]",
execute: (args: string) => {
if (!args.trim()) {
searchState.clear();
claudeStore.addLine("system", "Search cleared");
return;
}
searchState.setQuery(args.trim());
claudeStore.addLine("system", `Searching for: "${args.trim()}"`);
},
},
{
name: "summarise",
description: "Get a summary of the entire conversation",
usage: "/summarise",
execute: async () => {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
claudeStore.addLine("error", "No active conversation");
return;
}
try {
claudeStore.addLine("system", "Requesting conversation summary...");
await invoke("send_prompt", {
conversationId,
message:
"Please provide a comprehensive summary of our entire conversation so far, including the key topics we've discussed, decisions made, and any important context.",
});
} catch (error) {
claudeStore.addLine("error", `Failed to request summary: ${error}`);
}
},
},
{
name: "skill",
description: "Invoke a Claude Code skill from ~/.claude/skills/",
usage: "/skill [name] [data]",
execute: async (args: string) => {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
claudeStore.addLine("error", "No active conversation");
return;
}
const parts = args.trim().split(/\s+/);
const skillName = parts[0];
const skillData = parts.slice(1).join(" ");
// If no skill name provided, list available skills
if (!skillName) {
try {
const skills = await invoke<string[]>("list_skills");
if (skills.length === 0) {
claudeStore.addLine(
"system",
"No skills found in ~/.claude/skills/\nCreate a skill by adding a folder with a SKILL.md file."
);
} else {
const skillList = skills.map((s) => `${s}`).join("\n");
claudeStore.addLine(
"system",
`Available skills:\n${skillList}\n\nUsage: /skill <skill-name> [data]`
);
}
} catch (error) {
claudeStore.addLine("error", `Failed to list skills: ${error}`);
}
return;
}
try {
claudeStore.addLine("system", `Invoking skill: ${skillName}`);
characterState.setState("thinking");
const message = skillData
? `Please run the /${skillName} skill with the following data:\n\n${skillData}`
: `Please run the /${skillName} skill.`;
await invoke("send_prompt", {
conversationId,
message,
});
} catch (error) {
claudeStore.addLine("error", `Failed to invoke skill: ${error}`);
characterState.setTemporaryState("error", 3000);
}
},
},
];
export function parseSlashCommand(input: string): {
command: SlashCommand | null;
args: string;
} {
const trimmed = input.trim();
if (!trimmed.startsWith("/")) {
return { command: null, args: "" };
}
const parts = trimmed.slice(1).split(/\s+/);
const commandName = parts[0]?.toLowerCase();
const args = parts.slice(1).join(" ");
const command = slashCommands.find((cmd) => cmd.name.toLowerCase() === commandName);
return { command: command || null, args };
}
export function getMatchingCommands(input: string): SlashCommand[] {
const trimmed = input.trim();
if (!trimmed.startsWith("/")) {
return [];
}
const partial = trimmed.slice(1).toLowerCase();
if (partial === "") {
return slashCommands;
}
return slashCommands.filter((cmd) => cmd.name.toLowerCase().startsWith(partial));
}
export function isSlashCommand(input: string): boolean {
return input.trim().startsWith("/");
}
+15 -13
View File
@@ -40,10 +40,12 @@
tabindex="-1"
>
<div class="flex items-center justify-between mb-4">
<h2 id="about-title" class="text-xl font-semibold text-gray-100">About Hikari Desktop</h2>
<h2 id="about-title" class="text-xl font-semibold text-[var(--text-primary)]">
About Hikari Desktop
</h2>
<button
onclick={onClose}
class="p-1 text-gray-500 hover:text-gray-300 transition-colors"
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -59,16 +61,16 @@
<div class="space-y-4 text-sm">
<div>
<h3 class="font-medium text-gray-200 mb-2">What is Hikari Desktop?</h3>
<p class="text-gray-400">
<h3 class="font-medium text-[var(--text-primary)] mb-2">What is Hikari Desktop?</h3>
<p class="text-[var(--text-secondary)]">
Hikari Desktop is an AI-powered desktop assistant that brings Claude directly to your
desktop. Built with love using Tauri, Svelte, and Rust for a fast, native experience.
</p>
</div>
<div>
<h3 class="font-medium text-gray-200 mb-2">Version</h3>
<p class="text-gray-400 mb-1">
<h3 class="font-medium text-[var(--text-primary)] mb-2">Version</h3>
<p class="text-[var(--text-secondary)] mb-1">
{appVersion || "Loading..."}
</p>
<button
@@ -80,7 +82,7 @@
</div>
<div>
<h3 class="font-medium text-gray-200 mb-2">Source Code</h3>
<h3 class="font-medium text-[var(--text-primary)] mb-2">Source Code</h3>
<button
onclick={() => openUrl(links.source)}
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
@@ -90,8 +92,8 @@
</div>
<div>
<h3 class="font-medium text-gray-200 mb-2">Support & Community</h3>
<p class="text-gray-400 mb-1">Found a bug or have a suggestion?</p>
<h3 class="font-medium text-[var(--text-primary)] mb-2">Support & Community</h3>
<p class="text-[var(--text-secondary)] mb-1">Found a bug or have a suggestion?</p>
<button
onclick={() => openUrl(links.discord)}
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
@@ -101,7 +103,7 @@
</div>
<div>
<h3 class="font-medium text-gray-200 mb-2">Built with 💕 by</h3>
<h3 class="font-medium text-[var(--text-primary)] mb-2">Built with 💕 by</h3>
<button
onclick={() => openUrl(links.website)}
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
@@ -111,8 +113,8 @@
</div>
<div>
<h3 class="font-medium text-gray-200 mb-2">License</h3>
<p class="text-gray-400 mb-1">
<h3 class="font-medium text-[var(--text-primary)] mb-2">License</h3>
<p class="text-[var(--text-secondary)] mb-1">
This project is open source and available under our license terms.
</p>
<button
@@ -124,7 +126,7 @@
</div>
<div class="pt-4 mt-4 border-t border-[var(--border-color)]">
<p class="text-xs text-gray-500 text-center">
<p class="text-xs text-[var(--text-tertiary)] text-center">
Copyright © {new Date().getFullYear()} Naomi Carrigan. All rights reserved.
</p>
</div>
+15 -11
View File
@@ -57,30 +57,34 @@
}
</script>
<div class="anime-girl-container flex flex-col items-center justify-end h-full p-4">
<div class="character-frame relative {getBackgroundGlow()} w-full max-w-md">
<div class="sprite-container {getAnimationClass()}">
<div
class="anime-girl-container flex flex-col items-center justify-between h-full p-4 overflow-hidden"
>
<div
class="character-frame relative {getBackgroundGlow()} flex-1 flex items-center justify-center min-h-0"
>
<div class="sprite-container {getAnimationClass()} h-full flex items-center justify-center">
<img
src="/sprites/{info.spriteFile}"
alt="Hikari - {info.label}"
class="character-sprite w-full h-auto object-contain"
class="character-sprite h-full w-auto max-w-full object-contain"
onerror={(e) => {
const target = e.currentTarget as HTMLImageElement;
target.src = "/sprites/placeholder.svg";
}}
/>
</div>
</div>
<div class="state-indicator absolute -bottom-2 left-1/2 transform -translate-x-1/2">
<div
class="px-3 py-1 rounded-full text-xs font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[var(--accent-primary)]"
>
{info.label}
</div>
<div class="state-indicator mt-2">
<div
class="px-3 py-1 rounded-full text-xs font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[var(--accent-primary)]"
>
{info.label}
</div>
</div>
<div class="speech-bubble mt-4 max-w-xs">
<div class="speech-bubble mt-2 max-w-xs flex-shrink-0">
<div
class="relative bg-[var(--bg-secondary)] rounded-lg px-4 py-2 border border-[var(--border-color)]"
>
+209
View File
@@ -0,0 +1,209 @@
<script lang="ts">
import type { Attachment } from "$lib/types/messages";
interface Props {
attachments: Attachment[];
onRemove: (id: string) => void;
}
let { attachments, onRemove }: Props = $props();
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function getFileIcon(type: Attachment["type"]): string {
switch (type) {
case "image":
return "🖼️";
case "document":
return "📄";
default:
return "📎";
}
}
</script>
{#if attachments.length > 0}
<div class="attachment-preview-container">
<div class="attachment-header">
<span class="attachment-count"
>{attachments.length} attachment{attachments.length !== 1 ? "s" : ""}</span
>
</div>
<div class="attachment-list">
{#each attachments as attachment (attachment.id)}
<div class="attachment-item" class:is-image={attachment.type === "image"}>
{#if attachment.type === "image" && attachment.previewUrl}
<div class="image-preview">
<img src={attachment.previewUrl} alt={attachment.filename} />
</div>
{:else}
<div class="file-icon">
{getFileIcon(attachment.type)}
</div>
{/if}
<div class="attachment-info">
<span class="attachment-filename" title={attachment.filename}>
{attachment.filename}
</span>
<span class="attachment-size">
{formatFileSize(attachment.size)}
</span>
</div>
<button
type="button"
class="remove-button"
onclick={() => onRemove(attachment.id)}
title="Remove attachment"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
{/each}
</div>
</div>
{/if}
<style>
.attachment-preview-container {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
margin-bottom: 8px;
}
.attachment-header {
display: flex;
align-items: center;
gap: 8px;
}
.attachment-count {
font-size: 12px;
color: var(--text-secondary);
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.attachment-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
max-width: 200px;
position: relative;
}
.attachment-item.is-image {
flex-direction: column;
padding: 4px;
max-width: 120px;
}
.image-preview {
width: 100%;
max-width: 110px;
max-height: 80px;
border-radius: 4px;
overflow: hidden;
background: var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
}
.image-preview img {
max-width: 100%;
max-height: 80px;
object-fit: contain;
}
.file-icon {
font-size: 24px;
flex-shrink: 0;
}
.attachment-info {
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
flex: 1;
}
.is-image .attachment-info {
width: 100%;
padding: 0 4px;
}
.attachment-filename {
font-size: 12px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.attachment-size {
font-size: 10px;
color: var(--text-secondary);
}
.remove-button {
position: absolute;
top: -6px;
right: -6px;
width: 20px;
height: 20px;
padding: 0;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 50%;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition:
opacity 0.2s,
background 0.2s,
color 0.2s;
}
.attachment-item:hover .remove-button {
opacity: 1;
}
.remove-button:hover {
background: var(--error-color, #ef4444);
border-color: var(--error-color, #ef4444);
color: white;
}
</style>
@@ -0,0 +1,107 @@
<script lang="ts">
interface Props {
isOpen: boolean;
tabName: string;
onConfirm: () => void;
onCancel: () => void;
}
const { isOpen, tabName, onConfirm, onCancel }: Props = $props();
function handleKeydown(event: KeyboardEvent) {
if (!isOpen) return;
if (event.key === "Enter") {
event.preventDefault();
onConfirm();
} else 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 Connected Tab?
</h3>
<p id="confirm-message" class="text-sm text-[var(--text-secondary)]">
The tab "{tabName}" is currently connected to Claude. Are you sure you want to close
it? This will disconnect the session.
</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={onConfirm}
class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
>
Close Tab
</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>
+161 -38
View File
@@ -1,6 +1,15 @@
<script lang="ts">
import { configStore, type HikariConfig, type Theme } from "$lib/stores/config";
import {
configStore,
type HikariConfig,
type Theme,
applyFontSize,
MIN_FONT_SIZE,
MAX_FONT_SIZE,
DEFAULT_FONT_SIZE,
} from "$lib/stores/config";
import { claudeStore } from "$lib/stores/claude";
import { getCurrentWindow } from "@tauri-apps/api/window";
let config: HikariConfig = $state({
model: null,
@@ -13,6 +22,11 @@
greeting_custom_prompt: null,
notifications_enabled: true,
notification_volume: 0.7,
always_on_top: false,
minimize_to_tray: false,
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
});
let isOpen = $state(false);
@@ -61,6 +75,7 @@
saveError = null;
try {
await configStore.saveConfig(config);
configStore.closeSidebar();
} catch {
// Error is handled by the store
} finally {
@@ -95,6 +110,13 @@
function importFromSession() {
config.auto_granted_tools = [...new Set([...config.auto_granted_tools, ...grantedTools])];
}
async function handleAlwaysOnTopChange(enabled: boolean) {
config.always_on_top = enabled;
const window = getCurrentWindow();
await window.setAlwaysOnTop(enabled);
await configStore.updateConfig({ always_on_top: enabled });
}
</script>
<!-- Backdrop -->
@@ -121,7 +143,7 @@
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Settings</h2>
<button
onclick={configStore.closeSidebar}
class="p-1 text-gray-400 hover:text-white transition-colors"
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close settings"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -149,7 +171,7 @@
<!-- Model Selection -->
<div class="mb-4">
<label for="model" class="block text-sm text-gray-400 mb-1">Model</label>
<label for="model" class="block text-sm text-[var(--text-secondary)] mb-1">Model</label>
<select
id="model"
bind:value={config.model}
@@ -163,8 +185,8 @@
<!-- API Key -->
<div class="mb-4">
<label for="api-key" class="block text-sm text-gray-400 mb-1">
API Key <span class="text-gray-600">(optional override)</span>
<label for="api-key" class="block text-sm text-[var(--text-secondary)] mb-1">
API Key <span class="text-[var(--text-tertiary)]">(optional override)</span>
</label>
<div class="relative">
<input
@@ -177,7 +199,7 @@
<button
type="button"
onclick={() => (showApiKey = !showApiKey)}
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
class="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
aria-label={showApiKey ? "Hide API key" : "Show API key"}
>
{#if showApiKey}
@@ -211,7 +233,7 @@
<!-- Custom Instructions -->
<div class="mb-4">
<label for="instructions" class="block text-sm text-gray-400 mb-1"
<label for="instructions" class="block text-sm text-[var(--text-secondary)] mb-1"
>Custom Instructions</label
>
<textarea
@@ -238,9 +260,9 @@
bind:checked={config.greeting_enabled}
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-gray-300">Send greeting on connect</span>
<span class="text-sm text-[var(--text-primary)]">Send greeting on connect</span>
</label>
<p class="text-xs text-gray-500 mt-1 ml-7">
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Automatically greet you when a session starts with time-based messages
</p>
</div>
@@ -248,8 +270,8 @@
<!-- Custom Greeting Prompt -->
{#if config.greeting_enabled}
<div class="mb-4">
<label for="greeting-prompt" class="block text-sm text-gray-400 mb-1">
Custom Greeting Prompt <span class="text-gray-600">(optional)</span>
<label for="greeting-prompt" class="block text-sm text-[var(--text-secondary)] mb-1">
Custom Greeting Prompt <span class="text-[var(--text-tertiary)]">(optional)</span>
</label>
<textarea
id="greeting-prompt"
@@ -268,8 +290,8 @@
MCP Servers
</h3>
<div class="mb-2">
<label for="mcp-config" class="block text-sm text-gray-400 mb-1">
Server Configuration <span class="text-gray-600">(JSON)</span>
<label for="mcp-config" class="block text-sm text-[var(--text-secondary)] mb-1">
Server Configuration <span class="text-[var(--text-tertiary)]">(JSON)</span>
</label>
<textarea
id="mcp-config"
@@ -286,14 +308,14 @@
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Auto-Granted Tools
</h3>
<p class="text-xs text-gray-500 mb-3">
<p class="text-xs text-[var(--text-tertiary)] mb-3">
These tools will be pre-approved for every session (no permission prompts).
</p>
<!-- Common tools checkboxes -->
<div class="grid grid-cols-2 gap-2 mb-3">
{#each commonTools as tool (tool)}
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<label class="flex items-center gap-2 text-sm text-[var(--text-primary)] cursor-pointer">
<input
type="checkbox"
checked={config.auto_granted_tools.includes(tool)}
@@ -309,7 +331,7 @@
{#if grantedTools.length > 0}
<div class="mb-3">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-500">Session-granted tools:</span>
<span class="text-xs text-[var(--text-tertiary)]">Session-granted tools:</span>
<button
onclick={importFromSession}
class="text-xs text-[var(--accent-primary)] hover:text-[var(--accent-secondary)] transition-colors"
@@ -332,7 +354,7 @@
<!-- Custom tools list -->
{#if config.auto_granted_tools.filter((t) => !commonTools.includes(t)).length > 0}
<div class="mb-3">
<span class="text-xs text-gray-500 block mb-2">Custom tools:</span>
<span class="text-xs text-[var(--text-tertiary)] block mb-2">Custom tools:</span>
<div class="flex flex-wrap gap-1">
{#each config.auto_granted_tools.filter((t) => !commonTools.includes(t)) as tool (tool)}
<span
@@ -341,7 +363,7 @@
{tool}
<button
onclick={() => removeTool(tool)}
class="text-gray-500 hover:text-red-400"
class="text-[var(--text-tertiary)] hover:text-red-400"
aria-label="Remove {tool}"
>
×
@@ -376,23 +398,124 @@
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Appearance
</h3>
<div class="flex gap-2">
<button
onclick={() => handleThemeChange("dark")}
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'dark'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-gray-400 hover:border-[var(--accent-primary)]'}"
>
Dark
</button>
<button
onclick={() => handleThemeChange("light")}
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'light'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-gray-400 hover:border-[var(--accent-primary)]'}"
>
Light
</button>
<!-- Theme Selection -->
<div class="mb-4">
<label class="block text-sm text-[var(--text-secondary)] mb-2">Theme</label>
<div class="flex gap-2">
<button
onclick={() => handleThemeChange("dark")}
class="flex-1 px-3 py-2 rounded-lg border transition-colors {config.theme === 'dark'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
>
Dark
</button>
<button
onclick={() => handleThemeChange("light")}
class="flex-1 px-3 py-2 rounded-lg border transition-colors {config.theme === 'light'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
>
Light
</button>
<button
onclick={() => handleThemeChange("high-contrast")}
class="flex-1 px-3 py-2 rounded-lg border transition-colors {config.theme ===
'high-contrast'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
title="High contrast mode for improved accessibility"
>
High Contrast
</button>
</div>
</div>
<!-- Font Size -->
<div class="mb-4">
<label for="font-size" class="block text-sm text-[var(--text-secondary)] mb-2">
Terminal Font Size
</label>
<div class="flex items-center gap-3">
<input
id="font-size"
type="range"
bind:value={config.font_size}
oninput={() => applyFontSize(config.font_size)}
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
step="1"
class="flex-1 h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
/>
<span class="text-sm text-gray-300 w-12 text-right">{config.font_size}px</span>
<button
onclick={() => {
config.font_size = DEFAULT_FONT_SIZE;
applyFontSize(DEFAULT_FONT_SIZE);
}}
class="px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:border-[var(--accent-primary)] text-[var(--text-secondary)] transition-colors"
title="Reset to default (14px)"
>
Reset
</button>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">
Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset
</p>
</div>
</section>
<!-- Window Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Window
</h3>
<!-- Always on Top Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={config.always_on_top}
onchange={(e) => handleAlwaysOnTopChange(e.currentTarget.checked)}
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)]">Always on top</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Keep the window above other windows
</p>
</div>
<!-- Minimize to Tray Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.minimize_to_tray}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Minimize to system tray</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Hide to tray instead of closing when you click the X button
</p>
</div>
<!-- Update Checks Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.update_checks_enabled}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Check for updates on startup</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Notify when a new version is available
</p>
</div>
</section>
@@ -410,13 +533,13 @@
bind:checked={config.notifications_enabled}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-gray-300">Enable sound notifications</span>
<span class="text-sm text-[var(--text-primary)]">Enable sound notifications</span>
</label>
</div>
<!-- Volume Control -->
<div class="mb-4">
<label for="notification-volume" class="block text-sm text-gray-400 mb-2">
<label for="notification-volume" class="block text-sm text-[var(--text-secondary)] mb-2">
Notification Volume
</label>
<div class="flex items-center gap-3">
@@ -436,7 +559,7 @@
</div>
</div>
<div class="text-xs text-gray-500">
<div class="text-xs text-[var(--text-tertiary)]">
Sound notifications will play when I complete tasks, encounter errors, or need permissions.
</div>
</section>
+76 -59
View File
@@ -3,49 +3,35 @@
import { onMount } from "svelte";
import type { Conversation } from "$lib/stores/conversations";
import { SvelteMap } from "svelte/reactivity";
import CloseTabConfirmModal from "./CloseTabConfirmModal.svelte";
let conversations: Map<string, Conversation> = new Map();
let activeConversationId: string | null = null;
let editingTabId: string | null = null;
let editingName = "";
// Use store subscriptions with $ syntax
const conversations = $derived(claudeStore.conversations);
const activeConversationId = $derived(claudeStore.activeConversationId);
// Track which conversation actually has the Claude connection
let connectedConversationId: string | null = null;
let editingTabId = $state<string | null>(null);
let editingName = $state("");
// Track last seen message count for each conversation
let lastSeenMessageCount = new SvelteMap<string, number>();
claudeStore.conversations.subscribe((convs) => {
conversations = convs;
// Confirmation modal state
let showConfirmModal = $state(false);
let tabToClose = $state<string | null>(null);
let tabToCloseName = $state("");
// Update the last seen count for the active conversation
if (activeConversationId) {
const activeConv = convs.get(activeConversationId);
// Update last seen count when active conversation changes
$effect(() => {
if ($activeConversationId) {
const activeConv = $conversations.get($activeConversationId);
if (activeConv) {
lastSeenMessageCount.set(activeConversationId, activeConv.terminalLines.length);
lastSeenMessageCount.set($activeConversationId, activeConv.terminalLines.length);
// Trigger reactivity
lastSeenMessageCount = lastSeenMessageCount;
}
}
});
claudeStore.activeConversationId.subscribe((id) => {
activeConversationId = id;
});
// Find the connected conversation
$: {
let foundConnected = false;
for (const [id, conv] of conversations) {
if (conv.connectionStatus === "connected" || conv.connectionStatus === "connecting") {
connectedConversationId = id;
foundConnected = true;
break;
}
}
if (!foundConnected) {
connectedConversationId = null;
}
}
function createNewTab() {
claudeStore.createConversation();
}
@@ -57,7 +43,7 @@
await claudeStore.switchConversation(id);
// Mark messages as seen when switching to this tab
const conv = conversations.get(id);
const conv = $conversations.get(id);
if (conv) {
lastSeenMessageCount.set(id, conv.terminalLines.length);
// Trigger reactivity
@@ -67,11 +53,35 @@
function deleteTab(id: string, event: MouseEvent) {
event.stopPropagation();
if (conversations.size > 1) {
claudeStore.deleteConversation(id);
if ($conversations.size > 1) {
const conversation = $conversations.get(id);
if (conversation && conversation.connectionStatus === "connected") {
// Show confirmation modal for connected tabs
tabToClose = id;
tabToCloseName = conversation.name;
showConfirmModal = true;
} else {
// Close disconnected tabs immediately
claudeStore.deleteConversation(id);
}
}
}
function confirmCloseTab() {
if (tabToClose) {
claudeStore.deleteConversation(tabToClose);
}
showConfirmModal = false;
tabToClose = null;
tabToCloseName = "";
}
function cancelCloseTab() {
showConfirmModal = false;
tabToClose = null;
tabToCloseName = "";
}
function startEditing(id: string, name: string, event: MouseEvent) {
event.stopPropagation();
editingTabId = id;
@@ -105,7 +115,7 @@
}
function hasUnreadMessages(id: string, conversation: Conversation): boolean {
if (id === activeConversationId) return false; // Active tab never has unread
if (id === $activeConversationId) return false; // Active tab never has unread
const lastSeen = lastSeenMessageCount.get(id) || 0;
return conversation.terminalLines.length > lastSeen;
}
@@ -116,6 +126,8 @@
} else if (event.key === "Escape") {
editingTabId = null;
editingName = "";
} else if (event.key === " ") {
event.stopPropagation();
}
}
@@ -137,15 +149,24 @@
// Ctrl/Cmd + W: Close current tab
else if ((event.ctrlKey || event.metaKey) && event.key === "w") {
event.preventDefault();
if (activeConversationId && conversations.size > 1) {
claudeStore.deleteConversation(activeConversationId);
if ($activeConversationId && $conversations.size > 1) {
const conversation = $conversations.get($activeConversationId);
if (conversation && conversation.connectionStatus === "connected") {
// Show confirmation modal for connected tabs
tabToClose = $activeConversationId;
tabToCloseName = conversation.name;
showConfirmModal = true;
} else {
// Close disconnected tabs immediately
claudeStore.deleteConversation($activeConversationId);
}
}
}
// Ctrl/Cmd + Tab: Next tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
event.preventDefault();
const tabs = Array.from(conversations.keys());
const currentIndex = tabs.findIndex((id) => id === activeConversationId);
const tabs = Array.from($conversations.keys());
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
if (currentIndex !== -1) {
const nextIndex = (currentIndex + 1) % tabs.length;
claudeStore.switchConversation(tabs[nextIndex]);
@@ -154,8 +175,8 @@
// Ctrl/Cmd + Shift + Tab: Previous tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
event.preventDefault();
const tabs = Array.from(conversations.keys());
const currentIndex = tabs.findIndex((id) => id === activeConversationId);
const tabs = Array.from($conversations.keys());
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
if (currentIndex !== -1) {
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
claudeStore.switchConversation(tabs[prevIndex]);
@@ -171,17 +192,17 @@
<div
class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
>
{#each Array.from(conversations.entries()) as [id, conversation] (id)}
{#each Array.from($conversations.entries()) as [id, conversation] (id)}
<div
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all
{id === activeConversationId
{id === $activeConversationId
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
onclick={() => switchTab(id)}
onkeydown={(e) => handleTabKeydown(id, e)}
role="tab"
tabindex={0}
aria-selected={id === activeConversationId}
aria-selected={id === $activeConversationId}
>
{#if editingTabId === id}
<input
@@ -196,37 +217,26 @@
<div class="flex items-center gap-2">
<div
class="w-2 h-2 rounded-full {getConnectionStatusColor(conversation.connectionStatus)}"
title="Connection: {conversation.connectionStatus}{id !== connectedConversationId &&
connectedConversationId
? ' (Another tab is connected)'
: ''}"
title="Connection: {conversation.connectionStatus}"
></div>
<span
class="text-sm pr-6 max-w-[150px] truncate"
class="text-sm pr-2 max-w-[150px] truncate"
ondblclick={(e) => startEditing(id, conversation.name, e)}
role="button"
tabindex={-1}
>
{conversation.name}
</span>
{#if id !== activeConversationId && id === connectedConversationId}
<span
class="text-xs text-[var(--text-tertiary)]"
title="This tab has the Claude connection"
>
(connected)
</span>
{/if}
{#if hasUnreadMessages(id, conversation)}
<div
class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse"
class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse pointer-events-none"
title="New messages"
></div>
{/if}
</div>
{/if}
{#if conversations.size > 1}
{#if $conversations.size > 1}
<button
onclick={(e) => deleteTab(id, e)}
class="absolute right-1 top-1/2 -translate-y-1/2 w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)] opacity-0 group-hover:opacity-100 transition-opacity"
@@ -268,6 +278,13 @@
</button>
</div>
<CloseTabConfirmModal
isOpen={showConfirmModal}
tabName={tabToCloseName}
onConfirm={confirmCloseTab}
onCancel={cancelCloseTab}
/>
<style>
.terminal-tabs {
min-height: 36px;
+8 -13
View File
@@ -43,14 +43,7 @@
"🔒 Grant tool permissions as needed for security",
"📌 Pin important conversations for quick access",
"🎨 Customize your theme and preferences in Settings",
],
},
{
title: "Keyboard Shortcuts",
items: [
"Ctrl/Cmd + Enter: Send message",
"Ctrl/Cmd + K: Clear chat (when supported)",
"Escape: Close modals and panels",
"⌨️ Check the keyboard icon for available shortcuts",
],
},
];
@@ -72,10 +65,12 @@
tabindex="-1"
>
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<h2 id="help-title" class="text-xl font-semibold text-gray-100">How to Use Hikari Desktop</h2>
<h2 id="help-title" class="text-xl font-semibold text-[var(--text-primary)]">
How to Use Hikari Desktop
</h2>
<button
onclick={onClose}
class="p-1 text-gray-500 hover:text-gray-300 transition-colors"
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -92,8 +87,8 @@
<div class="overflow-y-auto flex-1 p-6 space-y-6">
{#each sections as section (section.title)}
<div>
<h3 class="font-medium text-gray-200 mb-3">{section.title}</h3>
<ul class="space-y-2 text-sm text-gray-400">
<h3 class="font-medium text-[var(--text-primary)] mb-3">{section.title}</h3>
<ul class="space-y-2 text-sm text-[var(--text-secondary)]">
{#each section.items as item (item)}
<li class="flex items-start">
<span class="text-[var(--accent-primary)] mr-2 mt-0.5"></span>
@@ -105,7 +100,7 @@
{/each}
<div class="pt-4 border-t border-[var(--border-color)]">
<p class="text-sm text-gray-500">
<p class="text-sm text-[var(--text-tertiary)]">
<strong>Need more help?</strong> Join our Discord community for support and updates!
</p>
</div>
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
export let content: string;
export let searchQuery: string;
interface TextPart {
text: string;
isMatch: boolean;
}
function getHighlightedParts(text: string, query: string): TextPart[] {
if (!query) {
return [{ text, isMatch: false }];
}
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedQuery})`, "gi");
const parts: TextPart[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
// Add non-matching text before the match
if (match.index > lastIndex) {
parts.push({
text: text.slice(lastIndex, match.index),
isMatch: false,
});
}
// Add the matching text
parts.push({
text: match[1],
isMatch: true,
});
lastIndex = regex.lastIndex;
}
// Add any remaining text after the last match
if (lastIndex < text.length) {
parts.push({
text: text.slice(lastIndex),
isMatch: false,
});
}
return parts;
}
$: parts = getHighlightedParts(content, searchQuery);
</script>
<span class="whitespace-pre-wrap">
{#each parts as part, index (index)}
{#if part.isMatch}
<mark class="search-highlight">{part.text}</mark>
{:else}
{part.text}
{/if}
{/each}
</span>
+722 -37
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { readImage } from "@tauri-apps/plugin-clipboard-manager";
import { get } from "svelte/store";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
@@ -13,13 +15,100 @@
clearHistoryRestore,
} from "$lib/stores/historyRestore";
import MessageModeSelector from "$lib/components/MessageModeSelector.svelte";
import SlashCommandMenu from "$lib/components/SlashCommandMenu.svelte";
import { getCurrentMode } from "$lib/stores/messageMode";
import { formatMessageWithMode } from "$lib/types/messageMode";
import {
parseSlashCommand,
getMatchingCommands,
isSlashCommand,
type SlashCommand,
} from "$lib/commands/slashCommands";
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
import type { Attachment } from "$lib/types/messages";
const INPUT_HISTORY_KEY = "hikari-input-history";
const MAX_HISTORY_SIZE = 100;
let inputValue = $state("");
let isSubmitting = $state(false);
let isConnected = $state(false);
let isProcessing = $state(false);
let showCommandMenu = $state(false);
let matchingCommands = $state<SlashCommand[]>([]);
let selectedCommandIndex = $state(0);
let attachments = $state<Attachment[]>([]);
let isDragging = $state(false);
// Input history state
let inputHistory = $state<string[]>([]);
let historyIndex = $state(-1);
let tempInput = $state("");
let userHasTyped = $state(false); // Track if user manually typed (vs history navigation)
// Textarea resize state
let textareaHeight = $state(48);
const MIN_HEIGHT = 48;
const MAX_HEIGHT = 200;
let isResizing = $state(false);
let startY = 0;
let startHeight = 0;
function handleResizeStart(event: MouseEvent) {
isResizing = true;
startY = event.clientY;
startHeight = textareaHeight;
document.addEventListener("mousemove", handleResizeMove);
document.addEventListener("mouseup", handleResizeEnd);
event.preventDefault();
}
function handleResizeMove(event: MouseEvent) {
if (!isResizing) return;
// Dragging up (negative deltaY) should increase height
const deltaY = startY - event.clientY;
const newHeight = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, startHeight + deltaY));
textareaHeight = newHeight;
}
function handleResizeEnd() {
isResizing = false;
document.removeEventListener("mousemove", handleResizeMove);
document.removeEventListener("mouseup", handleResizeEnd);
}
// Load history from localStorage on init
function loadHistory(): string[] {
try {
const stored = localStorage.getItem(INPUT_HISTORY_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
function saveHistory(history: string[]) {
try {
localStorage.setItem(INPUT_HISTORY_KEY, JSON.stringify(history));
} catch {
// Ignore storage errors
}
}
function addToHistory(input: string) {
const trimmed = input.trim();
if (!trimmed) return;
// Don't add duplicates of the most recent entry
if (inputHistory.length > 0 && inputHistory[0] === trimmed) return;
// Add to front of history
inputHistory = [trimmed, ...inputHistory.slice(0, MAX_HISTORY_SIZE - 1)];
saveHistory(inputHistory);
}
// Initialize history on mount
inputHistory = loadHistory();
claudeStore.connectionStatus.subscribe((status) => {
isConnected = status === "connected";
@@ -29,21 +118,117 @@
isProcessing = processing;
});
claudeStore.attachments.subscribe((storedAttachments) => {
attachments = storedAttachments;
});
function handleInputChange() {
// If input is empty, allow history navigation again
// Otherwise, mark that user has manually typed
if (inputValue === "") {
userHasTyped = false;
} else {
userHasTyped = true;
}
// Reset history navigation when user types
historyIndex = -1;
tempInput = "";
if (isSlashCommand(inputValue)) {
matchingCommands = getMatchingCommands(inputValue);
showCommandMenu = matchingCommands.length > 0;
selectedCommandIndex = 0;
} else {
showCommandMenu = false;
matchingCommands = [];
}
}
function selectCommand(command: SlashCommand) {
inputValue = `/${command.name} `;
showCommandMenu = false;
matchingCommands = [];
}
async function executeSlashCommand(): Promise<boolean> {
const { command, args } = parseSlashCommand(inputValue);
if (command) {
inputValue = "";
showCommandMenu = false;
matchingCommands = [];
await command.execute(args);
return true;
}
return false;
}
async function handleSubmit(event: Event) {
event.preventDefault();
const message = inputValue.trim();
if (!message || isSubmitting || !isConnected) return;
const hasAttachments = attachments.length > 0;
// Need either a message or attachments to submit
if ((!message && !hasAttachments) || isSubmitting) return;
// Check for slash commands first (these work even when disconnected)
if (message && isSlashCommand(message)) {
// Add slash commands to history too
addToHistory(message);
historyIndex = -1;
tempInput = "";
userHasTyped = false;
const wasCommand = await executeSlashCommand();
if (wasCommand) return;
// If it started with / but wasn't a valid command, show error
claudeStore.addLine(
"error",
`Unknown command: ${message.split(" ")[0]}. Type /help for available commands.`
);
inputValue = "";
return;
}
// Regular messages require connection
if (!isConnected) return;
// Add to history before clearing (only if there's text)
if (message) {
addToHistory(message);
}
historyIndex = -1;
tempInput = "";
userHasTyped = false;
isSubmitting = true;
inputValue = "";
// Apply mode prefix if needed
// Capture attachments before clearing
const currentAttachments = [...attachments];
// Apply mode prefix if needed (only if there's a message)
const currentMode = getCurrentMode();
const formattedMessage = formatMessageWithMode(message, currentMode);
const formattedMessage = message ? formatMessageWithMode(message, currentMode) : "";
// Build message with attachments
let messageWithAttachments = formattedMessage;
if (currentAttachments.length > 0) {
const attachmentPaths = currentAttachments.map((a) => a.path).join("\n");
const attachmentPrefix = formattedMessage ? `${formattedMessage}\n\n` : "";
messageWithAttachments = `${attachmentPrefix}[Attached files - please read these files to see their contents:]\n${attachmentPaths}`;
// Log attached files to the output
for (const attachment of currentAttachments) {
claudeStore.addLine("system", `📎 Attached: ${attachment.filename} (${attachment.path})`);
}
}
// Clear attachments after capturing them
claudeStore.clearAttachments();
// Check if we need to restore conversation history
let messageToSend = formattedMessage;
let messageToSend = messageWithAttachments;
if (getShouldRestoreHistory()) {
const savedHistory = getSavedHistory();
@@ -138,61 +323,449 @@ User: ${formattedMessage}`;
}
}
function handleRemoveAttachment(id: string) {
claudeStore.removeAttachment(id);
}
function getFileTypeFromExtension(extension: string): "image" | "document" | "other" {
const imageExtensions = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"];
const documentExtensions = ["pdf", "txt", "md", "doc", "docx", "csv", "json", "xml"];
if (imageExtensions.includes(extension)) {
return "image";
} else if (documentExtensions.includes(extension)) {
return "document";
}
return "other";
}
function handleDragEnter(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer?.types.includes("Files")) {
isDragging = true;
}
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "copy";
}
}
function handleDragLeave(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
// Only set isDragging to false if we're leaving the form element entirely
const relatedTarget = event.relatedTarget as Node | null;
const currentTarget = event.currentTarget as HTMLElement;
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
isDragging = false;
}
}
async function handleDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
isDragging = false;
const files = event.dataTransfer?.files;
if (!files || files.length === 0) return;
const conversationId = get(claudeStore.activeConversationId);
for (const file of files) {
const filename = file.name;
const extension = filename.split(".").pop()?.toLowerCase() || "";
const fileType = getFileTypeFromExtension(extension);
// Create attachment from dropped file
// Note: For dropped files, we create a preview URL for images
let previewUrl: string | undefined;
if (fileType === "image") {
previewUrl = URL.createObjectURL(file);
}
// Check if the file has a native path (from Tauri's file drop)
// If not, we need to save it to a temp file
let savedPath = (file as File & { path?: string }).path;
if (!savedPath && conversationId) {
try {
const arrayBuffer = await file.arrayBuffer();
const bytes = Array.from(new Uint8Array(arrayBuffer));
savedPath = await invoke<string>("save_temp_file", {
conversationId,
filename,
data: bytes,
});
} catch (error) {
console.error("Failed to save dropped file to temp:", error);
savedPath = file.name;
}
}
const attachment: Attachment = {
id: `attachment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
filename,
path: savedPath || file.name,
size: file.size,
type: fileType,
mimeType: file.type,
previewUrl,
};
claudeStore.addAttachment(attachment);
}
}
async function handleFilePicker() {
try {
const selected = await open({
multiple: true,
filters: [
{
name: "All Files",
extensions: ["*"],
},
{
name: "Images",
extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"],
},
{
name: "Documents",
extensions: ["pdf", "txt", "md", "doc", "docx", "csv", "json", "xml"],
},
{
name: "Code",
extensions: [
"js",
"ts",
"jsx",
"tsx",
"py",
"rs",
"go",
"java",
"c",
"cpp",
"h",
"hpp",
"css",
"html",
"svelte",
"vue",
],
},
],
});
if (!selected) return;
// Handle both single and multiple file selection
const files = Array.isArray(selected) ? selected : [selected];
for (const filePath of files) {
const filename = filePath.split(/[/\\]/).pop() || "unknown";
const extension = filename.split(".").pop()?.toLowerCase() || "";
const fileType = getFileTypeFromExtension(extension);
// Get file size from Tauri
let fileSize = 0;
try {
fileSize = await invoke<number>("get_file_size", { filePath });
} catch (e) {
console.warn("Could not get file size:", e);
}
const attachment: Attachment = {
id: `attachment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
filename,
path: filePath,
size: fileSize,
type: fileType,
};
claudeStore.addAttachment(attachment);
}
} catch (error) {
console.error("Failed to open file picker:", error);
}
}
async function handlePaste(event: ClipboardEvent) {
// First, try the web clipboard API for files
const items = event.clipboardData?.items;
let handledFile = false;
if (items && items.length > 0) {
for (const item of items) {
if (item.kind === "file") {
const file = item.getAsFile();
if (!file) continue;
event.preventDefault();
handledFile = true;
const filename = file.name || `pasted-${Date.now()}.${file.type.split("/")[1] || "png"}`;
const extension = filename.split(".").pop()?.toLowerCase() || "";
const fileType = getFileTypeFromExtension(extension);
let previewUrl: string | undefined;
if (fileType === "image" || file.type.startsWith("image/")) {
previewUrl = URL.createObjectURL(file);
}
const conversationId = get(claudeStore.activeConversationId);
let savedPath = filename;
if (conversationId) {
try {
const arrayBuffer = await file.arrayBuffer();
const bytes = Array.from(new Uint8Array(arrayBuffer));
savedPath = await invoke<string>("save_temp_file", {
conversationId,
filename,
data: bytes,
});
} catch (error) {
console.error("Failed to save pasted file to temp:", error);
}
}
const attachment: Attachment = {
id: `attachment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
filename,
path: savedPath,
size: file.size,
type: file.type.startsWith("image/") ? "image" : fileType,
mimeType: file.type,
previewUrl,
};
claudeStore.addAttachment(attachment);
}
}
}
// If web clipboard didn't have files, try Tauri's native clipboard for images
if (!handledFile) {
try {
const image = await readImage();
const rgba = await image.rgba();
const size = await image.size();
if (rgba && rgba.length > 0) {
event.preventDefault();
const conversationId = get(claudeStore.activeConversationId);
const filename = `screenshot-${Date.now()}.png`;
// Convert RGBA to PNG using canvas
const canvas = document.createElement("canvas");
canvas.width = size.width;
canvas.height = size.height;
const ctx = canvas.getContext("2d");
if (ctx) {
const imgData = ctx.createImageData(size.width, size.height);
imgData.data.set(new Uint8ClampedArray(rgba));
ctx.putImageData(imgData, 0, 0);
// Create preview URL from canvas
const previewUrl = canvas.toDataURL("image/png");
// Convert to blob for saving
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/png")
);
let savedPath = filename;
if (blob && conversationId) {
try {
const arrayBuffer = await blob.arrayBuffer();
const bytes = Array.from(new Uint8Array(arrayBuffer));
savedPath = await invoke<string>("save_temp_file", {
conversationId,
filename,
data: bytes,
});
} catch (error) {
console.error("Failed to save clipboard image to temp:", error);
}
}
const attachment: Attachment = {
id: `attachment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
filename,
path: savedPath,
size: blob?.size || 0,
type: "image",
mimeType: "image/png",
previewUrl,
};
claudeStore.addAttachment(attachment);
}
}
} catch (error) {
// No image in clipboard or clipboard read failed - that's fine, just ignore
console.log("No image in native clipboard:", error);
}
}
}
function handleKeyDown(event: KeyboardEvent) {
// Handle command menu navigation
if (showCommandMenu && matchingCommands.length > 0) {
if (event.key === "ArrowDown") {
event.preventDefault();
selectedCommandIndex = (selectedCommandIndex + 1) % matchingCommands.length;
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
selectedCommandIndex =
(selectedCommandIndex - 1 + matchingCommands.length) % matchingCommands.length;
return;
}
if (event.key === "Tab") {
event.preventDefault();
const selected = matchingCommands[selectedCommandIndex];
if (selected) {
selectCommand(selected);
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
showCommandMenu = false;
return;
}
}
// Handle input history navigation (when command menu is closed AND user hasn't typed)
// If user has typed something, let arrow keys navigate the cursor instead
if (event.key === "ArrowUp" && inputHistory.length > 0 && !userHasTyped) {
event.preventDefault();
if (historyIndex === -1) {
// Save current input before navigating history
tempInput = inputValue;
}
if (historyIndex < inputHistory.length - 1) {
historyIndex++;
inputValue = inputHistory[historyIndex];
}
return;
}
if (event.key === "ArrowDown" && historyIndex >= 0 && !userHasTyped) {
event.preventDefault();
historyIndex--;
if (historyIndex === -1) {
// Restore the temp input when going back to current
inputValue = tempInput;
userHasTyped = false; // Reset since we're back to empty/temp state
} else {
inputValue = inputHistory[historyIndex];
}
return;
}
if (event.key === "Enter" && !event.shiftKey) {
handleSubmit(event);
}
}
</script>
<form onsubmit={handleSubmit} class="input-bar">
<form
onsubmit={handleSubmit}
ondragenter={handleDragEnter}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
class="input-bar"
class:is-dragging={isDragging}
>
<AttachmentPreview {attachments} onRemove={handleRemoveAttachment} />
<div class="input-controls flex gap-2 mb-2">
<MessageModeSelector />
</div>
<div class="input-row flex gap-3 items-end">
<div class="flex-1 relative">
<div class="input-row">
<div class="textarea-wrapper">
<SlashCommandMenu
commands={matchingCommands}
selectedIndex={selectedCommandIndex}
onSelect={selectCommand}
/>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="resize-handle" onmousedown={handleResizeStart} title="Drag to resize"></div>
<textarea
bind:value={inputValue}
onkeydown={handleKeyDown}
placeholder={isConnected ? "Ask Hikari anything..." : "Connect to Claude first..."}
disabled={!isConnected || isSubmitting}
oninput={handleInputChange}
onpaste={handlePaste}
placeholder={isConnected
? "Ask Hikari anything... (type / for commands)"
: "Connect to Claude first..."}
disabled={isSubmitting}
rows={1}
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px);"
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200"
disabled:opacity-50 disabled:cursor-not-allowed"
></textarea>
</div>
{#if isProcessing}
<button
type="button"
onclick={handleInterrupt}
class="px-6 py-3 bg-red-600 hover:bg-red-700
text-white font-medium rounded-lg
transition-all duration-200 transform hover:scale-105 active:scale-95"
title="Interrupt the current response (Ctrl+C)"
>
<span class="font-bold"></span> Stop
<div class="button-wrapper">
<button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"
/>
</svg>
</button>
{:else}
<button
type="submit"
disabled={!isConnected || isSubmitting || !inputValue.trim()}
class="px-6 py-3 bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)]
text-white font-medium rounded-lg
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200 transform hover:scale-105 active:scale-95"
>
{#if isSubmitting}
<span class="inline-block animate-spin"></span>
{:else}
Send
{/if}
</button>
{/if}
{#if isProcessing}
<button
type="button"
onclick={handleInterrupt}
class="send-button bg-red-600 hover:bg-red-700"
title="Interrupt the current response (Ctrl+C)"
>
<span class="font-bold"></span> Stop
</button>
{:else}
<button
type="submit"
disabled={!isConnected ||
isSubmitting ||
(!inputValue.trim() && attachments.length === 0)}
class="send-button bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)]
disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if isSubmitting}
<span class="inline-block animate-spin"></span>
{:else}
Send
{/if}
</button>
{/if}
</div>
</div>
</form>
@@ -201,6 +774,35 @@ User: ${formattedMessage}`;
display: flex;
flex-direction: column;
gap: 8px;
position: relative;
transition:
border-color 0.2s,
background 0.2s;
}
.input-bar.is-dragging {
background: var(--bg-secondary);
border: 2px dashed var(--accent-primary);
border-radius: 12px;
padding: 8px;
}
.input-bar.is-dragging::before {
content: "Drop files here";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 18px;
font-weight: 500;
color: var(--accent-primary);
pointer-events: none;
z-index: 10;
}
.input-bar.is-dragging > * {
opacity: 0.3;
pointer-events: none;
}
.input-controls {
@@ -214,4 +816,87 @@ User: ${formattedMessage}`;
gap: 12px;
align-items: flex-end;
}
.textarea-wrapper {
flex: 1;
position: relative;
display: flex;
flex-direction: column;
}
.resize-handle {
height: 6px;
cursor: ns-resize;
background: transparent;
border-radius: 3px;
margin-bottom: 2px;
display: flex;
justify-content: center;
align-items: center;
}
.resize-handle::before {
content: "";
width: 40px;
height: 3px;
background: var(--border-color);
border-radius: 2px;
opacity: 0.5;
transition: opacity 0.2s;
}
.resize-handle:hover::before {
opacity: 1;
background: var(--accent-primary);
}
.button-wrapper {
display: flex;
align-items: flex-end;
height: 100%;
}
.attach-button {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
padding: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.attach-button:hover {
background: var(--bg-tertiary);
border-color: var(--accent-primary);
color: var(--accent-primary);
transform: scale(1.05);
}
.attach-button:active {
transform: scale(0.95);
}
.send-button {
padding: 0 24px;
height: 48px;
color: white;
font-weight: 500;
border-radius: 8px;
transition: all 0.2s;
white-space: nowrap;
}
.send-button:hover:not(:disabled) {
transform: scale(1.05);
}
.send-button:active:not(:disabled) {
transform: scale(0.95);
}
</style>
@@ -0,0 +1,177 @@
<script lang="ts">
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
const shortcuts = [
{
category: "General",
items: [
{ keys: ["Escape"], description: "Close modals and panels" },
{ keys: ["Ctrl", "L"], description: "Clear the terminal" },
{ keys: ["Ctrl", ","], description: "Open settings" },
],
},
{
category: "Chat",
items: [
{ keys: ["Enter"], description: "Send message" },
{ keys: ["Shift", "Enter"], description: "New line in message" },
{ keys: ["Ctrl", "C"], description: "Interrupt/stop response" },
{ keys: ["↑"], description: "Previous input from history" },
{ keys: ["↓"], description: "Next input from history" },
],
},
{
category: "Slash Commands",
items: [
{ keys: ["↑", "↓"], description: "Navigate command menu" },
{ keys: ["Tab"], description: "Complete selected command" },
{ keys: ["Escape"], description: "Close command menu" },
],
},
{
category: "Permission Prompts",
items: [
{ keys: ["Enter"], description: "Allow & reconnect" },
{ keys: ["Escape"], description: "Dismiss" },
],
},
];
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] overflow-hidden flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="shortcuts-title"
tabindex="-1"
>
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-lg bg-[var(--accent-primary)]/20 flex items-center justify-center"
>
<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="M12 3C10.22 3 8.47 3.23 6.86 3.68A2 2 0 005 5.57V18.43a2 2 0 001.86 1.89C8.47 20.77 10.22 21 12 21s3.53-.23 5.14-.68A2 2 0 0019 18.43V5.57a2 2 0 00-1.86-1.89C15.53 3.23 13.78 3 12 3z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7h.01M12 7h.01M16 7h.01M8 11h.01M12 11h.01M16 11h.01M8 15h8"
/>
</svg>
</div>
<h2 id="shortcuts-title" class="text-xl font-semibold text-[var(--text-primary)]">
Keyboard Shortcuts
</h2>
</div>
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="overflow-y-auto flex-1 p-6 space-y-6">
{#each shortcuts as section (section.category)}
<div>
<h3
class="text-sm font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-3"
>
{section.category}
</h3>
<div class="space-y-2">
{#each section.items as item (item.description)}
<div
class="flex items-center justify-between py-2 px-3 bg-[var(--bg-secondary)] rounded-lg"
>
<span class="text-sm text-[var(--text-primary)]">{item.description}</span>
<div class="flex items-center gap-1">
{#each item.keys as key, i (key)}
{#if i > 0}
<span class="text-[var(--text-secondary)] text-xs">+</span>
{/if}
<kbd
class="px-2 py-1 text-xs font-mono bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] shadow-sm min-w-[24px] text-center"
>
{key}
</kbd>
{/each}
</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
<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);
}
}
.overflow-y-auto {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.overflow-y-auto::-webkit-scrollbar {
width: 8px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 4px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background-color: var(--accent-primary);
}
</style>
+352
View File
@@ -0,0 +1,352 @@
<script lang="ts">
import { marked } from "marked";
import hljs from "highlight.js";
import { onMount } from "svelte";
import { openUrl } from "@tauri-apps/plugin-opener";
interface Props {
content: string;
searchQuery?: string;
}
let { content, searchQuery = "" }: Props = $props();
let containerElement: HTMLDivElement;
const renderer = new marked.Renderer();
renderer.code = ({ text, lang }) => {
const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
const highlighted = hljs.highlight(text, { language }).value;
return `<pre class="hljs-code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`;
};
renderer.codespan = ({ text }) => {
return `<code class="hljs-inline">${text}</code>`;
};
marked.setOptions({
renderer,
gfm: true,
breaks: true,
});
function processSpoilers(html: string): string {
const codeBlockPlaceholders: string[] = [];
// Temporarily replace code blocks and inline code with placeholders
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
codeBlockPlaceholders.push(match);
return `__CODE_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
});
// Apply spoiler transformation only to non-code content
processed = processed.replace(
/\|\|(.+?)\|\|/g,
'<span class="spoiler" role="button" tabindex="0">$1</span>'
);
// Restore code blocks
processed = processed.replace(/__CODE_PLACEHOLDER_(\d+)__/g, (_, index) => {
return codeBlockPlaceholders[parseInt(index)];
});
return processed;
}
function highlightSearchMatches(html: string, query: string): string {
if (!query) return html;
const codeBlockPlaceholders: string[] = [];
const tagPlaceholders: string[] = [];
// Temporarily replace code blocks with placeholders (don't highlight in code)
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
codeBlockPlaceholders.push(match);
return `__CODE_SEARCH_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
});
// Temporarily replace all HTML tags with placeholders
processed = processed.replace(/<[^>]+>/g, (match) => {
tagPlaceholders.push(match);
return `__TAG_PLACEHOLDER_${tagPlaceholders.length - 1}__`;
});
// Apply search highlighting to text content
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedQuery})`, "gi");
processed = processed.replace(regex, '<mark class="search-highlight">$1</mark>');
// Restore HTML tags
processed = processed.replace(/__TAG_PLACEHOLDER_(\d+)__/g, (_, index) => {
return tagPlaceholders[parseInt(index)];
});
// Restore code blocks
processed = processed.replace(/__CODE_SEARCH_PLACEHOLDER_(\d+)__/g, (_, index) => {
return codeBlockPlaceholders[parseInt(index)];
});
return processed;
}
function renderMarkdown(text: string): string {
try {
const html = marked.parse(text) as string;
const withSpoilers = processSpoilers(html);
return highlightSearchMatches(withSpoilers, searchQuery);
} catch {
return text;
}
}
function handleSpoilerClick(event: Event) {
const target = event.target as HTMLElement;
if (target.classList.contains("spoiler")) {
target.classList.toggle("revealed");
}
}
function handleSpoilerKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target.classList.contains("spoiler") && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
target.classList.toggle("revealed");
}
}
function handleLinkClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const anchor = target.closest("a");
if (anchor?.href) {
event.preventDefault();
openUrl(anchor.href);
}
}
onMount(() => {
if (containerElement) {
containerElement.querySelectorAll("pre code:not(.hljs)").forEach((block) => {
hljs.highlightElement(block as HTMLElement);
});
}
});
</script>
<div
bind:this={containerElement}
class="markdown-content"
onclick={(e) => {
handleSpoilerClick(e);
handleLinkClick(e);
}}
onkeydown={handleSpoilerKeydown}
role="presentation"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown rendering requires @html; content is from Claude API -->
{@html renderMarkdown(content)}
</div>
<style>
.markdown-content {
line-height: 1.6;
}
.markdown-content :global(p) {
margin: 0.5em 0;
}
.markdown-content :global(p:first-child) {
margin-top: 0;
}
.markdown-content :global(p:last-child) {
margin-bottom: 0;
}
.markdown-content :global(.hljs-code-block) {
background: var(--bg-code, #1e1e2e);
border-radius: 6px;
padding: 1em;
margin: 0.75em 0;
overflow-x: auto;
border: 1px solid var(--border-color);
}
.markdown-content :global(.hljs-code-block code) {
background: transparent;
padding: 0;
font-size: 0.9em;
font-family: "JetBrains Mono", "Fira Code", monospace;
}
.markdown-content :global(.hljs-inline) {
background: var(--bg-code, #1e1e2e);
padding: 0.2em 0.4em;
border-radius: 4px;
font-size: 0.9em;
font-family: "JetBrains Mono", "Fira Code", monospace;
}
.markdown-content :global(ul),
.markdown-content :global(ol) {
margin: 0.5em 0;
padding-left: 1.5em;
}
.markdown-content :global(li) {
margin: 0.25em 0;
}
.markdown-content :global(blockquote) {
border-left: 3px solid var(--border-color);
margin: 0.75em 0;
padding-left: 1em;
color: var(--text-secondary);
}
.markdown-content :global(a) {
color: var(--accent-primary, #f472b6);
text-decoration: underline;
}
.markdown-content :global(a:hover) {
color: var(--accent-secondary, #e879f9);
}
.markdown-content :global(strong) {
font-weight: 600;
}
.markdown-content :global(h1),
.markdown-content :global(h2),
.markdown-content :global(h3),
.markdown-content :global(h4) {
margin: 1em 0 0.5em 0;
font-weight: 600;
}
.markdown-content :global(h1:first-child),
.markdown-content :global(h2:first-child),
.markdown-content :global(h3:first-child),
.markdown-content :global(h4:first-child) {
margin-top: 0;
}
.markdown-content :global(hr) {
border: none;
border-top: 1px solid var(--border-color);
margin: 1em 0;
}
.markdown-content :global(table) {
border-collapse: collapse;
margin: 0.75em 0;
width: 100%;
}
.markdown-content :global(th),
.markdown-content :global(td) {
border: 1px solid var(--border-color);
padding: 0.5em;
text-align: left;
}
.markdown-content :global(th) {
background: var(--bg-secondary);
font-weight: 600;
}
/* Highlight.js theme colors - using CSS variables for light/dark mode support */
.markdown-content :global(.hljs) {
color: var(--text-primary);
}
.markdown-content :global(.hljs-keyword),
.markdown-content :global(.hljs-selector-tag),
.markdown-content :global(.hljs-built_in),
.markdown-content :global(.hljs-name) {
color: var(--hljs-keyword);
}
.markdown-content :global(.hljs-string),
.markdown-content :global(.hljs-attr),
.markdown-content :global(.hljs-symbol),
.markdown-content :global(.hljs-bullet) {
color: var(--hljs-string);
}
.markdown-content :global(.hljs-number),
.markdown-content :global(.hljs-literal) {
color: var(--hljs-number);
}
.markdown-content :global(.hljs-comment),
.markdown-content :global(.hljs-quote) {
color: var(--hljs-comment);
font-style: italic;
}
.markdown-content :global(.hljs-function),
.markdown-content :global(.hljs-title) {
color: var(--hljs-function);
}
.markdown-content :global(.hljs-type),
.markdown-content :global(.hljs-class) {
color: var(--hljs-type);
}
.markdown-content :global(.hljs-variable),
.markdown-content :global(.hljs-template-variable) {
color: var(--hljs-variable);
}
.markdown-content :global(.hljs-meta) {
color: var(--hljs-meta);
}
.markdown-content :global(.hljs-tag) {
color: var(--hljs-keyword);
}
.markdown-content :global(.hljs-attribute) {
color: var(--hljs-function);
}
.markdown-content :global(.hljs-params) {
color: var(--text-primary);
}
/* Spoiler tag styles */
.markdown-content :global(.spoiler) {
background: var(--text-primary);
color: transparent;
border-radius: 4px;
padding: 0 0.25em;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.markdown-content :global(.spoiler:hover) {
opacity: 0.8;
}
.markdown-content :global(.spoiler:focus) {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.markdown-content :global(.spoiler.revealed) {
background: var(--bg-hover);
color: var(--text-primary);
user-select: text;
}
.markdown-content :global(.search-highlight) {
background-color: var(--search-highlight, #fbbf24);
color: var(--search-highlight-text, #000);
border-radius: 2px;
padding: 0 2px;
}
</style>
+22 -8
View File
@@ -109,8 +109,22 @@ Please continue where we left off and retry that action now that you have permis
function isToolAlreadyGranted(toolName: string): boolean {
return grantedToolsList.includes(toolName);
}
function handleKeydown(event: KeyboardEvent) {
if (!isVisible || !permission) return;
if (event.key === "Enter") {
event.preventDefault();
handleApproveAndReconnect();
} else if (event.key === "Escape") {
event.preventDefault();
handleDismiss();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isVisible && permission}
<div
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
@@ -123,8 +137,8 @@ Please continue where we left off and retry that action now that you have permis
<span class="text-xl">🔐</span>
</div>
<div>
<h2 class="text-lg font-semibold text-white">Permission Blocked</h2>
<p class="text-sm text-gray-400">Hikari tried to use a restricted tool</p>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Permission Blocked</h2>
<p class="text-sm text-[var(--text-secondary)]">Hikari tried to use a restricted tool</p>
</div>
</div>
@@ -135,7 +149,7 @@ Please continue where we left off and retry that action now that you have permis
</div>
<div class="mb-4">
<div class="text-sm text-gray-400 mb-1">Tool</div>
<div class="text-sm text-[var(--text-secondary)] mb-1">Tool</div>
<div
class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--accent-primary)] font-mono flex items-center justify-between"
>
@@ -149,17 +163,17 @@ Please continue where we left off and retry that action now that you have permis
</div>
<div class="mb-4">
<div class="text-sm text-gray-400 mb-1">Description</div>
<div class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-gray-300">
<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-gray-400 mb-1">Details</div>
<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-gray-300 text-xs overflow-x-auto max-h-32">{formatInput(
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>
@@ -168,7 +182,7 @@ Please continue where we left off and retry that action now that you have permis
<div class="flex gap-3">
<button
onclick={handleDismiss}
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-gray-400 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
</button>
@@ -0,0 +1,86 @@
<script lang="ts">
import type { SlashCommand } from "$lib/commands/slashCommands";
interface Props {
commands: SlashCommand[];
selectedIndex: number;
onSelect: (command: SlashCommand) => void;
}
let { commands, selectedIndex, onSelect }: Props = $props();
</script>
{#if commands.length > 0}
<div class="slash-command-menu">
<div class="menu-header">Commands</div>
{#each commands as command, index (command.name)}
<button
type="button"
class="menu-item"
class:selected={index === selectedIndex}
onclick={() => onSelect(command)}
onmouseenter={() => (selectedIndex = index)}
>
<span class="command-name">/{command.name}</span>
<span class="command-description">{command.description}</span>
</button>
{/each}
</div>
{/if}
<style>
.slash-command-menu {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
margin-bottom: 8px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.2);
z-index: 100;
}
.menu-header {
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-tertiary);
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
}
.menu-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 12px;
text-align: left;
background: transparent;
border: none;
cursor: pointer;
transition: background-color 0.15s ease;
}
.menu-item:hover,
.menu-item.selected {
background: var(--bg-tertiary);
}
.command-name {
font-family: monospace;
font-weight: 600;
color: var(--accent-primary);
min-width: 80px;
}
.command-description {
color: var(--text-secondary);
font-size: 13px;
}
</style>
+31
View File
@@ -17,6 +17,7 @@
import StatsDisplay from "./StatsDisplay.svelte";
import AboutPanel from "./AboutPanel.svelte";
import HelpPanel from "./HelpPanel.svelte";
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
import { achievementProgress } from "$lib/stores/achievements";
const DISCORD_URL = "https://chat.nhcarrigan.com";
@@ -31,6 +32,7 @@
let showStats = $state(false);
let showAbout = $state(false);
let showHelp = $state(false);
let showKeyboardShortcuts = $state(false);
const progress = $derived($achievementProgress);
let currentConfig: HikariConfig = $state({
model: null,
@@ -43,6 +45,11 @@
greeting_custom_prompt: null,
notifications_enabled: true,
notification_volume: 0.5,
always_on_top: false,
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
minimize_to_tray: false,
});
onMount(async () => {
@@ -271,6 +278,26 @@
/>
</svg>
</button>
<button
onclick={() => (showKeyboardShortcuts = true)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
title="Keyboard Shortcuts"
>
<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="M12 3C10.22 3 8.47 3.23 6.86 3.68A2 2 0 005 5.57V18.43a2 2 0 001.86 1.89C8.47 20.77 10.22 21 12 21s3.53-.23 5.14-.68A2 2 0 0019 18.43V5.57a2 2 0 00-1.86-1.89C15.53 3.23 13.78 3 12 3z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7h.01M12 7h.01M16 7h.01M8 11h.01M12 11h.01M16 11h.01M8 15h8"
/>
</svg>
</button>
<button
onclick={() => (showHelp = true)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
@@ -340,3 +367,7 @@
{#if showHelp}
<HelpPanel onClose={() => (showHelp = false)} />
{/if}
{#if showKeyboardShortcuts}
<KeyboardShortcutsModal onClose={() => (showKeyboardShortcuts = false)} />
{/if}
+76 -5
View File
@@ -1,24 +1,66 @@
<script lang="ts">
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
import { afterUpdate } from "svelte";
import { afterUpdate, tick } from "svelte";
import ConversationTabs from "./ConversationTabs.svelte";
import Markdown from "./Markdown.svelte";
import HighlightedText from "./HighlightedText.svelte";
import { searchState, searchQuery } from "$lib/stores/search";
let terminalElement: HTMLDivElement;
let shouldAutoScroll = true;
let lines: TerminalLine[] = [];
let currentSearchQuery = "";
let currentConversationId: string | null = null;
let isRestoringScroll = false;
searchQuery.subscribe((value) => {
currentSearchQuery = value;
});
claudeStore.terminalLines.subscribe((value) => {
lines = value;
});
claudeStore.activeConversationId.subscribe(async (newId) => {
if (!newId) return;
// Save current conversation's scroll position before switching
if (currentConversationId && currentConversationId !== newId && terminalElement) {
const position = shouldAutoScroll ? -1 : terminalElement.scrollTop;
claudeStore.saveScrollPosition(currentConversationId, position);
}
currentConversationId = newId;
// Restore scroll position for the new conversation after DOM updates
await tick();
if (terminalElement) {
const savedPosition = claudeStore.getScrollPosition(newId);
isRestoringScroll = true;
if (savedPosition === -1) {
// Auto-scroll to bottom
shouldAutoScroll = true;
terminalElement.scrollTop = terminalElement.scrollHeight;
} else {
// Restore to saved position
shouldAutoScroll = false;
terminalElement.scrollTop = savedPosition;
}
// Small delay to prevent the scroll handler from overriding our restore
setTimeout(() => {
isRestoringScroll = false;
}, 50);
}
});
function handleScroll() {
if (!terminalElement) return;
if (!terminalElement || isRestoringScroll) return;
const { scrollTop, scrollHeight, clientHeight } = terminalElement;
shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100;
}
afterUpdate(() => {
if (shouldAutoScroll && terminalElement) {
if (shouldAutoScroll && terminalElement && !isRestoringScroll) {
terminalElement.scrollTop = terminalElement.scrollHeight;
}
});
@@ -63,6 +105,23 @@
minute: "2-digit",
});
}
$: {
if (currentSearchQuery && lines.length > 0) {
const escapedQuery = currentSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(escapedQuery, "gi");
let totalMatches = 0;
for (const line of lines) {
const matches = line.content.match(regex);
if (matches) {
totalMatches += matches.length;
}
}
searchState.setMatchCount(totalMatches);
} else {
searchState.setMatchCount(0);
}
}
</script>
<div
@@ -84,7 +143,8 @@
<div
bind:this={terminalElement}
onscroll={handleScroll}
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono text-sm"
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono"
style="font-size: var(--terminal-font-size, 14px);"
>
{#if lines.length === 0}
<div class="terminal-waiting italic">
@@ -100,7 +160,11 @@
{#if line.toolName}
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if}
<span class="whitespace-pre-wrap">{line.content}</span>
{#if line.type === "assistant"}
<Markdown content={line.content} searchQuery={currentSearchQuery} />
{:else}
<HighlightedText content={line.content} searchQuery={currentSearchQuery} />
{/if}
</div>
{/each}
{/if}
@@ -157,4 +221,11 @@
.terminal-header-text {
color: var(--text-secondary);
}
:global(.search-highlight) {
background-color: var(--search-highlight, #fbbf24);
color: var(--search-highlight-text, #000);
border-radius: 2px;
padding: 0 2px;
}
</style>
@@ -0,0 +1,92 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { UpdateInfo } from "$lib/types/messages";
import { configStore } from "$lib/stores/config";
let updateInfo = $state<UpdateInfo | null>(null);
let dismissed = $state(false);
export async function checkForUpdates() {
// Check if update checks are enabled
const config = configStore.getConfig();
if (!config.update_checks_enabled) {
return;
}
try {
const info = await invoke<UpdateInfo>("check_for_updates");
if (info.has_update) {
updateInfo = info;
dismissed = false;
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
console.error("Failed to check for updates:", errorMessage);
}
}
function dismiss() {
dismissed = true;
}
async function openRelease() {
if (updateInfo?.release_url) {
await openUrl(updateInfo.release_url);
}
}
</script>
{#if updateInfo && !dismissed}
<div
class="fixed bottom-4 right-4 max-w-sm bg-[var(--bg-tertiary)] border border-[var(--accent-primary)] rounded-lg shadow-lg p-4 z-50"
>
<div class="flex items-start gap-3">
<div class="text-2xl">🎉</div>
<div class="flex-1">
<h3 class="text-[var(--text-primary)] font-semibold mb-1">Update Available!</h3>
<p class="text-[var(--text-secondary)] text-sm mb-2">
A new version of Hikari Desktop is available:
<span class="text-[var(--accent-primary)] font-mono">{updateInfo.latest_version}</span>
</p>
<p class="text-[var(--text-muted)] text-xs mb-3">
Current version: {updateInfo.current_version}
</p>
<div class="flex gap-2">
<button
onclick={openRelease}
class="px-3 py-1.5 bg-[var(--accent-primary)] text-white rounded text-sm hover:brightness-110 transition-all"
>
View Release
</button>
<button
onclick={dismiss}
class="px-3 py-1.5 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded text-sm hover:bg-[var(--bg-primary)] transition-all"
>
Later
</button>
</div>
</div>
<button
onclick={dismiss}
class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Dismiss"
>
<svg
class="w-4 h-4"
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"
></path>
</svg>
</button>
</div>
</div>
{/if}
+264
View File
@@ -0,0 +1,264 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { SvelteSet } from "svelte/reactivity";
import { claudeStore, hasQuestionPending } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import type { UserQuestionEvent } from "$lib/types/messages";
let isVisible = $state(false);
let question: UserQuestionEvent | null = $state(null);
let selectedOptions: SvelteSet<string> = new SvelteSet();
let customAnswer = $state("");
let showCustomInput = $state(false);
let grantedToolsList: string[] = $state([]);
let workingDirectory = $state("");
hasQuestionPending.subscribe((pending) => {
isVisible = pending;
if (!pending) {
selectedOptions = new SvelteSet();
customAnswer = "";
showCustomInput = false;
}
});
claudeStore.pendingQuestion.subscribe((q) => {
question = q;
if (q) {
characterState.setState("permission");
}
});
claudeStore.grantedTools.subscribe((tools) => {
grantedToolsList = Array.from(tools);
});
claudeStore.currentWorkingDirectory.subscribe((dir) => {
workingDirectory = dir;
});
function toggleOption(label: string) {
if (!question) return;
if (question.multi_select) {
if (selectedOptions.has(label)) {
selectedOptions.delete(label);
} else {
selectedOptions.add(label);
}
} else {
selectedOptions.clear();
selectedOptions.add(label);
}
showCustomInput = false;
}
function selectCustom() {
showCustomInput = true;
selectedOptions.clear();
}
async function handleSubmitAndReconnect() {
if (!question) return;
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
let answerText: string;
if (showCustomInput && customAnswer.trim()) {
answerText = customAnswer.trim();
} else if (selectedOptions.size > 0) {
if (question.multi_select) {
answerText = Array.from(selectedOptions).join(", ");
} else {
answerText = Array.from(selectedOptions)[0];
}
} else {
return;
}
const questionText = question.question;
const conversationHistory = claudeStore.getConversationHistory();
claudeStore.addLine("system", `Answer: ${answerText}. Reconnecting with context...`);
claudeStore.clearQuestion();
try {
await invoke("stop_claude", { conversationId });
await new Promise((resolve) => setTimeout(resolve, 500));
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDirectory || "/home/naomi",
allowed_tools: grantedToolsList,
},
});
await new Promise((resolve) => setTimeout(resolve, 1000));
if (conversationHistory) {
const contextMessage = `[CONTEXT RESTORATION]
I just answered your question. Here's our conversation so far:
${conversationHistory}
You asked me: "${questionText}"
My answer: "${answerText}"
Please continue where we left off, taking my answer into account.`;
await invoke("send_prompt", {
conversationId,
message: contextMessage,
});
}
characterState.setTemporaryState("success", 2000);
} catch (error) {
console.error("Failed to reconnect:", error);
claudeStore.addLine("error", `Reconnect failed: ${error}`);
characterState.setTemporaryState("error", 3000);
}
}
function handleDismiss() {
claudeStore.clearQuestion();
claudeStore.addLine("system", "Question dismissed");
characterState.setTemporaryState("idle", 1000);
}
function handleKeydown(event: KeyboardEvent) {
if (!isVisible || !question) return;
if (event.key === "Enter" && !showCustomInput) {
event.preventDefault();
if (selectedOptions.size > 0) {
handleSubmitAndReconnect();
}
} else if (event.key === "Escape") {
event.preventDefault();
handleDismiss();
}
}
function canSubmit(): boolean {
return selectedOptions.size > 0 || (showCustomInput && customAnswer.trim().length > 0);
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isVisible && question}
<div
class="question-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
>
<div
class="question-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl"
>
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
<span class="text-xl">?</span>
</div>
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">
{question.header || "Question"}
</h2>
<p class="text-sm text-[var(--text-secondary)]">Hikari needs your input</p>
</div>
</div>
<div class="mb-4">
<p class="text-[var(--text-primary)]">{question.question}</p>
</div>
<div class="mb-4 space-y-2">
{#each question.options as option (option.label)}
<button
onclick={() => toggleOption(option.label)}
class="w-full text-left px-4 py-3 rounded-lg border transition-colors {selectedOptions.has(
option.label
)
? 'bg-[var(--accent-primary)]/20 border-[var(--accent-primary)] text-[var(--text-primary)]'
: 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50'}"
>
<div class="flex items-start gap-3">
<div
class="mt-0.5 w-5 h-5 rounded-{question.multi_select
? 'sm'
: 'full'} border-2 flex items-center justify-center {selectedOptions.has(
option.label
)
? 'border-[var(--accent-primary)] bg-[var(--accent-primary)]'
: 'border-[var(--text-secondary)]'}"
>
{#if selectedOptions.has(option.label)}
<span class="text-white text-xs">{question.multi_select ? "x" : "x"}</span>
{/if}
</div>
<div class="flex-1">
<div class="font-medium">{option.label}</div>
{#if option.description}
<div class="text-sm text-[var(--text-secondary)] mt-1">{option.description}</div>
{/if}
</div>
</div>
</button>
{/each}
<button
onclick={selectCustom}
class="w-full text-left px-4 py-3 rounded-lg border transition-colors {showCustomInput
? 'bg-[var(--accent-primary)]/20 border-[var(--accent-primary)] text-[var(--text-primary)]'
: 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50'}"
>
<div class="flex items-start gap-3">
<div
class="mt-0.5 w-5 h-5 rounded-full border-2 flex items-center justify-center {showCustomInput
? 'border-[var(--accent-primary)] bg-[var(--accent-primary)]'
: 'border-[var(--text-secondary)]'}"
>
{#if showCustomInput}
<span class="text-white text-xs">x</span>
{/if}
</div>
<div class="flex-1">
<div class="font-medium">Other</div>
<div class="text-sm text-[var(--text-secondary)] mt-1">Provide a custom answer</div>
</div>
</div>
</button>
</div>
{#if showCustomInput}
<div class="mb-4">
<textarea
bind:value={customAnswer}
placeholder="Type your answer here..."
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-secondary)] resize-none focus:outline-none focus:border-[var(--accent-primary)]"
rows="3"
></textarea>
</div>
{/if}
<div class="flex gap-3">
<button
onclick={handleDismiss}
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
>
Dismiss
</button>
<button
onclick={handleSubmitAndReconnect}
disabled={!canSubmit()}
class="flex-1 px-4 py-2 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Answer & Reconnect
</button>
</div>
</div>
</div>
{/if}
+24 -2
View File
@@ -21,9 +21,11 @@ export const claudeStore = {
currentWorkingDirectory: conversationsStore.currentWorkingDirectory,
terminalLines: conversationsStore.terminalLines,
pendingPermission: conversationsStore.pendingPermission,
pendingQuestion: conversationsStore.pendingQuestion,
isProcessing: conversationsStore.isProcessing,
grantedTools: conversationsStore.grantedTools,
pendingRetryMessage: conversationsStore.pendingRetryMessage,
attachments: conversationsStore.attachments,
// New conversation-aware subscriptions
conversations: conversationsStore.conversations,
@@ -47,6 +49,12 @@ export const claudeStore = {
getConversationHistory: conversationsStore.getConversationHistory,
requestPermission: conversationsStore.requestPermission,
clearPermission: conversationsStore.clearPermission,
requestPermissionForConversation: conversationsStore.requestPermissionForConversation,
clearPermissionForConversation: conversationsStore.clearPermissionForConversation,
requestQuestion: conversationsStore.requestQuestion,
clearQuestion: conversationsStore.clearQuestion,
requestQuestionForConversation: conversationsStore.requestQuestionForConversation,
clearQuestionForConversation: conversationsStore.clearQuestionForConversation,
grantTool: conversationsStore.grantTool,
revokeAllTools: conversationsStore.revokeAllTools,
isToolGranted: conversationsStore.isToolGranted,
@@ -57,6 +65,14 @@ export const claudeStore = {
deleteConversation: conversationsStore.deleteConversation,
switchConversation: conversationsStore.switchConversation,
renameConversation: conversationsStore.renameConversation,
saveScrollPosition: conversationsStore.saveScrollPosition,
getScrollPosition: conversationsStore.getScrollPosition,
// Attachment management
addAttachment: conversationsStore.addAttachment,
removeAttachment: conversationsStore.removeAttachment,
clearAttachments: conversationsStore.clearAttachments,
getAttachments: conversationsStore.getAttachments,
getGrantedTools: (): string[] => {
let tools: string[] = [];
@@ -77,14 +93,20 @@ export const claudeStore = {
conversationsStore.setWorkingDirectory("");
conversationsStore.setProcessing(false);
conversationsStore.revokeAllTools();
conversationsStore.clearAttachments();
// Also clear history restoration
clearHistoryRestore();
},
};
export const hasPermissionPending = derived(
claudeStore.pendingPermission,
($permission) => $permission !== null
claudeStore.activeConversation,
($conversation) => $conversation?.pendingPermission !== null
);
export const hasQuestionPending = derived(
claudeStore.activeConversation,
($conversation) => $conversation?.pendingQuestion !== null
);
// Derived store to check if Claude is currently processing (can be interrupted)
+55 -1
View File
@@ -1,7 +1,7 @@
import { writable, derived } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
export type Theme = "dark" | "light";
export type Theme = "dark" | "light" | "high-contrast";
export interface HikariConfig {
model: string | null;
@@ -14,6 +14,11 @@ export interface HikariConfig {
greeting_custom_prompt: string | null;
notifications_enabled: boolean;
notification_volume: number;
always_on_top: boolean;
minimize_to_tray: boolean;
update_checks_enabled: boolean;
character_panel_width: number | null;
font_size: number;
}
const defaultConfig: HikariConfig = {
@@ -27,6 +32,11 @@ const defaultConfig: HikariConfig = {
greeting_custom_prompt: null,
notifications_enabled: true,
notification_volume: 0.7,
always_on_top: false,
minimize_to_tray: false,
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
};
function createConfigStore() {
@@ -87,6 +97,33 @@ function createConfigStore() {
applyTheme(theme);
},
setFontSize: async (size: number) => {
const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
await updateConfig({ font_size: clampedSize });
applyFontSize(clampedSize);
},
increaseFontSize: async () => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2);
await updateConfig({ font_size: newSize });
applyFontSize(newSize);
},
decreaseFontSize: async () => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2);
await updateConfig({ font_size: newSize });
applyFontSize(newSize);
},
resetFontSize: async () => {
await updateConfig({ font_size: DEFAULT_FONT_SIZE });
applyFontSize(DEFAULT_FONT_SIZE);
},
addAutoGrantedTool: async (tool: string) => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
@@ -117,6 +154,23 @@ export function applyTheme(theme: Theme) {
}
}
const MIN_FONT_SIZE = 10;
const MAX_FONT_SIZE = 24;
const DEFAULT_FONT_SIZE = 14;
export function applyFontSize(size: number) {
if (typeof document !== "undefined") {
const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
document.documentElement.style.setProperty("--terminal-font-size", `${clampedSize}px`);
}
}
export function clampFontSize(size: number): number {
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
}
export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
export const configStore = createConfigStore();
export const isDarkTheme = derived(configStore.config, ($config) => $config.theme === "dark");
+188 -7
View File
@@ -1,5 +1,11 @@
import { writable, derived, get } from "svelte/store";
import type { TerminalLine, ConnectionStatus, PermissionRequest } from "$lib/types/messages";
import type {
TerminalLine,
ConnectionStatus,
PermissionRequest,
UserQuestionEvent,
Attachment,
} from "$lib/types/messages";
import type { CharacterState } from "$lib/types/states";
import { cleanupConversationTracking } from "$lib/tauri";
import { characterState } from "$lib/stores/character";
@@ -14,14 +20,17 @@ export interface Conversation {
characterState: CharacterState;
isProcessing: boolean;
grantedTools: Set<string>;
pendingPermission: PermissionRequest | null;
pendingQuestion: UserQuestionEvent | null;
scrollPosition: number;
createdAt: Date;
lastActivityAt: Date;
attachments: Attachment[];
}
function createConversationsStore() {
const conversations = writable<Map<string, Conversation>>(new Map());
const activeConversationId = writable<string | null>(null);
const pendingPermission = writable<PermissionRequest | null>(null);
const pendingRetryMessage = writable<string | null>(null);
let conversationCounter = 0;
@@ -47,8 +56,12 @@ function createConversationsStore() {
characterState: "idle",
isProcessing: false,
grantedTools: new Set(),
pendingPermission: null,
pendingQuestion: null,
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
createdAt: new Date(),
lastActivityAt: new Date(),
attachments: [],
};
}
@@ -93,6 +106,13 @@ function createConversationsStore() {
activeConversation,
($conv) => $conv?.grantedTools || new Set<string>()
);
const pendingPermission = derived(
activeConversation,
($conv) => $conv?.pendingPermission || null
);
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
const attachments = derived(activeConversation, ($conv) => $conv?.attachments || []);
return {
// Expose derived stores for compatibility
@@ -101,9 +121,12 @@ function createConversationsStore() {
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
terminalLines: { subscribe: terminalLines.subscribe },
pendingPermission: { subscribe: pendingPermission.subscribe },
pendingQuestion: { subscribe: pendingQuestion.subscribe },
isProcessing: { subscribe: isProcessing.subscribe },
grantedTools: { subscribe: grantedTools.subscribe },
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
scrollPosition: { subscribe: scrollPosition.subscribe },
attachments: { subscribe: attachments.subscribe },
// New conversation-specific stores
conversations: { subscribe: conversations.subscribe },
@@ -148,8 +171,98 @@ function createConversationsStore() {
return convs;
});
},
requestPermission: (request: PermissionRequest) => pendingPermission.set(request),
clearPermission: () => pendingPermission.set(null),
requestPermission: (request: PermissionRequest) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingPermission = request;
conv.lastActivityAt = new Date();
}
return convs;
});
},
clearPermission: () => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingPermission = null;
conv.lastActivityAt = new Date();
}
return convs;
});
},
requestPermissionForConversation: (conversationId: string, request: PermissionRequest) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.pendingPermission = request;
conv.lastActivityAt = new Date();
}
return convs;
});
},
clearPermissionForConversation: (conversationId: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.pendingPermission = null;
conv.lastActivityAt = new Date();
}
return convs;
});
},
requestQuestion: (question: UserQuestionEvent) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingQuestion = question;
conv.lastActivityAt = new Date();
}
return convs;
});
},
clearQuestion: () => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingQuestion = null;
conv.lastActivityAt = new Date();
}
return convs;
});
},
requestQuestionForConversation: (conversationId: string, question: UserQuestionEvent) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.pendingQuestion = question;
conv.lastActivityAt = new Date();
}
return convs;
});
},
clearQuestionForConversation: (conversationId: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.pendingQuestion = null;
conv.lastActivityAt = new Date();
}
return convs;
});
},
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
// Conversation management
@@ -164,7 +277,7 @@ function createConversationsStore() {
return newConv.id;
},
deleteConversation: (id: string) => {
deleteConversation: async (id: string) => {
ensureInitialized();
const convs = get(conversations);
const activeId = get(activeConversationId);
@@ -174,8 +287,8 @@ function createConversationsStore() {
return false;
}
// Clean up tracking for this conversation
cleanupConversationTracking(id);
// Clean up tracking for this conversation (including temp files)
await cleanupConversationTracking(id);
conversations.update((c) => {
c.delete(id);
@@ -221,6 +334,22 @@ function createConversationsStore() {
});
},
saveScrollPosition: (id: string, position: number) => {
conversations.update((convs) => {
const conv = convs.get(id);
if (conv) {
conv.scrollPosition = position;
}
return convs;
});
},
getScrollPosition: (id: string): number => {
const convs = get(conversations);
const conv = convs.get(id);
return conv?.scrollPosition ?? -1;
},
// Methods that operate on the active conversation
setSessionId: (id: string | null) => {
ensureInitialized();
@@ -447,6 +576,58 @@ function createConversationsStore() {
return conv?.grantedTools.has(toolName) || false;
},
// Attachment management
addAttachment: (attachment: Attachment) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.attachments.push(attachment);
conv.lastActivityAt = new Date();
}
return convs;
});
},
removeAttachment: (id: string) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.attachments = conv.attachments.filter((a) => a.id !== id);
conv.lastActivityAt = new Date();
}
return convs;
});
},
clearAttachments: () => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.attachments = [];
conv.lastActivityAt = new Date();
}
return convs;
});
},
getAttachments: (): Attachment[] => {
const activeId = get(activeConversationId);
if (!activeId) return [];
const convs = get(conversations);
const conv = convs.get(activeId);
return conv?.attachments || [];
},
// Add initialization helper
initialize: () => {
ensureInitialized();
+63
View File
@@ -0,0 +1,63 @@
import { writable, derived } from "svelte/store";
interface SearchState {
query: string;
isActive: boolean;
matchCount: number;
currentMatchIndex: number;
}
const initialState: SearchState = {
query: "",
isActive: false,
matchCount: 0,
currentMatchIndex: 0,
};
const searchStore = writable<SearchState>(initialState);
export const searchState = {
subscribe: searchStore.subscribe,
setQuery: (query: string) => {
searchStore.update((state) => ({
...state,
query,
isActive: query.length > 0,
currentMatchIndex: 0,
}));
},
setMatchCount: (count: number) => {
searchStore.update((state) => ({
...state,
matchCount: count,
}));
},
nextMatch: () => {
searchStore.update((state) => ({
...state,
currentMatchIndex:
state.matchCount > 0 ? (state.currentMatchIndex + 1) % state.matchCount : 0,
}));
},
previousMatch: () => {
searchStore.update((state) => ({
...state,
currentMatchIndex:
state.matchCount > 0
? (state.currentMatchIndex - 1 + state.matchCount) % state.matchCount
: 0,
}));
},
clear: () => {
searchStore.set(initialState);
},
};
export const isSearchActive = derived(searchStore, ($search) => $search.isActive);
export const searchQuery = derived(searchStore, ($search) => $search.query);
+47 -15
View File
@@ -6,7 +6,11 @@ import { characterState } from "$lib/stores/character";
import { configStore } from "$lib/stores/config";
import { initStatsListener, resetSessionStats } from "$lib/stores/stats";
import { initAchievementsListener } from "$lib/stores/achievements";
import type { ConnectionStatus, PermissionPromptEvent } from "$lib/types/messages";
import type {
ConnectionStatus,
PermissionPromptEvent,
UserQuestionEvent,
} from "$lib/types/messages";
import type { CharacterState } from "$lib/types/states";
import {
initializeNotificationRules,
@@ -103,8 +107,15 @@ interface WorkingDirectoryPayload {
conversation_id?: string;
}
export function cleanupConversationTracking(conversationId: string) {
export async function cleanupConversationTracking(conversationId: string) {
connectedConversations.delete(conversationId);
// Clean up any temp files associated with this conversation
try {
await invoke("cleanup_temp_files", { conversationId });
} catch (error) {
console.error("Failed to cleanup temp files for conversation:", error);
}
}
export async function initializeTauriListeners() {
@@ -292,29 +303,50 @@ export async function initializeTauriListeners() {
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
const { id, tool_name, tool_input, description, conversation_id } = event.payload;
// Only process permission requests for the active conversation
const activeConversationId = get(claudeStore.activeConversationId);
if (conversation_id === activeConversationId) {
// Store permission request for the specific conversation
if (conversation_id) {
claudeStore.requestPermissionForConversation(conversation_id, {
id,
tool: tool_name,
description,
input: tool_input,
});
claudeStore.addLineToConversation(
conversation_id,
"system",
`Permission requested for: ${tool_name}`
);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.requestPermission({
id,
tool: tool_name,
description,
input: tool_input,
});
}
// Always store the permission message to the correct conversation
if (conversation_id) {
claudeStore.addLineToConversation(
conversation_id,
"system",
`Permission requested for: ${tool_name}`
);
} else if (conversation_id === activeConversationId) {
claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
}
});
unlisteners.push(permissionUnlisten);
const questionUnlisten = await listen<UserQuestionEvent>("claude:question", (event) => {
const questionEvent = event.payload;
// Store question request for the specific conversation
if (questionEvent.conversation_id) {
claudeStore.requestQuestionForConversation(questionEvent.conversation_id, questionEvent);
claudeStore.addLineToConversation(
questionEvent.conversation_id,
"system",
`Question: ${questionEvent.question}`
);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.requestQuestion(questionEvent);
claudeStore.addLine("system", `Question: ${questionEvent.question}`);
}
});
unlisteners.push(questionUnlisten);
}
export function cleanupTauriListeners() {
+32
View File
@@ -126,4 +126,36 @@ export interface PermissionPromptEvent {
conversation_id?: string;
}
export interface QuestionOption {
label: string;
description?: string;
}
export interface UserQuestionEvent {
id: string;
question: string;
header?: string;
options: QuestionOption[];
multi_select: boolean;
conversation_id?: string;
}
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
export interface Attachment {
id: string;
filename: string;
path: string;
size: number;
type: "image" | "document" | "other";
mimeType?: string;
previewUrl?: string; // For images, a data URL or object URL for preview
}
export interface UpdateInfo {
current_version: string;
latest_version: string;
has_update: boolean;
release_url: string;
release_notes?: string;
}
+159 -5
View File
@@ -1,22 +1,140 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
import { configStore, applyTheme } from "$lib/stores/config";
import { configStore, applyTheme, applyFontSize } from "$lib/stores/config";
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
import { conversationsStore } from "$lib/stores/conversations";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
import { getCurrentWindow } from "@tauri-apps/api/window";
import "$lib/notifications/testNotifications";
import Terminal from "$lib/components/Terminal.svelte";
import InputBar from "$lib/components/InputBar.svelte";
import StatusBar from "$lib/components/StatusBar.svelte";
import AnimeGirl from "$lib/components/AnimeGirl.svelte";
import PermissionModal from "$lib/components/PermissionModal.svelte";
import UserQuestionModal from "$lib/components/UserQuestionModal.svelte";
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
import AchievementNotification from "$lib/components/AchievementNotification.svelte";
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
import UpdateNotification from "$lib/components/UpdateNotification.svelte";
let initialized = false;
let updateNotification: UpdateNotification;
let achievementPanelOpen = $state(false);
// Resizable panel state
let panelWidth = $state(320); // Default width in pixels
let isResizing = $state(false);
const MIN_PANEL_WIDTH = 200;
const MAX_PANEL_WIDTH = 600;
function startResize(event: MouseEvent) {
isResizing = true;
event.preventDefault();
document.addEventListener("mousemove", handleResize);
document.addEventListener("mouseup", stopResize);
}
function handleResize(event: MouseEvent) {
if (!isResizing) return;
const newWidth = event.clientX;
panelWidth = Math.max(MIN_PANEL_WIDTH, Math.min(MAX_PANEL_WIDTH, newWidth));
}
function stopResize() {
if (isResizing) {
isResizing = false;
document.removeEventListener("mousemove", handleResize);
document.removeEventListener("mouseup", stopResize);
// Save the panel width to config
configStore.updateConfig({ character_panel_width: panelWidth });
}
}
// Global keyboard shortcuts
function handleGlobalKeydown(event: KeyboardEvent) {
// Don't trigger shortcuts when typing in inputs (except for specific ones)
const target = event.target as HTMLElement;
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA";
// Escape closes panels (always works)
if (event.key === "Escape") {
// Check if any panels are open and close them
if (achievementPanelOpen) {
achievementPanelOpen = false;
event.preventDefault();
return;
}
// ConfigSidebar handles its own escape via store
if (get(configStore.isSidebarOpen)) {
configStore.closeSidebar();
event.preventDefault();
return;
}
}
// Skip other shortcuts if user is typing in an input
if (isInputFocused) return;
// Ctrl+L - Clear terminal
if (event.ctrlKey && event.key === "l") {
event.preventDefault();
claudeStore.clearTerminal();
return;
}
// Ctrl+, - Open settings
if (event.ctrlKey && event.key === ",") {
event.preventDefault();
configStore.openSidebar();
return;
}
// Ctrl+C - Interrupt (only when processing)
if (event.ctrlKey && event.key === "c") {
if (get(isClaudeProcessing)) {
event.preventDefault();
handleInterrupt();
return;
}
}
// Ctrl++ or Ctrl+= - Increase font size
if (event.ctrlKey && (event.key === "+" || event.key === "=")) {
event.preventDefault();
configStore.increaseFontSize();
return;
}
// Ctrl+- - Decrease font size
if (event.ctrlKey && event.key === "-") {
event.preventDefault();
configStore.decreaseFontSize();
return;
}
// Ctrl+0 - Reset font size
if (event.ctrlKey && event.key === "0") {
event.preventDefault();
configStore.resetFontSize();
return;
}
}
async function handleInterrupt() {
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted");
} catch (error) {
console.error("Failed to interrupt:", error);
}
}
onMount(async () => {
if (!initialized) {
initialized = true;
@@ -27,12 +145,32 @@
await initializeTauriListeners();
await configStore.loadConfig();
// Apply saved theme on startup
// Apply saved settings on startup
const config = configStore.getConfig();
applyTheme(config.theme);
applyFontSize(config.font_size);
// Apply always-on-top setting
if (config.always_on_top) {
const window = getCurrentWindow();
await window.setAlwaysOnTop(true);
}
// Load saved panel width
if (config.character_panel_width) {
panelWidth = config.character_panel_width;
}
// Initialize notification settings sync
initNotificationSync();
// Add global keyboard shortcut listener
window.addEventListener("keydown", handleGlobalKeydown);
// Check for updates on startup
if (config.update_checks_enabled) {
updateNotification?.checkForUpdates();
}
}
});
@@ -40,6 +178,7 @@
if (initialized) {
cleanupTauriListeners();
cleanupNotificationSync();
window.removeEventListener("keydown", handleGlobalKeydown);
initialized = false;
}
});
@@ -51,25 +190,36 @@
<main class="flex-1 flex overflow-hidden">
<!-- Left panel: Character display -->
<div
class="character-panel w-1/3 flex flex-col items-center justify-center border-r border-[var(--border-color)] bg-[var(--bg-secondary)]/50"
class="character-panel flex flex-col items-center justify-center bg-[var(--bg-secondary)]/50"
style="width: {panelWidth}px; min-width: {MIN_PANEL_WIDTH}px; max-width: {MAX_PANEL_WIDTH}px;"
>
<AnimeGirl />
</div>
<!-- Resize handle -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="resize-handle w-1 cursor-col-resize bg-[var(--border-color)] hover:bg-[var(--accent-primary)] transition-colors flex-shrink-0"
class:bg-[var(--accent-primary)]={isResizing}
onmousedown={startResize}
></div>
<!-- Right panel: Terminal and input -->
<div class="terminal-panel flex-1 flex flex-col">
<div class="terminal-panel flex-1 flex flex-col min-w-0">
<Terminal />
<InputBar />
</div>
</main>
<PermissionModal />
<UserQuestionModal />
<ConfigSidebar />
<AchievementNotification />
<AchievementsPanel
bind:isOpen={achievementPanelOpen}
onClose={() => (achievementPanelOpen = false)}
/>
<UpdateNotification bind:this={updateNotification} />
</div>
<style>
@@ -84,7 +234,11 @@
}
.character-panel {
min-width: 320px;
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%);
}
.resize-handle:hover,
.resize-handle:active {
width: 4px;
}
</style>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 690 KiB

After

Width:  |  Height:  |  Size: 688 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 KiB

After

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 680 KiB

After

Width:  |  Height:  |  Size: 676 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 KiB

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 KiB

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 KiB

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 777 KiB

After

Width:  |  Height:  |  Size: 775 KiB