Compare commits
9 Commits
v0.2.0
...
b1a45ed00e
| Author | SHA1 | Date | |
|---|---|---|---|
| b1a45ed00e | |||
| 457722dc3a | |||
| 852a4d6661 | |||
|
bbeff7ae2e
|
|||
| 3f30997f0e | |||
| 06810537a9 | |||
| 94991796be | |||
| 947e56ef41 | |||
| 9fe4e8a48a |
@@ -2,6 +2,7 @@ build/
|
||||
.svelte-kit/
|
||||
dist/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
pnpm-lock.yaml
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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\""
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()?)))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -81,4 +81,4 @@ $notifier.Show($toast)
|
||||
|
||||
// If all methods fail, return an error
|
||||
Err("All WSL notification methods failed".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("/");
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)]"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 690 KiB After Width: | Height: | Size: 688 KiB |
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 391 KiB |
|
Before Width: | Height: | Size: 463 KiB After Width: | Height: | Size: 442 KiB |
|
Before Width: | Height: | Size: 680 KiB After Width: | Height: | Size: 676 KiB |
|
Before Width: | Height: | Size: 442 KiB After Width: | Height: | Size: 432 KiB |
|
Before Width: | Height: | Size: 454 KiB After Width: | Height: | Size: 443 KiB |
|
Before Width: | Height: | Size: 424 KiB After Width: | Height: | Size: 416 KiB |
|
Before Width: | Height: | Size: 413 KiB After Width: | Height: | Size: 390 KiB |
|
Before Width: | Height: | Size: 777 KiB After Width: | Height: | Size: 775 KiB |