Compare commits
6 Commits
27f69cb308
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
bc596867d4
|
|||
| e877f4aaf2 | |||
| 377f81d978 | |||
| d83697e5cf | |||
| 2d3adcab1c | |||
| 70fcaa8650 |
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Function to run a command and check its status
|
||||
run_check() {
|
||||
local desc=$1
|
||||
local cmd=$2
|
||||
|
||||
echo -e "\n${YELLOW}Running: ${desc}${NC}"
|
||||
echo -e "${YELLOW}Command: ${cmd}${NC}"
|
||||
|
||||
if eval "$cmd"; then
|
||||
echo -e "${GREEN}✓ ${desc} passed${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}✗ ${desc} failed${NC}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Track if any checks fail
|
||||
failed=0
|
||||
|
||||
echo -e "${YELLOW}🔍 Running all checks for Hikari Desktop...${NC}"
|
||||
|
||||
# Frontend checks
|
||||
run_check "Frontend lint" "pnpm lint" || failed=1
|
||||
run_check "Frontend format check" "pnpm format:check" || failed=1
|
||||
run_check "Frontend type check" "pnpm check" || failed=1
|
||||
run_check "Frontend tests" "pnpm test" || failed=1
|
||||
|
||||
# Backend checks
|
||||
run_check "Backend clippy (strict)" "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings" || failed=1
|
||||
run_check "Backend tests" "cargo test" || failed=1
|
||||
|
||||
# Summary
|
||||
echo -e "\n${YELLOW}========================================${NC}"
|
||||
if [ $failed -eq 0 ]; then
|
||||
echo -e "${GREEN}✨ All checks passed! The code is looking great!${NC}"
|
||||
echo -e "${GREEN} Naomi would be so proud of us! 💖${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}❌ Some checks failed. Let's fix them together!${NC}"
|
||||
echo -e "${RED} Don't worry, we'll get through this! 💪${NC}"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hikari-desktop",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -442,8 +442,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
@@ -1409,8 +1411,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hikari-desktop"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hikari-desktop"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = "Hikari - Claude Code Visual Assistant"
|
||||
authors = ["Naomi Carrigan"]
|
||||
edition = "2021"
|
||||
@@ -26,6 +26,7 @@ tauri-plugin-store = "2.4.2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-os = "2"
|
||||
tempfile = "3"
|
||||
chrono = { version = "0.4.43", features = ["serde"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62", features = [
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
"opener:default",
|
||||
"shell:allow-spawn",
|
||||
"shell:allow-stdin-write",
|
||||
"shell:allow-kill"
|
||||
"shell:allow-kill",
|
||||
"notification:default",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,808 @@
|
||||
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)]
|
||||
pub enum AchievementId {
|
||||
// Token Milestones
|
||||
FirstSteps, // 1,000 tokens
|
||||
GrowingStrong, // 10,000 tokens
|
||||
BlossomingCoder, // 100,000 tokens
|
||||
TokenMaster, // 1,000,000 tokens
|
||||
|
||||
// Code Generation
|
||||
HelloWorld, // First code block
|
||||
CodeWizard, // 100 code blocks
|
||||
ThousandBlocks, // 1,000 code blocks
|
||||
|
||||
// File Operations
|
||||
FileManipulator, // 10 files edited
|
||||
FileArchitect, // 100 files edited
|
||||
|
||||
// Conversation milestones
|
||||
ConversationStarter, // 10 messages
|
||||
ChattyKathy, // 100 messages
|
||||
Conversationalist, // 1,000 messages
|
||||
|
||||
// Tool usage
|
||||
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
|
||||
DedicatedDeveloper, // 30 days in a row
|
||||
|
||||
// Search and exploration
|
||||
Explorer, // 50 searches
|
||||
MasterSearcher, // 500 searches
|
||||
|
||||
// Session achievements
|
||||
QuickSession, // Productive session < 5 min
|
||||
FocusedWork, // 30 min session
|
||||
DeepDive, // 2 hour session
|
||||
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
|
||||
ClaudeConnoisseur, // Used all Claude models
|
||||
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"
|
||||
|
||||
// Personality & Fun
|
||||
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")
|
||||
|
||||
// Tool Mastery
|
||||
BashMaster, // Use Bash tool 50 times
|
||||
FileExplorer, // Use Read tool 100 times
|
||||
SearchExpert, // Use Grep tool 50 times
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Achievement {
|
||||
pub id: AchievementId,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub icon: String,
|
||||
pub unlocked_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AchievementProgress {
|
||||
pub unlocked: HashSet<AchievementId>,
|
||||
pub newly_unlocked: Vec<AchievementId>, // Achievements unlocked but not yet notified
|
||||
#[serde(skip)]
|
||||
pub session_start: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl AchievementProgress {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
unlocked: HashSet::new(),
|
||||
newly_unlocked: Vec::new(),
|
||||
session_start: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unlock(&mut self, achievement: AchievementId) -> bool {
|
||||
if self.unlocked.insert(achievement.clone()) {
|
||||
self.newly_unlocked.push(achievement);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn take_newly_unlocked(&mut self) -> Vec<AchievementId> {
|
||||
std::mem::take(&mut self.newly_unlocked)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn is_unlocked(&self, achievement: &AchievementId) -> bool {
|
||||
self.unlocked.contains(achievement)
|
||||
}
|
||||
|
||||
pub fn start_session(&mut self) {
|
||||
self.session_start = Some(Utc::now());
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AchievementProgress {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_achievement_info(id: &AchievementId) -> Achievement {
|
||||
match id {
|
||||
// Token Milestones
|
||||
AchievementId::FirstSteps => Achievement {
|
||||
id: id.clone(),
|
||||
name: "First Steps!".to_string(),
|
||||
description: "Used 1,000 tokens".to_string(),
|
||||
icon: "🌱".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::GrowingStrong => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Growing Strong!".to_string(),
|
||||
description: "Used 10,000 tokens".to_string(),
|
||||
icon: "🌸".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::BlossomingCoder => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Blossoming Coder!".to_string(),
|
||||
description: "Used 100,000 tokens".to_string(),
|
||||
icon: "🌺".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::TokenMaster => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Token Master!".to_string(),
|
||||
description: "Used 1,000,000 tokens".to_string(),
|
||||
icon: "🌟".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
|
||||
// Code Generation
|
||||
AchievementId::HelloWorld => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Hello World!".to_string(),
|
||||
description: "Generated your first code block".to_string(),
|
||||
icon: "📝".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::CodeWizard => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Code Wizard!".to_string(),
|
||||
description: "Generated 100 code blocks".to_string(),
|
||||
icon: "🎯".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::ThousandBlocks => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Thousand Blocks".to_string(),
|
||||
description: "1,000 code blocks! You're a code machine!".to_string(),
|
||||
icon: "🏗️".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
|
||||
// File Operations
|
||||
AchievementId::FileManipulator => Achievement {
|
||||
id: id.clone(),
|
||||
name: "File Manipulator".to_string(),
|
||||
description: "Edited 10 files".to_string(),
|
||||
icon: "📝".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::FileArchitect => Achievement {
|
||||
id: id.clone(),
|
||||
name: "File Architect".to_string(),
|
||||
description: "Created or edited 100 files".to_string(),
|
||||
icon: "🏛️".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
|
||||
// Conversation milestones
|
||||
AchievementId::ConversationStarter => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Conversation Starter".to_string(),
|
||||
description: "Exchanged 10 messages".to_string(),
|
||||
icon: "💬".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::ChattyKathy => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Chatty Kathy".to_string(),
|
||||
description: "100 messages exchanged".to_string(),
|
||||
icon: "🗣️".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::Conversationalist => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Master Conversationalist".to_string(),
|
||||
description: "1,000 messages! We're really connecting!".to_string(),
|
||||
icon: "💖".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
|
||||
// Tool usage
|
||||
AchievementId::Toolsmith => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Toolsmith".to_string(),
|
||||
description: "Used 5 different tools".to_string(),
|
||||
icon: "🔨".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::ToolMaster => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Tool Master".to_string(),
|
||||
description: "Used 10 different tools efficiently".to_string(),
|
||||
icon: "🛠️".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
|
||||
// Time-based achievements
|
||||
AchievementId::EarlyBird => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Early Bird".to_string(),
|
||||
description: "Started a session between 5 AM and 7 AM".to_string(),
|
||||
icon: "🌅".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::NightOwl => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Night Owl".to_string(),
|
||||
description: "Coding after midnight".to_string(),
|
||||
icon: "🦉".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::AllNighter => Achievement {
|
||||
id: id.clone(),
|
||||
name: "All Nighter".to_string(),
|
||||
description: "Worked through the night (2 AM - 5 AM)".to_string(),
|
||||
icon: "🌙".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::WeekendWarrior => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Weekend Warrior".to_string(),
|
||||
description: "Coding on a weekend".to_string(),
|
||||
icon: "⚔️".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::DedicatedDeveloper => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Dedicated Developer".to_string(),
|
||||
description: "Coded for 30 days in a row".to_string(),
|
||||
icon: "🏆".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
|
||||
// Search and exploration
|
||||
AchievementId::Explorer => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Explorer".to_string(),
|
||||
description: "Used search tools 50 times".to_string(),
|
||||
icon: "🔍".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::MasterSearcher => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Master Searcher".to_string(),
|
||||
description: "Searched 500 times across files".to_string(),
|
||||
icon: "🕵️♀️".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
|
||||
// Session achievements
|
||||
AchievementId::QuickSession => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Quick Session".to_string(),
|
||||
description: "Completed a productive session in under 5 minutes".to_string(),
|
||||
icon: "⚡".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::FocusedWork => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Focused Work".to_string(),
|
||||
description: "Worked for 30 minutes straight".to_string(),
|
||||
icon: "🎯".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::DeepDive => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Deep Dive".to_string(),
|
||||
description: "Worked for 2 hours continuously".to_string(),
|
||||
icon: "🏊♀️".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::MarathonSession => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Marathon Session".to_string(),
|
||||
description: "5+ hour coding session!".to_string(),
|
||||
icon: "🏃♀️".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
|
||||
// Special achievements
|
||||
AchievementId::FirstMessage => Achievement {
|
||||
id: id.clone(),
|
||||
name: "First Message".to_string(),
|
||||
description: "Sent your first message to Hikari".to_string(),
|
||||
icon: "✨".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::FirstTool => Achievement {
|
||||
id: id.clone(),
|
||||
name: "First Tool".to_string(),
|
||||
description: "Used your first tool".to_string(),
|
||||
icon: "🔧".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::FirstCodeBlock => Achievement {
|
||||
id: id.clone(),
|
||||
name: "First Code".to_string(),
|
||||
description: "Generated your first code block".to_string(),
|
||||
icon: "📦".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::FirstFileEdit => Achievement {
|
||||
id: id.clone(),
|
||||
name: "First Edit".to_string(),
|
||||
description: "Made your first file edit".to_string(),
|
||||
icon: "✏️".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::Polyglot => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Polyglot".to_string(),
|
||||
description: "Generated code in 5+ languages in one session".to_string(),
|
||||
icon: "🌍".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::SpeedCoder => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Speed Coder".to_string(),
|
||||
description: "Generated 10 code blocks in 10 minutes".to_string(),
|
||||
icon: "🚀".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::ClaudeConnoisseur => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Claude Connoisseur".to_string(),
|
||||
description: "Used all available Claude models".to_string(),
|
||||
icon: "🎨".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::MarathonCoder => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Marathon Coder".to_string(),
|
||||
description: "10,000 tokens in a single session".to_string(),
|
||||
icon: "🏃♂️".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
|
||||
// Relationship & Greetings
|
||||
AchievementId::GoodMorning => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Good Morning!".to_string(),
|
||||
description: "Greeted Hikari with a good morning".to_string(),
|
||||
icon: "🌅".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::GoodNight => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Good Night".to_string(),
|
||||
description: "Said good night to Hikari".to_string(),
|
||||
icon: "🌙".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::ThankYou => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Grateful Heart".to_string(),
|
||||
description: "Thanked Hikari for her help".to_string(),
|
||||
icon: "💝".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::LoveYou => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Love Connection".to_string(),
|
||||
description: "Expressed love to Hikari".to_string(),
|
||||
icon: "💕".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
|
||||
// Personality & Fun
|
||||
AchievementId::EmojiUser => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Emoji Enthusiast".to_string(),
|
||||
description: "Used an emoji in your message".to_string(),
|
||||
icon: "😊".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::QuestionMaster => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Question Master".to_string(),
|
||||
description: "Asked 20 questions".to_string(),
|
||||
icon: "❓".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::CapsLock => Achievement {
|
||||
id: id.clone(),
|
||||
name: "CAPS LOCK ENGAGED".to_string(),
|
||||
description: "SENT A MESSAGE IN ALL CAPS".to_string(),
|
||||
icon: "📢".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::PleaseAndThankYou => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Polite Programmer".to_string(),
|
||||
description: "Said please in a request".to_string(),
|
||||
icon: "🎩".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
|
||||
// Git & Development
|
||||
AchievementId::GitGuru => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Git Guru".to_string(),
|
||||
description: "Used git commands 10 times".to_string(),
|
||||
icon: "🌿".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::TestWriter => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Test Writer".to_string(),
|
||||
description: "Created test files".to_string(),
|
||||
icon: "🧪".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::Debugger => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Bug Squasher".to_string(),
|
||||
description: "Fixed bugs and errors".to_string(),
|
||||
icon: "🐛".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
|
||||
// Tool Mastery
|
||||
AchievementId::BashMaster => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Bash Master".to_string(),
|
||||
description: "Used Bash tool 50 times".to_string(),
|
||||
icon: "🐚".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::FileExplorer => Achievement {
|
||||
id: id.clone(),
|
||||
name: "File Explorer".to_string(),
|
||||
description: "Read 100 files".to_string(),
|
||||
icon: "📂".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
AchievementId::SearchExpert => Achievement {
|
||||
id: id.clone(),
|
||||
name: "Search Expert".to_string(),
|
||||
description: "Used Grep tool 50 times".to_string(),
|
||||
icon: "🔎".to_string(),
|
||||
unlocked_at: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Check achievements based on message content
|
||||
pub fn check_message_achievements(
|
||||
message: &str,
|
||||
progress: &mut AchievementProgress,
|
||||
) -> Vec<AchievementId> {
|
||||
let mut newly_unlocked = Vec::new();
|
||||
let message_lower = message.to_lowercase();
|
||||
|
||||
println!("Checking message achievements for: {}", message);
|
||||
|
||||
// Relationship & Greetings
|
||||
if message_lower.contains("good morning") && progress.unlock(AchievementId::GoodMorning) {
|
||||
newly_unlocked.push(AchievementId::GoodMorning);
|
||||
}
|
||||
if (message_lower.contains("good night") || message_lower.contains("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) {
|
||||
newly_unlocked.push(AchievementId::ThankYou);
|
||||
}
|
||||
if (message_lower.contains("love you") || message_lower.contains("ily"))
|
||||
&& progress.unlock(AchievementId::LoveYou) {
|
||||
newly_unlocked.push(AchievementId::LoveYou);
|
||||
}
|
||||
|
||||
// Personality & Fun
|
||||
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
|
||||
&& message.chars().any(|c| c.is_alphabetic())
|
||||
&& progress.unlock(AchievementId::CapsLock) {
|
||||
newly_unlocked.push(AchievementId::CapsLock);
|
||||
}
|
||||
if message_lower.contains("please") && progress.unlock(AchievementId::PleaseAndThankYou) {
|
||||
newly_unlocked.push(AchievementId::PleaseAndThankYou);
|
||||
}
|
||||
|
||||
// Git & Development patterns in messages
|
||||
if (message_lower.contains("fix") || message_lower.contains("bug") || message_lower.contains("error"))
|
||||
&& progress.unlock(AchievementId::Debugger) {
|
||||
newly_unlocked.push(AchievementId::Debugger);
|
||||
}
|
||||
|
||||
newly_unlocked
|
||||
}
|
||||
|
||||
// Check which achievements should be unlocked based on current stats
|
||||
pub fn check_achievements(
|
||||
stats: &crate::stats::UsageStats,
|
||||
progress: &mut AchievementProgress,
|
||||
) -> Vec<AchievementId> {
|
||||
let mut newly_unlocked = Vec::new();
|
||||
|
||||
println!("Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
|
||||
stats.messages_exchanged,
|
||||
stats.total_input_tokens + stats.total_output_tokens,
|
||||
stats.code_blocks_generated);
|
||||
println!("Currently unlocked: {:?}", progress.unlocked);
|
||||
|
||||
// Token milestones
|
||||
let total_tokens = stats.total_input_tokens + stats.total_output_tokens;
|
||||
if total_tokens >= 1_000 && progress.unlock(AchievementId::FirstSteps) {
|
||||
println!("Unlocked FirstSteps achievement!");
|
||||
newly_unlocked.push(AchievementId::FirstSteps);
|
||||
}
|
||||
if total_tokens >= 10_000 && progress.unlock(AchievementId::GrowingStrong) {
|
||||
newly_unlocked.push(AchievementId::GrowingStrong);
|
||||
}
|
||||
if total_tokens >= 100_000 && progress.unlock(AchievementId::BlossomingCoder) {
|
||||
newly_unlocked.push(AchievementId::BlossomingCoder);
|
||||
}
|
||||
if total_tokens >= 1_000_000 && progress.unlock(AchievementId::TokenMaster) {
|
||||
newly_unlocked.push(AchievementId::TokenMaster);
|
||||
}
|
||||
|
||||
// Code generation
|
||||
if stats.code_blocks_generated >= 1 && progress.unlock(AchievementId::HelloWorld) {
|
||||
newly_unlocked.push(AchievementId::HelloWorld);
|
||||
}
|
||||
if stats.code_blocks_generated >= 100 && progress.unlock(AchievementId::CodeWizard) {
|
||||
newly_unlocked.push(AchievementId::CodeWizard);
|
||||
}
|
||||
if stats.code_blocks_generated >= 1000 && progress.unlock(AchievementId::ThousandBlocks) {
|
||||
newly_unlocked.push(AchievementId::ThousandBlocks);
|
||||
}
|
||||
|
||||
// File operations
|
||||
if stats.files_edited >= 10 && progress.unlock(AchievementId::FileManipulator) {
|
||||
newly_unlocked.push(AchievementId::FileManipulator);
|
||||
}
|
||||
let total_files = stats.files_edited + stats.files_created;
|
||||
if total_files >= 100 && progress.unlock(AchievementId::FileArchitect) {
|
||||
newly_unlocked.push(AchievementId::FileArchitect);
|
||||
}
|
||||
|
||||
// Conversation milestones
|
||||
if stats.messages_exchanged >= 1 && progress.unlock(AchievementId::FirstMessage) {
|
||||
newly_unlocked.push(AchievementId::FirstMessage);
|
||||
}
|
||||
if stats.messages_exchanged >= 10 && progress.unlock(AchievementId::ConversationStarter) {
|
||||
newly_unlocked.push(AchievementId::ConversationStarter);
|
||||
}
|
||||
if stats.messages_exchanged >= 100 && progress.unlock(AchievementId::ChattyKathy) {
|
||||
newly_unlocked.push(AchievementId::ChattyKathy);
|
||||
}
|
||||
if stats.messages_exchanged >= 1000 && progress.unlock(AchievementId::Conversationalist) {
|
||||
newly_unlocked.push(AchievementId::Conversationalist);
|
||||
}
|
||||
|
||||
// Tool usage
|
||||
let unique_tools = stats.tools_usage.len();
|
||||
if unique_tools >= 5 && progress.unlock(AchievementId::Toolsmith) {
|
||||
newly_unlocked.push(AchievementId::Toolsmith);
|
||||
}
|
||||
if unique_tools >= 10 && progress.unlock(AchievementId::ToolMaster) {
|
||||
newly_unlocked.push(AchievementId::ToolMaster);
|
||||
}
|
||||
|
||||
// Search and exploration
|
||||
let search_tools = ["Glob", "Grep", "search", "Task"];
|
||||
let search_count: u64 = search_tools.iter()
|
||||
.filter_map(|tool| stats.tools_usage.get(*tool))
|
||||
.sum();
|
||||
if search_count >= 50 && progress.unlock(AchievementId::Explorer) {
|
||||
newly_unlocked.push(AchievementId::Explorer);
|
||||
}
|
||||
if search_count >= 500 && progress.unlock(AchievementId::MasterSearcher) {
|
||||
newly_unlocked.push(AchievementId::MasterSearcher);
|
||||
}
|
||||
|
||||
// Session duration achievements
|
||||
let session_secs = stats.session_duration_seconds;
|
||||
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) {
|
||||
newly_unlocked.push(AchievementId::FocusedWork);
|
||||
}
|
||||
if session_secs >= 7200 && progress.unlock(AchievementId::DeepDive) {
|
||||
newly_unlocked.push(AchievementId::DeepDive);
|
||||
}
|
||||
if session_secs >= 18000 && progress.unlock(AchievementId::MarathonSession) {
|
||||
newly_unlocked.push(AchievementId::MarathonSession);
|
||||
}
|
||||
|
||||
// Session token achievement
|
||||
let session_tokens = stats.session_input_tokens + stats.session_output_tokens;
|
||||
if session_tokens >= 10000 && progress.unlock(AchievementId::MarathonCoder) {
|
||||
newly_unlocked.push(AchievementId::MarathonCoder);
|
||||
}
|
||||
|
||||
// Special first-time achievements
|
||||
if !stats.tools_usage.is_empty() && progress.unlock(AchievementId::FirstTool) {
|
||||
newly_unlocked.push(AchievementId::FirstTool);
|
||||
}
|
||||
if stats.code_blocks_generated >= 1 && progress.unlock(AchievementId::FirstCodeBlock) {
|
||||
newly_unlocked.push(AchievementId::FirstCodeBlock);
|
||||
}
|
||||
if stats.files_edited >= 1 && progress.unlock(AchievementId::FirstFileEdit) {
|
||||
newly_unlocked.push(AchievementId::FirstFileEdit);
|
||||
}
|
||||
|
||||
// Speed coder - need to track time for this
|
||||
// TODO: Implement tracking for 10 code blocks in 10 minutes
|
||||
|
||||
// Polyglot - need to track languages
|
||||
// TODO: Implement tracking for multiple programming languages
|
||||
|
||||
// Claude Connoisseur - check model usage
|
||||
// TODO: Track different Claude models used
|
||||
|
||||
// Tool mastery achievements
|
||||
if let Some(bash_count) = stats.tools_usage.get("Bash") {
|
||||
if *bash_count >= 50 && progress.unlock(AchievementId::BashMaster) {
|
||||
newly_unlocked.push(AchievementId::BashMaster);
|
||||
}
|
||||
}
|
||||
if let Some(read_count) = stats.tools_usage.get("Read") {
|
||||
if *read_count >= 100 && progress.unlock(AchievementId::FileExplorer) {
|
||||
newly_unlocked.push(AchievementId::FileExplorer);
|
||||
}
|
||||
}
|
||||
if let Some(grep_count) = stats.tools_usage.get("Grep") {
|
||||
if *grep_count >= 50 && progress.unlock(AchievementId::SearchExpert) {
|
||||
newly_unlocked.push(AchievementId::SearchExpert);
|
||||
}
|
||||
}
|
||||
|
||||
// Git Guru - check git command usage in Bash
|
||||
if let Some(bash_count) = stats.tools_usage.get("Bash") {
|
||||
if *bash_count >= 10 && progress.unlock(AchievementId::GitGuru) {
|
||||
// TODO: More specific git command tracking
|
||||
newly_unlocked.push(AchievementId::GitGuru);
|
||||
}
|
||||
}
|
||||
|
||||
// Time-based achievements
|
||||
if let Some(session_start) = progress.session_start {
|
||||
let hour = session_start.hour();
|
||||
let weekday = session_start.weekday();
|
||||
|
||||
// Early bird - 5 AM to 7 AM
|
||||
if (5..=7).contains(&hour) && progress.unlock(AchievementId::EarlyBird) {
|
||||
newly_unlocked.push(AchievementId::EarlyBird);
|
||||
}
|
||||
|
||||
// Night owl - after midnight
|
||||
let current_hour = Utc::now().hour();
|
||||
if current_hour < 6 && progress.unlock(AchievementId::NightOwl) {
|
||||
newly_unlocked.push(AchievementId::NightOwl);
|
||||
}
|
||||
|
||||
// All nighter - 2 AM to 5 AM
|
||||
if (2..=5).contains(¤t_hour) && progress.unlock(AchievementId::AllNighter) {
|
||||
newly_unlocked.push(AchievementId::AllNighter);
|
||||
}
|
||||
|
||||
// Weekend warrior
|
||||
use chrono::Weekday;
|
||||
if (weekday == Weekday::Sat || weekday == Weekday::Sun) && progress.unlock(AchievementId::WeekendWarrior) {
|
||||
newly_unlocked.push(AchievementId::WeekendWarrior);
|
||||
}
|
||||
}
|
||||
|
||||
// Dedicated Developer - need to track consecutive days
|
||||
// TODO: Implement 30 days in a row tracking
|
||||
|
||||
newly_unlocked
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AchievementUnlockedEvent {
|
||||
pub achievement: Achievement,
|
||||
}
|
||||
|
||||
// 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())?;
|
||||
|
||||
// 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.save().map_err(|e| e.to_string())?;
|
||||
|
||||
println!("Achievements saved successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Load achievements from persistent store
|
||||
pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
|
||||
println!("Loading achievements from store...");
|
||||
|
||||
let store = match app.store("achievements.json") {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
println!("Failed to open achievements store: {}", e);
|
||||
return AchievementProgress::new();
|
||||
}
|
||||
};
|
||||
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
// 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()) {
|
||||
println!("Loaded {} achievements", unlocked_list.len());
|
||||
for achievement_id in unlocked_list {
|
||||
progress.unlocked.insert(achievement_id);
|
||||
}
|
||||
} else {
|
||||
println!("Failed to parse unlocked achievements");
|
||||
}
|
||||
} else {
|
||||
println!("No unlocked achievements found in store");
|
||||
}
|
||||
|
||||
progress
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_achievement_unlock() {
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
// First unlock should return true
|
||||
assert!(progress.unlock(AchievementId::FirstSteps));
|
||||
assert!(progress.is_unlocked(&AchievementId::FirstSteps));
|
||||
|
||||
// Second unlock of same achievement should return false
|
||||
assert!(!progress.unlock(AchievementId::FirstSteps));
|
||||
|
||||
// Newly unlocked should contain the achievement
|
||||
let newly = progress.take_newly_unlocked();
|
||||
assert_eq!(newly.len(), 1);
|
||||
assert_eq!(newly[0], AchievementId::FirstSteps);
|
||||
|
||||
// After taking, newly unlocked should be empty
|
||||
let newly = progress.take_newly_unlocked();
|
||||
assert!(newly.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::config::ClaudeStartOptions;
|
||||
use crate::stats::UsageStats;
|
||||
use crate::wsl_bridge::WslBridge;
|
||||
|
||||
pub struct BridgeManager {
|
||||
bridges: HashMap<String, WslBridge>,
|
||||
app_handle: Option<AppHandle>,
|
||||
}
|
||||
|
||||
impl BridgeManager {
|
||||
pub fn new() -> Self {
|
||||
BridgeManager {
|
||||
bridges: HashMap::new(),
|
||||
app_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_app_handle(&mut self, app: AppHandle) {
|
||||
self.app_handle = Some(app);
|
||||
}
|
||||
|
||||
pub fn start_claude(
|
||||
&mut self,
|
||||
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) {
|
||||
return Err("Claude is already running for this conversation".to_string());
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
// 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()
|
||||
.ok_or_else(|| "App handle not set".to_string())?;
|
||||
bridge.stop(app);
|
||||
Ok(())
|
||||
} else {
|
||||
Err("No Claude instance found for this conversation".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
.ok_or_else(|| "App handle not set".to_string())?;
|
||||
bridge.interrupt(app)
|
||||
} else {
|
||||
Err("No Claude instance found for this conversation".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_prompt(&mut self, conversation_id: &str, message: String) -> Result<(), String> {
|
||||
if let Some(bridge) = self.bridges.get_mut(conversation_id) {
|
||||
bridge.send_message(&message)
|
||||
} 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)
|
||||
.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)
|
||||
.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)
|
||||
.map(|b| b.get_stats())
|
||||
.ok_or_else(|| "No Claude instance found for this conversation".to_string())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn cleanup_stopped_bridges(&mut self) {
|
||||
// Remove bridges that are no longer running
|
||||
self.bridges.retain(|_, bridge| bridge.is_running());
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn stop_all(&mut self) {
|
||||
if let Some(app) = &self.app_handle {
|
||||
for (_, bridge) in self.bridges.iter_mut() {
|
||||
bridge.stop(app);
|
||||
}
|
||||
}
|
||||
self.bridges.clear();
|
||||
}
|
||||
|
||||
#[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))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BridgeManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedBridgeManager = Arc<Mutex<BridgeManager>>;
|
||||
|
||||
pub fn create_shared_bridge_manager() -> SharedBridgeManager {
|
||||
Arc::new(Mutex::new(BridgeManager::new()))
|
||||
}
|
||||
@@ -2,43 +2,66 @@ use tauri::{AppHandle, State};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
use crate::config::{ClaudeStartOptions, HikariConfig};
|
||||
use crate::wsl_bridge::SharedBridge;
|
||||
use crate::stats::UsageStats;
|
||||
use crate::bridge_manager::SharedBridgeManager;
|
||||
use crate::achievements::{load_achievements, get_achievement_info, AchievementUnlockedEvent};
|
||||
|
||||
const CONFIG_STORE_KEY: &str = "config";
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_claude(
|
||||
app: AppHandle,
|
||||
bridge: State<'_, SharedBridge>,
|
||||
bridge_manager: State<'_, SharedBridgeManager>,
|
||||
conversation_id: String,
|
||||
options: ClaudeStartOptions,
|
||||
) -> Result<(), String> {
|
||||
let mut bridge = bridge.lock();
|
||||
bridge.start(app, options)
|
||||
let mut manager = bridge_manager.lock();
|
||||
manager.start_claude(&conversation_id, options)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_claude(app: AppHandle, bridge: State<'_, SharedBridge>) -> Result<(), String> {
|
||||
let mut bridge = bridge.lock();
|
||||
bridge.stop(&app);
|
||||
Ok(())
|
||||
pub async fn stop_claude(
|
||||
bridge_manager: State<'_, SharedBridgeManager>,
|
||||
conversation_id: String,
|
||||
) -> Result<(), String> {
|
||||
let mut manager = bridge_manager.lock();
|
||||
manager.stop_claude(&conversation_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_prompt(bridge: State<'_, SharedBridge>, message: String) -> Result<(), String> {
|
||||
let mut bridge = bridge.lock();
|
||||
bridge.send_message(&message)
|
||||
pub async fn interrupt_claude(
|
||||
bridge_manager: State<'_, SharedBridgeManager>,
|
||||
conversation_id: String,
|
||||
) -> Result<(), String> {
|
||||
let mut manager = bridge_manager.lock();
|
||||
manager.interrupt_claude(&conversation_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn is_claude_running(bridge: State<'_, SharedBridge>) -> Result<bool, String> {
|
||||
let bridge = bridge.lock();
|
||||
Ok(bridge.is_running())
|
||||
pub async fn send_prompt(
|
||||
bridge_manager: State<'_, SharedBridgeManager>,
|
||||
conversation_id: String,
|
||||
message: String,
|
||||
) -> Result<(), String> {
|
||||
let mut manager = bridge_manager.lock();
|
||||
manager.send_prompt(&conversation_id, message)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_working_directory(bridge: State<'_, SharedBridge>) -> Result<String, String> {
|
||||
let bridge = bridge.lock();
|
||||
Ok(bridge.get_working_directory().to_string())
|
||||
pub async fn is_claude_running(
|
||||
bridge_manager: State<'_, SharedBridgeManager>,
|
||||
conversation_id: String,
|
||||
) -> Result<bool, String> {
|
||||
let manager = bridge_manager.lock();
|
||||
Ok(manager.is_claude_running(&conversation_id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_working_directory(
|
||||
bridge_manager: State<'_, SharedBridgeManager>,
|
||||
conversation_id: String,
|
||||
) -> Result<String, String> {
|
||||
let manager = bridge_manager.lock();
|
||||
manager.get_working_directory(&conversation_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -72,3 +95,32 @@ pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), Str
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_usage_stats(
|
||||
bridge_manager: State<'_, SharedBridgeManager>,
|
||||
conversation_id: String,
|
||||
) -> Result<UsageStats, String> {
|
||||
let manager = bridge_manager.lock();
|
||||
manager.get_usage_stats(&conversation_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_saved_achievements(app: AppHandle) -> Result<Vec<AchievementUnlockedEvent>, String> {
|
||||
use chrono::Utc;
|
||||
|
||||
// Load achievements from persistent store
|
||||
let progress = load_achievements(&app).await;
|
||||
|
||||
// Create events for all previously unlocked achievements
|
||||
let mut events = Vec::new();
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ pub struct ClaudeStartOptions {
|
||||
|
||||
#[serde(default)]
|
||||
pub allowed_tools: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub skip_greeting: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
mod achievements;
|
||||
mod bridge_manager;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod notifications;
|
||||
mod stats;
|
||||
mod types;
|
||||
mod wsl_bridge;
|
||||
mod wsl_notifications;
|
||||
@@ -9,14 +12,15 @@ mod windows_toast;
|
||||
|
||||
use commands::*;
|
||||
use notifications::*;
|
||||
use wsl_bridge::create_shared_bridge;
|
||||
use bridge_manager::create_shared_bridge_manager;
|
||||
use commands::load_saved_achievements;
|
||||
use wsl_notifications::*;
|
||||
use vbs_notification::*;
|
||||
use windows_toast::*;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let bridge = create_shared_bridge();
|
||||
let bridge_manager = create_shared_bridge_manager();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
@@ -25,16 +29,24 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.manage(bridge)
|
||||
.manage(bridge_manager.clone())
|
||||
.setup(move |app| {
|
||||
// Initialize the app handle in the bridge manager
|
||||
bridge_manager.lock().set_app_handle(app.handle().clone());
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
start_claude,
|
||||
stop_claude,
|
||||
interrupt_claude,
|
||||
send_prompt,
|
||||
is_claude_running,
|
||||
get_working_directory,
|
||||
select_wsl_directory,
|
||||
get_config,
|
||||
save_config,
|
||||
get_usage_stats,
|
||||
load_saved_achievements,
|
||||
send_windows_notification,
|
||||
send_simple_notification,
|
||||
send_windows_toast,
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
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 {
|
||||
pub total_input_tokens: u64,
|
||||
pub total_output_tokens: u64,
|
||||
pub total_cost_usd: f64,
|
||||
pub session_input_tokens: u64,
|
||||
pub session_output_tokens: u64,
|
||||
pub session_cost_usd: f64,
|
||||
pub model: Option<String>,
|
||||
|
||||
// New fields
|
||||
pub messages_exchanged: u64,
|
||||
pub session_messages_exchanged: u64,
|
||||
pub code_blocks_generated: u64,
|
||||
pub session_code_blocks_generated: u64,
|
||||
pub files_edited: u64,
|
||||
pub session_files_edited: u64,
|
||||
pub files_created: u64,
|
||||
pub session_files_created: u64,
|
||||
pub tools_usage: HashMap<String, u64>,
|
||||
pub session_tools_usage: HashMap<String, u64>,
|
||||
pub session_duration_seconds: u64,
|
||||
#[serde(skip)]
|
||||
pub session_start: Option<Instant>,
|
||||
|
||||
// Achievement tracking
|
||||
#[serde(skip)]
|
||||
pub achievements: AchievementProgress,
|
||||
}
|
||||
|
||||
impl UsageStats {
|
||||
pub fn new() -> Self {
|
||||
let mut stats = Self::default();
|
||||
stats.achievements.start_session();
|
||||
stats
|
||||
}
|
||||
|
||||
pub fn add_usage(&mut self, input_tokens: u64, output_tokens: u64, model: &str) {
|
||||
self.total_input_tokens += input_tokens;
|
||||
self.total_output_tokens += output_tokens;
|
||||
self.session_input_tokens += input_tokens;
|
||||
self.session_output_tokens += output_tokens;
|
||||
|
||||
let cost = calculate_cost(input_tokens, output_tokens, model);
|
||||
self.total_cost_usd += cost;
|
||||
self.session_cost_usd += cost;
|
||||
|
||||
self.model = Some(model.to_string());
|
||||
}
|
||||
|
||||
pub fn reset_session(&mut self) {
|
||||
self.session_input_tokens = 0;
|
||||
self.session_output_tokens = 0;
|
||||
self.session_cost_usd = 0.0;
|
||||
self.session_messages_exchanged = 0;
|
||||
self.session_code_blocks_generated = 0;
|
||||
self.session_files_edited = 0;
|
||||
self.session_files_created = 0;
|
||||
self.session_tools_usage.clear();
|
||||
self.session_duration_seconds = 0;
|
||||
self.session_start = Some(Instant::now());
|
||||
self.achievements.start_session();
|
||||
}
|
||||
|
||||
pub fn increment_messages(&mut self) {
|
||||
self.messages_exchanged += 1;
|
||||
self.session_messages_exchanged += 1;
|
||||
}
|
||||
|
||||
pub fn increment_code_blocks(&mut self) {
|
||||
self.code_blocks_generated += 1;
|
||||
self.session_code_blocks_generated += 1;
|
||||
}
|
||||
|
||||
pub fn increment_files_edited(&mut self) {
|
||||
self.files_edited += 1;
|
||||
self.session_files_edited += 1;
|
||||
}
|
||||
|
||||
pub fn increment_files_created(&mut self) {
|
||||
self.files_created += 1;
|
||||
self.session_files_created += 1;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
pub fn get_session_duration(&mut self) -> u64 {
|
||||
// Only update if more than 1 second has passed to reduce calculations
|
||||
if let Some(start) = self.session_start {
|
||||
let elapsed = start.elapsed().as_secs();
|
||||
if elapsed > self.session_duration_seconds {
|
||||
self.session_duration_seconds = elapsed;
|
||||
}
|
||||
}
|
||||
self.session_duration_seconds
|
||||
}
|
||||
|
||||
pub fn check_achievements(&mut self) -> Vec<crate::achievements::AchievementId> {
|
||||
let stats_copy = UsageStats {
|
||||
total_input_tokens: self.total_input_tokens,
|
||||
total_output_tokens: self.total_output_tokens,
|
||||
total_cost_usd: self.total_cost_usd,
|
||||
session_input_tokens: self.session_input_tokens,
|
||||
session_output_tokens: self.session_output_tokens,
|
||||
session_cost_usd: self.session_cost_usd,
|
||||
model: self.model.clone(),
|
||||
messages_exchanged: self.messages_exchanged,
|
||||
session_messages_exchanged: self.session_messages_exchanged,
|
||||
code_blocks_generated: self.code_blocks_generated,
|
||||
session_code_blocks_generated: self.session_code_blocks_generated,
|
||||
files_edited: self.files_edited,
|
||||
session_files_edited: self.session_files_edited,
|
||||
files_created: self.files_created,
|
||||
session_files_created: self.session_files_created,
|
||||
tools_usage: self.tools_usage.clone(),
|
||||
session_tools_usage: self.session_tools_usage.clone(),
|
||||
session_duration_seconds: self.session_duration_seconds,
|
||||
session_start: self.session_start,
|
||||
achievements: AchievementProgress::new(), // Dummy for copy
|
||||
};
|
||||
check_achievements(&stats_copy, &mut self.achievements)
|
||||
}
|
||||
}
|
||||
|
||||
// Pricing as of January 2025
|
||||
// https://www.anthropic.com/pricing
|
||||
fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
|
||||
let (input_price_per_million, output_price_per_million) = match model {
|
||||
// Opus 4.5
|
||||
"claude-opus-4-5-20251101" => (15.0, 75.0),
|
||||
|
||||
// Opus 4
|
||||
"claude-opus-4-20250514" => (15.0, 75.0),
|
||||
|
||||
// Sonnet 4
|
||||
"claude-sonnet-4-20250514" => (3.0, 15.0),
|
||||
|
||||
// Previous generation models
|
||||
"claude-3-5-sonnet-20241022" => (3.0, 15.0),
|
||||
"claude-3-5-sonnet-20240620" => (3.0, 15.0),
|
||||
"claude-3-5-haiku-20241022" => (1.0, 5.0),
|
||||
"claude-3-opus-20240229" => (15.0, 75.0),
|
||||
"claude-3-sonnet-20240229" => (3.0, 15.0),
|
||||
"claude-3-haiku-20240307" => (0.25, 1.25),
|
||||
|
||||
// Default to Sonnet pricing if model unknown
|
||||
_ => (3.0, 15.0),
|
||||
};
|
||||
|
||||
let input_cost = (input_tokens as f64 / 1_000_000.0) * input_price_per_million;
|
||||
let output_cost = (output_tokens as f64 / 1_000_000.0) * output_price_per_million;
|
||||
|
||||
input_cost + output_cost
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StatsUpdateEvent {
|
||||
pub stats: UsageStats,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_cost_calculation_sonnet() {
|
||||
let cost = calculate_cost(1000, 2000, "claude-sonnet-4-20250514");
|
||||
// 1000 input * $3/M = $0.003
|
||||
// 2000 output * $15/M = $0.030
|
||||
// Total = $0.033
|
||||
assert!((cost - 0.033).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_calculation_opus() {
|
||||
let cost = calculate_cost(1000, 2000, "claude-opus-4-20250514");
|
||||
// 1000 input * $15/M = $0.015
|
||||
// 2000 output * $75/M = $0.150
|
||||
// Total = $0.165
|
||||
assert!((cost - 0.165).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_usage_stats_accumulation() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
|
||||
|
||||
assert_eq!(stats.total_input_tokens, 1000);
|
||||
assert_eq!(stats.total_output_tokens, 2000);
|
||||
assert_eq!(stats.session_input_tokens, 1000);
|
||||
assert_eq!(stats.session_output_tokens, 2000);
|
||||
assert!((stats.total_cost_usd - 0.033).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_reset() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
|
||||
stats.reset_session();
|
||||
|
||||
assert_eq!(stats.total_input_tokens, 1000);
|
||||
assert_eq!(stats.total_output_tokens, 2000);
|
||||
assert_eq!(stats.session_input_tokens, 0);
|
||||
assert_eq!(stats.session_output_tokens, 0);
|
||||
assert_eq!(stats.session_cost_usd, 0.0);
|
||||
assert!(stats.total_cost_usd > 0.0);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UsageInfo {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CharacterState {
|
||||
@@ -87,6 +93,8 @@ pub enum ClaudeMessage {
|
||||
num_turns: Option<u32>,
|
||||
#[serde(default)]
|
||||
permission_denials: Option<Vec<PermissionDenial>>,
|
||||
#[serde(default)]
|
||||
usage: Option<UsageInfo>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -97,6 +105,8 @@ pub struct AssistantMessageContent {
|
||||
pub model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stop_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub usage: Option<UsageInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -162,6 +172,8 @@ pub struct DeltaContent {
|
||||
pub struct StateChangeEvent {
|
||||
pub state: CharacterState,
|
||||
pub tool_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -169,6 +181,8 @@ pub struct OutputEvent {
|
||||
pub line_type: String,
|
||||
pub content: String,
|
||||
pub tool_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -177,6 +191,29 @@ pub struct PermissionPromptEvent {
|
||||
pub tool_name: String,
|
||||
pub tool_input: serde_json::Value,
|
||||
pub description: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectionEvent {
|
||||
pub status: ConnectionStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionEvent {
|
||||
pub session_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkingDirectoryEvent {
|
||||
pub directory: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -284,6 +321,7 @@ mod tests {
|
||||
let event = StateChangeEvent {
|
||||
state: CharacterState::Coding,
|
||||
tool_name: Some("Edit".to_string()),
|
||||
conversation_id: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
@@ -297,6 +335,7 @@ mod tests {
|
||||
line_type: "assistant".to_string(),
|
||||
content: "Test output".to_string(),
|
||||
tool_name: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use parking_lot::Mutex;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
@@ -10,7 +9,10 @@ use tempfile::NamedTempFile;
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
use crate::config::ClaudeStartOptions;
|
||||
use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent};
|
||||
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};
|
||||
|
||||
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
|
||||
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
|
||||
@@ -72,6 +74,8 @@ pub struct WslBridge {
|
||||
working_directory: String,
|
||||
session_id: Option<String>,
|
||||
mcp_config_file: Option<NamedTempFile>,
|
||||
stats: Arc<RwLock<UsageStats>>,
|
||||
conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
impl WslBridge {
|
||||
@@ -82,18 +86,43 @@ impl WslBridge {
|
||||
working_directory: String::new(),
|
||||
session_id: None,
|
||||
mcp_config_file: None,
|
||||
stats: Arc::new(RwLock::new(UsageStats::new())),
|
||||
conversation_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_conversation_id(conversation_id: String) -> Self {
|
||||
WslBridge {
|
||||
process: None,
|
||||
stdin: None,
|
||||
working_directory: String::new(),
|
||||
session_id: None,
|
||||
mcp_config_file: None,
|
||||
stats: Arc::new(RwLock::new(UsageStats::new())),
|
||||
conversation_id: Some(conversation_id),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
|
||||
if self.process.is_some() {
|
||||
return Err("Process already running".to_string());
|
||||
}
|
||||
|
||||
// Load saved achievements when starting a new session
|
||||
let app_clone = app.clone();
|
||||
let stats = self.stats.clone();
|
||||
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());
|
||||
stats.write().achievements = achievements;
|
||||
});
|
||||
|
||||
let working_dir = &options.working_dir;
|
||||
self.working_directory = working_dir.clone();
|
||||
|
||||
emit_connection_status(&app, ConnectionStatus::Connecting);
|
||||
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 {
|
||||
@@ -249,21 +278,35 @@ impl WslBridge {
|
||||
self.stdin = stdin;
|
||||
self.process = Some(child);
|
||||
|
||||
// Reset session stats when starting new session
|
||||
self.stats.write().reset_session();
|
||||
|
||||
// Load saved achievements
|
||||
let app_handle = app.clone();
|
||||
let stats_clone = self.stats.clone();
|
||||
tokio::spawn(async move {
|
||||
let saved_progress = crate::achievements::load_achievements(&app_handle).await;
|
||||
stats_clone.write().achievements = saved_progress;
|
||||
});
|
||||
|
||||
if let Some(stdout) = stdout {
|
||||
let app_clone = app.clone();
|
||||
let stats_clone = self.stats.clone();
|
||||
let conv_id = self.conversation_id.clone();
|
||||
thread::spawn(move || {
|
||||
handle_stdout(stdout, app_clone);
|
||||
handle_stdout(stdout, app_clone, stats_clone, conv_id);
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(stderr) = stderr {
|
||||
let app_clone = app.clone();
|
||||
let conv_id = self.conversation_id.clone();
|
||||
thread::spawn(move || {
|
||||
handle_stderr(stderr, app_clone);
|
||||
handle_stderr(stderr, app_clone, conv_id);
|
||||
});
|
||||
}
|
||||
|
||||
emit_connection_status(&app, ConnectionStatus::Connected);
|
||||
emit_connection_status(&app, ConnectionStatus::Connected, self.conversation_id.clone());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -293,6 +336,30 @@ impl WslBridge {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn interrupt(&mut self, app: &AppHandle) -> Result<(), String> {
|
||||
// Due to persistent bug in Claude Code where ESC/Ctrl+C doesn't work,
|
||||
// we have to kill the process. This is the only reliable way to stop it.
|
||||
// See: https://github.com/anthropics/claude-code/issues/3455
|
||||
if let Some(mut process) = self.process.take() {
|
||||
// Kill the process immediately
|
||||
let _ = process.kill();
|
||||
let _ = process.wait();
|
||||
|
||||
// Clear stdin
|
||||
self.stdin = None;
|
||||
|
||||
// Keep session_id and working directory for user reference
|
||||
// The user will see what session was interrupted
|
||||
|
||||
// Emit disconnected status
|
||||
emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone());
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err("No active process to interrupt".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop(&mut self, app: &AppHandle) {
|
||||
if let Some(mut process) = self.process.take() {
|
||||
let _ = process.kill();
|
||||
@@ -301,7 +368,7 @@ 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);
|
||||
emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone());
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
@@ -311,6 +378,11 @@ impl WslBridge {
|
||||
pub fn get_working_directory(&self) -> &str {
|
||||
&self.working_directory
|
||||
}
|
||||
|
||||
pub fn get_stats(&self) -> UsageStats {
|
||||
self.stats.read().clone()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl Default for WslBridge {
|
||||
@@ -319,13 +391,13 @@ impl Default for WslBridge {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle) {
|
||||
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() {
|
||||
match line {
|
||||
Ok(line) if !line.is_empty() => {
|
||||
if let Err(e) = process_json_line(&line, &app) {
|
||||
if let Err(e) = process_json_line(&line, &app, &stats, &conversation_id) {
|
||||
eprintln!("Error processing line: {}", e);
|
||||
}
|
||||
}
|
||||
@@ -337,10 +409,10 @@ fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
emit_connection_status(&app, ConnectionStatus::Disconnected);
|
||||
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
|
||||
}
|
||||
|
||||
fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) {
|
||||
fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle, conversation_id: Option<String>) {
|
||||
let reader = BufReader::new(stderr);
|
||||
|
||||
for line in reader.lines() {
|
||||
@@ -350,6 +422,7 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) {
|
||||
line_type: "error".to_string(),
|
||||
content: line,
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
});
|
||||
}
|
||||
Err(_) => break,
|
||||
@@ -358,7 +431,7 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
fn process_json_line(line: &str, app: &AppHandle) -> 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))?;
|
||||
|
||||
@@ -366,12 +439,18 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> {
|
||||
ClaudeMessage::System { subtype, session_id, cwd, .. } => {
|
||||
if subtype == "init" {
|
||||
if let Some(id) = session_id {
|
||||
let _ = app.emit("claude:session", 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", dir.clone());
|
||||
let _ = app.emit("claude:cwd", WorkingDirectoryEvent {
|
||||
directory: dir.clone(),
|
||||
conversation_id: conversation_id.clone(),
|
||||
});
|
||||
}
|
||||
emit_state_change(app, CharacterState::Idle, None);
|
||||
emit_state_change(app, CharacterState::Idle, None, conversation_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,24 +458,67 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> {
|
||||
let mut state = CharacterState::Typing;
|
||||
let mut tool_name = None;
|
||||
|
||||
// Only update stats if we have usage information
|
||||
if let Some(usage) = &message.usage {
|
||||
if let Some(model) = &message.model {
|
||||
// Batch all stats updates in a single write lock
|
||||
{
|
||||
let mut stats_guard = stats.write();
|
||||
stats_guard.increment_messages();
|
||||
stats_guard.add_usage(usage.input_tokens, usage.output_tokens, model);
|
||||
stats_guard.get_session_duration();
|
||||
}
|
||||
|
||||
// Don't emit here - we'll emit on Result message instead
|
||||
// This reduces the frequency of updates
|
||||
} else {
|
||||
// Just increment message count if no usage info
|
||||
stats.write().increment_messages();
|
||||
}
|
||||
} else {
|
||||
// Just increment message count if no usage info
|
||||
stats.write().increment_messages();
|
||||
}
|
||||
|
||||
for block in &message.content {
|
||||
match block {
|
||||
ContentBlock::ToolUse { name, input, .. } => {
|
||||
tool_name = Some(name.clone());
|
||||
state = get_tool_state(name);
|
||||
|
||||
// Batch tool tracking updates
|
||||
{
|
||||
let mut stats_guard = stats.write();
|
||||
stats_guard.increment_tool_usage(name);
|
||||
|
||||
// Track file operations
|
||||
match name.as_str() {
|
||||
"Edit" => stats_guard.increment_files_edited(),
|
||||
"Write" => stats_guard.increment_files_created(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
});
|
||||
}
|
||||
ContentBlock::Text { text } => {
|
||||
// Count code blocks in the text
|
||||
let code_blocks = text.matches("```").count() / 2;
|
||||
for _ in 0..code_blocks {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
ContentBlock::Thinking { thinking } => {
|
||||
@@ -405,13 +527,14 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> {
|
||||
line_type: "system".to_string(),
|
||||
content: format!("[Thinking] {}", thinking),
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
emit_state_change(app, state, tool_name);
|
||||
emit_state_change(app, state, tool_name, conversation_id.clone());
|
||||
}
|
||||
|
||||
ClaudeMessage::StreamEvent { event } => {
|
||||
@@ -429,7 +552,7 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> {
|
||||
}
|
||||
_ => CharacterState::Typing,
|
||||
};
|
||||
emit_state_change(app, state, block.name.clone());
|
||||
emit_state_change(app, state, block.name.clone(), conversation_id.clone());
|
||||
}
|
||||
} else if event.event_type == "content_block_delta" {
|
||||
if let Some(delta) = &event.delta {
|
||||
@@ -440,13 +563,55 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
ClaudeMessage::Result { subtype, result, permission_denials, .. } => {
|
||||
ClaudeMessage::Result { subtype, result, permission_denials, usage: _, .. } => {
|
||||
let state = if subtype == "success" {
|
||||
CharacterState::Success
|
||||
} else {
|
||||
CharacterState::Error
|
||||
};
|
||||
|
||||
// Always emit updated stats on result message (less frequent)
|
||||
// This includes the latest session duration
|
||||
let newly_unlocked = {
|
||||
let mut stats_guard = stats.write();
|
||||
stats_guard.get_session_duration();
|
||||
println!("Checking achievements after result message...");
|
||||
let unlocked = stats_guard.check_achievements();
|
||||
println!("Newly unlocked achievements: {:?}", unlocked);
|
||||
unlocked
|
||||
};
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
// Save achievements after unlocking new ones
|
||||
if !newly_unlocked.is_empty() {
|
||||
println!("Saving newly unlocked achievements: {:?}", newly_unlocked);
|
||||
let app_handle = app.clone();
|
||||
let achievements_progress = stats.read().achievements.clone();
|
||||
|
||||
// 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 {
|
||||
eprintln!("Failed to save achievements: {}", e);
|
||||
} else {
|
||||
println!("Achievement save task completed successfully");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let current_stats = stats.read().clone();
|
||||
let stats_event = StatsUpdateEvent {
|
||||
stats: current_stats,
|
||||
};
|
||||
let _ = app.emit("claude:stats", stats_event);
|
||||
|
||||
// Only emit error results - success content is already sent via Assistant message
|
||||
if subtype != "success" {
|
||||
if let Some(text) = result {
|
||||
@@ -454,6 +619,7 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> {
|
||||
line_type: "error".to_string(),
|
||||
content: text.clone(),
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -467,21 +633,75 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> {
|
||||
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);
|
||||
emit_state_change(app, CharacterState::Permission, None, conversation_id.clone());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
emit_state_change(app, state, None);
|
||||
emit_state_change(app, state, None, conversation_id.clone());
|
||||
}
|
||||
|
||||
ClaudeMessage::User { .. } => {
|
||||
emit_state_change(app, CharacterState::Thinking, None);
|
||||
ClaudeMessage::User { message } => {
|
||||
// Increment message count for user messages
|
||||
stats.write().increment_messages();
|
||||
|
||||
// Extract text content from the message
|
||||
let message_text = message.content.iter()
|
||||
.filter_map(|block| match block {
|
||||
crate::types::ContentBlock::Text { text } => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join(" ");
|
||||
|
||||
// Check achievements after user message
|
||||
let newly_unlocked = {
|
||||
let mut stats_guard = stats.write();
|
||||
println!("User sent message, checking achievements...");
|
||||
|
||||
// Check message-based achievements
|
||||
let mut unlocked = crate::achievements::check_message_achievements(
|
||||
&message_text,
|
||||
&mut stats_guard.achievements,
|
||||
);
|
||||
|
||||
// Check stats-based achievements
|
||||
let stats_unlocked = stats_guard.check_achievements();
|
||||
unlocked.extend(stats_unlocked);
|
||||
|
||||
unlocked
|
||||
};
|
||||
|
||||
// Emit achievement events for any newly unlocked achievements
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// Save achievements after unlocking new ones
|
||||
if !newly_unlocked.is_empty() {
|
||||
println!("Saving newly unlocked achievements from user message");
|
||||
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 {
|
||||
eprintln!("Failed to save achievements: {}", e);
|
||||
} else {
|
||||
println!("Achievements saved after user message");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
emit_state_change(app, CharacterState::Thinking, None, conversation_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,19 +768,14 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option<String>) {
|
||||
let _ = app.emit("claude:state", StateChangeEvent { state, tool_name });
|
||||
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) {
|
||||
let _ = app.emit("claude:connection", status);
|
||||
fn emit_connection_status(app: &AppHandle, status: ConnectionStatus, conversation_id: Option<String>) {
|
||||
let _ = app.emit("claude:connection", ConnectionEvent { status, conversation_id });
|
||||
}
|
||||
|
||||
pub type SharedBridge = Arc<Mutex<WslBridge>>;
|
||||
|
||||
pub fn create_shared_bridge() -> SharedBridge {
|
||||
Arc::new(Mutex::new(WslBridge::new()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -679,9 +894,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_shared_bridge() {
|
||||
let shared = create_shared_bridge();
|
||||
let bridge = shared.lock();
|
||||
assert!(!bridge.is_running());
|
||||
fn test_create_shared_bridge_manager() {
|
||||
use crate::bridge_manager::create_shared_bridge_manager;
|
||||
let shared = create_shared_bridge_manager();
|
||||
let manager = shared.lock();
|
||||
assert!(manager.get_active_conversations().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "hikari-desktop",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"identifier": "com.naomi.hikari-desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
@@ -10,7 +10,14 @@
|
||||
--accent-secondary: #ff6b9d;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-tertiary: #6b7280;
|
||||
--border-color: #2a2a4a;
|
||||
|
||||
/* Terminal specific colors */
|
||||
--terminal-user: #22d3ee;
|
||||
--terminal-tool: #c084fc;
|
||||
--terminal-tool-name: #ddd6fe;
|
||||
--terminal-error: #f87171;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
@@ -22,7 +29,14 @@
|
||||
--accent-secondary: #ff6b9d;
|
||||
--text-primary: #1a1a2e;
|
||||
--text-secondary: #5a5a7a;
|
||||
--text-tertiary: #9ca3af;
|
||||
--border-color: #d0d0e0;
|
||||
|
||||
/* Terminal specific colors */
|
||||
--terminal-user: #0891b2;
|
||||
--terminal-tool: #7c3aed;
|
||||
--terminal-tool-name: #8b5cf6;
|
||||
--terminal-error: #dc2626;
|
||||
}
|
||||
|
||||
html,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
let appVersion = $state("");
|
||||
|
||||
onMount(async () => {
|
||||
appVersion = await getVersion();
|
||||
});
|
||||
|
||||
const links = {
|
||||
source: "https://git.nhcarrigan.com/nhcarrigan/hikari-desktop",
|
||||
discord: "https://chat.nhcarrigan.com",
|
||||
website: "https://nhcarrigan.com",
|
||||
license: "https://docs.nhcarrigan.com/legal/license/",
|
||||
changelog: "https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/releases",
|
||||
};
|
||||
</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-md w-full p-6"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="about-title"
|
||||
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>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-gray-500 hover:text-gray-300 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="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">
|
||||
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">
|
||||
{appVersion || "Loading..."}
|
||||
</p>
|
||||
<button
|
||||
onclick={() => openUrl(links.changelog)}
|
||||
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
|
||||
>
|
||||
View Changelog
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-200 mb-2">Source Code</h3>
|
||||
<button
|
||||
onclick={() => openUrl(links.source)}
|
||||
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
|
||||
>
|
||||
View on Git
|
||||
</button>
|
||||
</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>
|
||||
<button
|
||||
onclick={() => openUrl(links.discord)}
|
||||
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
|
||||
>
|
||||
Join our Discord
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-200 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"
|
||||
>
|
||||
Naomi Carrigan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-200 mb-2">License</h3>
|
||||
<p class="text-gray-400 mb-1">
|
||||
This project is open source and available under our license terms.
|
||||
</p>
|
||||
<button
|
||||
onclick={() => openUrl(links.license)}
|
||||
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
|
||||
>
|
||||
View License
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 mt-4 border-t border-[var(--border-color)]">
|
||||
<p class="text-xs text-gray-500 text-center">
|
||||
Copyright © {new Date().getFullYear()} Naomi Carrigan. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Ensure the panel appears above other content */
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,202 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { fade, fly } from "svelte/transition";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
|
||||
|
||||
let achievements = $state<AchievementUnlockedEvent[]>([]);
|
||||
let currentAchievement = $state<AchievementUnlockedEvent | null>(null);
|
||||
let showNotification = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
|
||||
achievements.push(event.payload);
|
||||
if (!showNotification) {
|
||||
showNext();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
setupListener();
|
||||
|
||||
return () => {
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function showNext() {
|
||||
if (achievements.length > 0) {
|
||||
currentAchievement = achievements.shift() || null;
|
||||
showNotification = true;
|
||||
|
||||
// Auto-hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
showNotification = false;
|
||||
// Show next achievement after animation completes
|
||||
setTimeout(() => showNext(), 300);
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
showNotification = false;
|
||||
// Show next achievement after animation completes
|
||||
setTimeout(() => showNext(), 300);
|
||||
}
|
||||
|
||||
function getRarityColor(rarity: string): string {
|
||||
switch (rarity) {
|
||||
case "legendary":
|
||||
return "from-yellow-400 to-orange-500";
|
||||
case "epic":
|
||||
return "from-purple-400 to-pink-500";
|
||||
case "rare":
|
||||
return "from-blue-400 to-indigo-500";
|
||||
default:
|
||||
return "from-green-400 to-emerald-500";
|
||||
}
|
||||
}
|
||||
|
||||
function getAchievementRarity(id: string): string {
|
||||
// Determine rarity based on achievement ID
|
||||
if (id === "TokenMaster") return "legendary";
|
||||
if (["CodeMachine", "Unstoppable"].includes(id)) return "epic";
|
||||
if (
|
||||
[
|
||||
"BlossomingCoder",
|
||||
"CodeWizard",
|
||||
"MasterBuilder",
|
||||
"EnduranceChamp",
|
||||
"DeepDive",
|
||||
"CreativeCoder",
|
||||
].includes(id)
|
||||
)
|
||||
return "rare";
|
||||
return "common";
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showNotification && currentAchievement}
|
||||
<div
|
||||
class="fixed top-20 right-4 z-50 max-w-sm"
|
||||
in:fly={{ x: 300, duration: 500, easing: cubicOut }}
|
||||
out:fade={{ duration: 300 }}
|
||||
>
|
||||
<!-- Backdrop with animated gradient border -->
|
||||
<div class="relative p-[2px] rounded-lg overflow-hidden">
|
||||
<!-- Animated gradient border -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-r {getRarityColor(
|
||||
getAchievementRarity(currentAchievement.achievement.id)
|
||||
)} animate-pulse"
|
||||
></div>
|
||||
|
||||
<!-- Main notification content -->
|
||||
<div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm">
|
||||
<button
|
||||
onclick={dismiss}
|
||||
onkeydown={(e) => e.key === "Enter" && dismiss()}
|
||||
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Icon with animated sparkles -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="text-5xl animate-bounce">{currentAchievement.achievement.icon}</div>
|
||||
|
||||
<!-- Sparkle animations -->
|
||||
<div class="absolute -top-1 -right-1 text-yellow-400 animate-ping">✨</div>
|
||||
<div
|
||||
class="absolute -bottom-1 -left-1 text-yellow-400 animate-ping animation-delay-200"
|
||||
>
|
||||
✨
|
||||
</div>
|
||||
<div class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400">
|
||||
✨
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text content -->
|
||||
<div class="flex-1 min-w-0 pt-1">
|
||||
<h3
|
||||
class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide"
|
||||
>
|
||||
Achievement Unlocked!
|
||||
</h3>
|
||||
<p class="text-lg font-bold text-[var(--text-primary)] mt-1">
|
||||
{currentAchievement.achievement.name}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{currentAchievement.achievement.description}
|
||||
</p>
|
||||
|
||||
<!-- Rarity badge -->
|
||||
<div class="mt-2 inline-flex items-center">
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r {getRarityColor(
|
||||
getAchievementRarity(currentAchievement.achievement.id)
|
||||
)} text-white capitalize"
|
||||
>
|
||||
{getAchievementRarity(currentAchievement.achievement.id)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Celebration confetti effect (CSS only) -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
||||
{#each Array(10) as _ (_)}
|
||||
<div
|
||||
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
|
||||
getAchievementRarity(currentAchievement.achievement.id)
|
||||
)} rounded-full animate-fall"
|
||||
style="left: {Math.random() * 100}%; animation-delay: {Math.random() *
|
||||
2}s; animation-duration: {2 + Math.random() * 2}s;"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes fall {
|
||||
0% {
|
||||
transform: translateY(-20px) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(400px) rotate(720deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fall {
|
||||
animation: fall linear infinite;
|
||||
}
|
||||
|
||||
.animation-delay-200 {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.animation-delay-400 {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,266 @@
|
||||
<script lang="ts">
|
||||
import { slide } from "svelte/transition";
|
||||
import { quintOut } from "svelte/easing";
|
||||
import {
|
||||
achievementsStore,
|
||||
achievementProgress,
|
||||
achievementCategories,
|
||||
} from "$lib/stores/achievements";
|
||||
import type { Achievement } from "$lib/types/achievements";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const { isOpen = $bindable(false), onClose }: Props = $props();
|
||||
let selectedCategory = $state<string | null>(null);
|
||||
|
||||
const achievementsState = $derived($achievementsStore);
|
||||
const progress = $derived($achievementProgress);
|
||||
|
||||
function getRarityColor(rarity: string): string {
|
||||
switch (rarity) {
|
||||
case "legendary":
|
||||
return "text-yellow-500 dark:text-yellow-400";
|
||||
case "epic":
|
||||
return "text-purple-500 dark:text-purple-400";
|
||||
case "rare":
|
||||
return "text-blue-500 dark:text-blue-400";
|
||||
default:
|
||||
return "text-green-500 dark:text-green-400";
|
||||
}
|
||||
}
|
||||
|
||||
function getRarityBg(rarity: string): string {
|
||||
switch (rarity) {
|
||||
case "legendary":
|
||||
return "bg-yellow-500/10";
|
||||
case "epic":
|
||||
return "bg-purple-500/10";
|
||||
case "rare":
|
||||
return "bg-blue-500/10";
|
||||
default:
|
||||
return "bg-green-500/10";
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date | undefined): string {
|
||||
if (!date) return "";
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function getAchievementsForCategory(categoryIds: string[]): Achievement[] {
|
||||
return categoryIds
|
||||
.map(
|
||||
(id) => achievementsState.achievements[id as keyof typeof achievementsState.achievements]
|
||||
)
|
||||
.filter(Boolean);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Achievements panel -->
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 z-40"
|
||||
onclick={onClose}
|
||||
onkeydown={(e) => e.key === "Escape" && onClose?.()}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label="Close achievements panel"
|
||||
transition:slide={{ duration: 300, easing: quintOut }}
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="fixed left-0 top-0 h-full w-96 bg-[var(--bg-primary)] border-r border-[var(--border-color)]
|
||||
shadow-2xl z-50 flex flex-col"
|
||||
transition:slide={{ duration: 300, easing: quintOut, axis: "x" }}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="p-6 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-bold text-[var(--text-primary)]">Achievements</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
onkeydown={(e) => e.key === "Enter" && onClose?.()}
|
||||
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
aria-label="Close achievements panel"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Overall progress -->
|
||||
<div class="mt-4">
|
||||
<div
|
||||
class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400 mb-2"
|
||||
>
|
||||
<span>{progress.unlocked} / {progress.total} Unlocked</span>
|
||||
<span>{progress.percentage}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] h-2 rounded-full transition-all duration-500"
|
||||
style="width: {progress.percentage}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categories -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#each achievementCategories as category (category.name)}
|
||||
{@const achievements = getAchievementsForCategory(category.ids)}
|
||||
{@const unlockedCount = achievements.filter((a) => a.unlocked).length}
|
||||
|
||||
<div class="border-b border-[var(--border-color)]">
|
||||
<button
|
||||
onclick={() =>
|
||||
(selectedCategory = selectedCategory === category.name ? null : category.name)}
|
||||
onkeydown={(e) =>
|
||||
e.key === "Enter" &&
|
||||
(selectedCategory = selectedCategory === category.name ? null : category.name)}
|
||||
class="w-full p-4 text-left hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold text-[var(--text-primary)]">{category.name}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{category.description}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{unlockedCount} / {achievements.length}
|
||||
</span>
|
||||
<svg
|
||||
class="w-5 h-5 transition-transform {selectedCategory === category.name
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if selectedCategory === category.name}
|
||||
<div class="p-4 space-y-3" transition:slide={{ duration: 200, easing: quintOut }}>
|
||||
{#each achievements as achievement (achievement.id)}
|
||||
<div
|
||||
class="p-3 rounded-lg border {achievement.unlocked
|
||||
? 'border-[var(--border-color)] bg-[var(--bg-secondary)]'
|
||||
: 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-800 opacity-50'}"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Icon -->
|
||||
<div class="text-3xl flex-shrink-0 {achievement.unlocked ? '' : 'grayscale'}">
|
||||
{achievement.icon}
|
||||
</div>
|
||||
|
||||
<!-- Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="font-semibold text-[var(--text-primary)]">
|
||||
{achievement.name}
|
||||
</h4>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full {getRarityBg(
|
||||
achievement.rarity
|
||||
)} {getRarityColor(achievement.rarity)} capitalize"
|
||||
>
|
||||
{achievement.rarity}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{achievement.description}
|
||||
</p>
|
||||
|
||||
{#if achievement.unlocked && achievement.unlockedAt}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500 mt-2">
|
||||
Unlocked {formatDate(achievement.unlockedAt)}
|
||||
</p>
|
||||
{:else if achievement.maxProgress && achievement.progress !== undefined}
|
||||
<!-- Progress bar for locked achievements -->
|
||||
<div class="mt-2">
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||
<span>Progress</span>
|
||||
<span>{achievement.progress} / {achievement.maxProgress}</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-300 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div
|
||||
class="bg-gray-500 h-1.5 rounded-full transition-all duration-300"
|
||||
style="width: {Math.min(
|
||||
(achievement.progress / achievement.maxProgress) * 100,
|
||||
100
|
||||
)}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Footer with last unlocked -->
|
||||
{#if achievementsState.lastUnlocked}
|
||||
<div class="p-4 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Last Unlocked:</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl">{achievementsState.lastUnlocked.icon}</span>
|
||||
<div>
|
||||
<p class="font-semibold text-[var(--text-primary)]">
|
||||
{achievementsState.lastUnlocked.name}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{formatDate(achievementsState.lastUnlocked.unlockedAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Custom scrollbar for achievement list */
|
||||
:global(.overflow-y-auto::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
:global(.overflow-y-auto::-webkit-scrollbar-track) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
:global(.overflow-y-auto::-webkit-scrollbar-thumb) {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:global(.overflow-y-auto::-webkit-scrollbar-thumb:hover) {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,279 @@
|
||||
<script lang="ts">
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { onMount } from "svelte";
|
||||
import type { Conversation } from "$lib/stores/conversations";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
|
||||
let conversations: Map<string, Conversation> = new Map();
|
||||
let activeConversationId: string | null = null;
|
||||
let editingTabId: string | null = null;
|
||||
let editingName = "";
|
||||
|
||||
// Track which conversation actually has the Claude connection
|
||||
let connectedConversationId: string | null = null;
|
||||
|
||||
// Track last seen message count for each conversation
|
||||
let lastSeenMessageCount = new SvelteMap<string, number>();
|
||||
|
||||
claudeStore.conversations.subscribe((convs) => {
|
||||
conversations = convs;
|
||||
|
||||
// Update the last seen count for the active conversation
|
||||
if (activeConversationId) {
|
||||
const activeConv = convs.get(activeConversationId);
|
||||
if (activeConv) {
|
||||
lastSeenMessageCount.set(activeConversationId, activeConv.terminalLines.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
async function switchTab(id: string) {
|
||||
if (editingTabId) {
|
||||
saveTabName();
|
||||
}
|
||||
await claudeStore.switchConversation(id);
|
||||
|
||||
// Mark messages as seen when switching to this tab
|
||||
const conv = conversations.get(id);
|
||||
if (conv) {
|
||||
lastSeenMessageCount.set(id, conv.terminalLines.length);
|
||||
// Trigger reactivity
|
||||
lastSeenMessageCount = lastSeenMessageCount;
|
||||
}
|
||||
}
|
||||
|
||||
function deleteTab(id: string, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
if (conversations.size > 1) {
|
||||
claudeStore.deleteConversation(id);
|
||||
}
|
||||
}
|
||||
|
||||
function startEditing(id: string, name: string, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
editingTabId = id;
|
||||
editingName = name;
|
||||
// Focus input after DOM update
|
||||
setTimeout(() => {
|
||||
const input = document.querySelector('.tab-item input[type="text"]') as HTMLInputElement;
|
||||
if (input) input.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function saveTabName() {
|
||||
if (editingTabId && editingName.trim()) {
|
||||
claudeStore.renameConversation(editingTabId, editingName.trim());
|
||||
}
|
||||
editingTabId = null;
|
||||
editingName = "";
|
||||
}
|
||||
|
||||
function getConnectionStatusColor(status: Conversation["connectionStatus"]): string {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return "bg-green-500";
|
||||
case "connecting":
|
||||
return "bg-yellow-500";
|
||||
case "disconnected":
|
||||
return "bg-red-500";
|
||||
default:
|
||||
return "bg-gray-500";
|
||||
}
|
||||
}
|
||||
|
||||
function hasUnreadMessages(id: string, conversation: Conversation): boolean {
|
||||
if (id === activeConversationId) return false; // Active tab never has unread
|
||||
const lastSeen = lastSeenMessageCount.get(id) || 0;
|
||||
return conversation.terminalLines.length > lastSeen;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter") {
|
||||
saveTabName();
|
||||
} else if (event.key === "Escape") {
|
||||
editingTabId = null;
|
||||
editingName = "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleTabKeydown(id: string, event: KeyboardEvent) {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
switchTab(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
onMount(() => {
|
||||
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||
// Ctrl/Cmd + T: New tab
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "t") {
|
||||
event.preventDefault();
|
||||
createNewTab();
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
if (currentIndex !== -1) {
|
||||
const nextIndex = (currentIndex + 1) % tabs.length;
|
||||
claudeStore.switchConversation(tabs[nextIndex]);
|
||||
}
|
||||
}
|
||||
// Ctrl/Cmd + Shift + Tab: Previous tab
|
||||
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const tabs = Array.from(conversations.keys());
|
||||
const currentIndex = tabs.findIndex((id) => id === activeConversationId);
|
||||
if (currentIndex !== -1) {
|
||||
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
||||
claudeStore.switchConversation(tabs[prevIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleGlobalKeydown);
|
||||
return () => window.removeEventListener("keydown", handleGlobalKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<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)}
|
||||
<div
|
||||
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all
|
||||
{id === activeConversationId
|
||||
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
|
||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
|
||||
onclick={() => switchTab(id)}
|
||||
onkeydown={(e) => handleTabKeydown(id, e)}
|
||||
role="tab"
|
||||
tabindex={0}
|
||||
aria-selected={id === activeConversationId}
|
||||
>
|
||||
{#if editingTabId === id}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editingName}
|
||||
onblur={saveTabName}
|
||||
onkeydown={handleKeydown}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32"
|
||||
/>
|
||||
{:else}
|
||||
<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)'
|
||||
: ''}"
|
||||
></div>
|
||||
<span
|
||||
class="text-sm pr-6 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"
|
||||
title="New messages"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#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"
|
||||
title="Close tab"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
onclick={createNewTab}
|
||||
class="new-tab-btn flex items-center justify-center w-7 h-7 rounded hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)] transition-colors"
|
||||
title="New conversation (Ctrl+T)"
|
||||
>
|
||||
<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="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.terminal-tabs {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
min-width: 100px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
console.log("ConversationTabs component loading...");
|
||||
</script>
|
||||
|
||||
<div class="terminal-tabs" style="background: red; height: 36px; color: white;">
|
||||
Debug: Tabs Component Loaded
|
||||
</div>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: "Getting Started",
|
||||
items: [
|
||||
"Enter your Claude API key in Settings (gear icon)",
|
||||
"Set your working directory and click Connect",
|
||||
"Start chatting with Hikari - your AI assistant!",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Key Features",
|
||||
items: [
|
||||
"🗂️ File Management: Hikari can read, write, and edit files in your project",
|
||||
"💻 Terminal Access: Execute commands and run scripts",
|
||||
"🔍 Code Search: Find files and search through code",
|
||||
"🌐 Web Access: Fetch information from the web",
|
||||
"📊 MCP Servers: Connect to external tools via Model Context Protocol",
|
||||
"📁 Multi-tab Support: Work on multiple conversations simultaneously",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Available Commands",
|
||||
items: [
|
||||
"Type naturally - Hikari understands context!",
|
||||
"Ask to read, create, or modify files",
|
||||
"Request code explanations or debugging help",
|
||||
"Have Hikari run tests or build commands",
|
||||
"Search for specific functions or patterns",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Tips & Tricks",
|
||||
items: [
|
||||
"💡 Use the stats panel to track your usage",
|
||||
"🎯 Be specific about file paths and requirements",
|
||||
"🔒 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",
|
||||
],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onClose}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="help-title"
|
||||
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>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-gray-500 hover:text-gray-300 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 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">
|
||||
{#each section.items as item (item)}
|
||||
<li class="flex items-start">
|
||||
<span class="text-[var(--accent-primary)] mr-2 mt-0.5">•</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="pt-4 border-t border-[var(--border-color)]">
|
||||
<p class="text-sm text-gray-500">
|
||||
<strong>Need more help?</strong> Join our Discord community for support and updates!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Ensure the panel appears above other content */
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar styling */
|
||||
.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>
|
||||
@@ -1,17 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { handleNewUserMessage } from "$lib/notifications/rules";
|
||||
import { setSkipNextGreeting } from "$lib/tauri";
|
||||
import {
|
||||
setShouldRestoreHistory,
|
||||
setSavedHistory,
|
||||
getShouldRestoreHistory,
|
||||
getSavedHistory,
|
||||
clearHistoryRestore,
|
||||
} from "$lib/stores/historyRestore";
|
||||
import MessageModeSelector from "$lib/components/MessageModeSelector.svelte";
|
||||
import { getCurrentMode } from "$lib/stores/messageMode";
|
||||
import { formatMessageWithMode } from "$lib/types/messageMode";
|
||||
|
||||
let inputValue = $state("");
|
||||
let isSubmitting = $state(false);
|
||||
let isConnected = $state(false);
|
||||
let isProcessing = $state(false);
|
||||
|
||||
claudeStore.connectionStatus.subscribe((status) => {
|
||||
isConnected = status === "connected";
|
||||
});
|
||||
|
||||
isClaudeProcessing.subscribe((processing) => {
|
||||
isProcessing = processing;
|
||||
});
|
||||
|
||||
async function handleSubmit(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -21,14 +38,43 @@
|
||||
isSubmitting = true;
|
||||
inputValue = "";
|
||||
|
||||
// Apply mode prefix if needed
|
||||
const currentMode = getCurrentMode();
|
||||
const formattedMessage = formatMessageWithMode(message, currentMode);
|
||||
|
||||
// Check if we need to restore conversation history
|
||||
let messageToSend = formattedMessage;
|
||||
if (getShouldRestoreHistory()) {
|
||||
const savedHistory = getSavedHistory();
|
||||
|
||||
if (savedHistory) {
|
||||
// Prepend the conversation history with a context message
|
||||
messageToSend = `[Previous conversation context:]
|
||||
${savedHistory}
|
||||
|
||||
[Continuing conversation after reconnection:]
|
||||
User: ${formattedMessage}`;
|
||||
|
||||
// Clear the restoration flags
|
||||
clearHistoryRestore();
|
||||
}
|
||||
}
|
||||
|
||||
// Reset notification state for new user message
|
||||
handleNewUserMessage();
|
||||
|
||||
claudeStore.addLine("user", message);
|
||||
claudeStore.addLine("user", formattedMessage);
|
||||
characterState.setState("thinking");
|
||||
|
||||
try {
|
||||
await invoke("send_prompt", { message });
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
await invoke("send_prompt", {
|
||||
conversationId,
|
||||
message: messageToSend,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send prompt:", error);
|
||||
claudeStore.addLine("error", `Failed to send: ${error}`);
|
||||
@@ -38,6 +84,60 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInterrupt() {
|
||||
// Save the conversation history FIRST before anything else
|
||||
const history = claudeStore.getConversationHistory();
|
||||
|
||||
if (history) {
|
||||
setSavedHistory(history);
|
||||
setShouldRestoreHistory(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
await invoke("interrupt_claude", { conversationId });
|
||||
claudeStore.addLine("system", "Process interrupted - reconnecting...");
|
||||
characterState.setState("idle");
|
||||
|
||||
// Show connecting status while we reconnect
|
||||
claudeStore.setConnectionStatus("connecting");
|
||||
|
||||
// Auto-reconnect after a brief delay
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
|
||||
// Get current working directory before reconnecting
|
||||
const workingDir = await invoke<string>("get_working_directory", { conversationId });
|
||||
|
||||
// Set the flag to skip greeting on next connection
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
// Reconnect to Claude
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDir,
|
||||
},
|
||||
});
|
||||
} catch (reconnectError) {
|
||||
console.error("Failed to auto-reconnect:", reconnectError);
|
||||
claudeStore.addLine("error", `Failed to reconnect: ${reconnectError}`);
|
||||
claudeStore.addLine("system", "Please manually reconnect to continue");
|
||||
}
|
||||
}, 500); // Brief delay to ensure process is fully terminated
|
||||
} catch (error) {
|
||||
console.error("Failed to interrupt:", error);
|
||||
claudeStore.addLine("error", `Failed to interrupt: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
handleSubmit(event);
|
||||
@@ -45,7 +145,12 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSubmit} class="input-bar flex gap-3 items-end">
|
||||
<form onsubmit={handleSubmit} class="input-bar">
|
||||
<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">
|
||||
<textarea
|
||||
bind:value={inputValue}
|
||||
@@ -54,13 +159,25 @@
|
||||
disabled={!isConnected || isSubmitting}
|
||||
rows={1}
|
||||
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
|
||||
rounded-lg text-white placeholder-gray-500 resize-none
|
||||
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"
|
||||
></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
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isConnected || isSubmitting || !inputValue.trim()}
|
||||
@@ -75,4 +192,26 @@
|
||||
Send
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.input-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import { MESSAGE_MODES, type MessageMode } from "$lib/types/messageMode";
|
||||
import { messageMode } from "$lib/stores/messageMode";
|
||||
|
||||
let currentMode = $state("chat");
|
||||
let isOpen = $state(false);
|
||||
|
||||
messageMode.subscribe((mode) => {
|
||||
currentMode = mode;
|
||||
});
|
||||
|
||||
let selectedMode = $derived(MESSAGE_MODES.find((m) => m.id === currentMode) || MESSAGE_MODES[0]);
|
||||
|
||||
function selectMode(mode: MessageMode) {
|
||||
messageMode.set(mode.id);
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function toggleDropdown(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
isOpen = !isOpen;
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
function handleClickOutside() {
|
||||
if (isOpen) {
|
||||
isOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} />
|
||||
|
||||
<div class="mode-selector-container">
|
||||
<button
|
||||
class="mode-selector-button"
|
||||
onclick={toggleDropdown}
|
||||
title={`Current mode: ${selectedMode.name} - ${selectedMode.description}`}
|
||||
>
|
||||
<span class="mode-icon">{selectedMode.icon}</span>
|
||||
<span class="mode-name">{selectedMode.name}</span>
|
||||
<svg class="dropdown-arrow" class:open={isOpen} width="12" height="12" viewBox="0 0 12 12">
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" fill="none" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="dropdown-menu">
|
||||
{#each MESSAGE_MODES as mode (mode.id)}
|
||||
<button
|
||||
class="dropdown-item"
|
||||
class:active={mode.id === currentMode}
|
||||
onclick={() => selectMode(mode)}
|
||||
>
|
||||
<span class="mode-icon">{mode.icon}</span>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">{mode.name}</div>
|
||||
<div class="mode-description">{mode.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mode-selector-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mode-selector-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mode-selector-button:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.mode-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.mode-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
margin-left: 4px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-arrow.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 280px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.dropdown-item.active {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mode-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dropdown-item .mode-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.mode-description {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore, hasPermissionPending } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import type { PermissionRequest } from "$lib/types/messages";
|
||||
@@ -45,12 +46,18 @@
|
||||
|
||||
// Stop current session and reconnect with new permissions
|
||||
try {
|
||||
await invoke("stop_claude");
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
|
||||
await invoke("stop_claude", { conversationId });
|
||||
|
||||
// Small delay to ensure clean shutdown
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDirectory || "/home/naomi",
|
||||
allowed_tools: newGrantedTools,
|
||||
@@ -72,7 +79,10 @@ ${JSON.stringify(toolInput, null, 2)}
|
||||
|
||||
Please continue where we left off and retry that action now that you have permission.`;
|
||||
|
||||
await invoke("send_prompt", { message: contextMessage });
|
||||
await invoke("send_prompt", {
|
||||
conversationId,
|
||||
message: contextMessage,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to reconnect:", error);
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
import { formattedStats } from "$lib/stores/stats";
|
||||
import { fade } from "svelte/transition";
|
||||
|
||||
let showToolsBreakdown = false;
|
||||
</script>
|
||||
|
||||
<div class="stats-display" transition:fade={{ duration: 200 }}>
|
||||
<div class="stats-row">
|
||||
<span class="stat-label">Duration:</span>
|
||||
<span class="stat-value">{$formattedStats.sessionDuration}</span>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
<span class="stat-label">Messages:</span>
|
||||
<span class="stat-value">{$formattedStats.messagesSession}</span>
|
||||
<span class="stat-secondary">/ {$formattedStats.messagesTotal}</span>
|
||||
</div>
|
||||
|
||||
<div class="stats-section">
|
||||
<h3>Tokens & Cost</h3>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Session:</span>
|
||||
<span class="stat-value">{$formattedStats.sessionTokens}</span>
|
||||
<span class="stat-cost">{$formattedStats.sessionCost}</span>
|
||||
</div>
|
||||
<div class="stat-row stat-detail">
|
||||
<span class="stat-label">Input:</span>
|
||||
<span class="stat-value">{$formattedStats.sessionInputTokens}</span>
|
||||
</div>
|
||||
<div class="stat-row stat-detail">
|
||||
<span class="stat-label">Output:</span>
|
||||
<span class="stat-value">{$formattedStats.sessionOutputTokens}</span>
|
||||
</div>
|
||||
<div class="stat-row stat-highlight">
|
||||
<span class="stat-label">Total:</span>
|
||||
<span class="stat-value">{$formattedStats.totalTokens}</span>
|
||||
<span class="stat-cost">{$formattedStats.totalCost}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-section">
|
||||
<h3>Activity</h3>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Code blocks:</span>
|
||||
<span class="stat-value">{$formattedStats.codeBlocksSession}</span>
|
||||
<span class="stat-secondary">/ {$formattedStats.codeBlocksTotal}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Files edited:</span>
|
||||
<span class="stat-value">{$formattedStats.filesEditedSession}</span>
|
||||
<span class="stat-secondary">/ {$formattedStats.filesEditedTotal}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Files created:</span>
|
||||
<span class="stat-value">{$formattedStats.filesCreatedSession}</span>
|
||||
<span class="stat-secondary">/ {$formattedStats.filesCreatedTotal}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if Object.keys($formattedStats.sessionToolsUsage).length > 0}
|
||||
<div class="stats-section">
|
||||
<h3 class="tools-header">
|
||||
<button class="tools-toggle" onclick={() => (showToolsBreakdown = !showToolsBreakdown)}>
|
||||
Tools Used
|
||||
<span class="toggle-icon">{showToolsBreakdown ? "▼" : "▶"}</span>
|
||||
</button>
|
||||
</h3>
|
||||
{#if showToolsBreakdown}
|
||||
<div class="tools-breakdown">
|
||||
{#each Object.entries($formattedStats.sessionToolsUsage).sort((a, b) => b[1] - a[1]) as [tool, count] (tool)}
|
||||
<div class="stat-row stat-detail">
|
||||
<span class="stat-label">{tool}:</span>
|
||||
<span class="stat-value">{count}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="model-info">
|
||||
<span class="model-label">Model:</span>
|
||||
<span class="model-value">{$formattedStats.model}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stats-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
box-shadow:
|
||||
0 4px 6px rgba(0, 0, 0, 0.1),
|
||||
0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stats-section h3 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.25rem 0;
|
||||
padding-bottom: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0;
|
||||
}
|
||||
|
||||
.stat-detail {
|
||||
margin-left: 1rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stat-highlight {
|
||||
font-weight: 600;
|
||||
color: var(--accent-primary);
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--text-primary, #e5e7eb);
|
||||
}
|
||||
|
||||
.model-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.model-label {
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.model-value {
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--text-primary, #e5e7eb);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,26 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
onToggleAchievements?: () => void;
|
||||
}
|
||||
|
||||
const { onToggleAchievements = () => {} }: Props = $props();
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { configStore, type HikariConfig } from "$lib/stores/config";
|
||||
import type { ConnectionStatus } from "$lib/types/messages";
|
||||
import { onMount } from "svelte";
|
||||
import StatsDisplay from "./StatsDisplay.svelte";
|
||||
import AboutPanel from "./AboutPanel.svelte";
|
||||
import HelpPanel from "./HelpPanel.svelte";
|
||||
import { achievementProgress } from "$lib/stores/achievements";
|
||||
|
||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||
const DONATE_URL = "https://donate.nhcarrigan.com";
|
||||
|
||||
let connectionStatus: ConnectionStatus = $state("disconnected");
|
||||
let workingDirectory = $state("");
|
||||
@@ -16,6 +28,10 @@
|
||||
let isConnecting = $state(false);
|
||||
let grantedToolsList: string[] = $state([]);
|
||||
let appVersion = $state("");
|
||||
let showStats = $state(false);
|
||||
let showAbout = $state(false);
|
||||
let showHelp = $state(false);
|
||||
const progress = $derived($achievementProgress);
|
||||
let currentConfig: HikariConfig = $state({
|
||||
model: null,
|
||||
api_key: null,
|
||||
@@ -77,7 +93,12 @@
|
||||
];
|
||||
|
||||
try {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: targetDir,
|
||||
model: currentConfig.model || null,
|
||||
@@ -95,7 +116,11 @@
|
||||
|
||||
async function handleDisconnect() {
|
||||
try {
|
||||
await invoke("stop_claude");
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
await invoke("stop_claude", { conversationId });
|
||||
} catch (error) {
|
||||
console.error("Failed to stop Claude:", error);
|
||||
}
|
||||
@@ -126,6 +151,10 @@
|
||||
return "Disconnected";
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAchievements() {
|
||||
onToggleAchievements();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -167,6 +196,36 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
onclick={toggleAchievements}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors relative"
|
||||
title="Achievements"
|
||||
>
|
||||
<span class="text-lg">🏆</span>
|
||||
{#if progress.unlocked > 0}
|
||||
<span
|
||||
class="absolute -top-1 -right-1 bg-[var(--accent-primary)] text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px]"
|
||||
>
|
||||
{progress.unlocked}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showStats = !showStats)}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors {showStats
|
||||
? 'text-[var(--accent-primary)]'
|
||||
: ''}"
|
||||
title="Usage Stats"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zM13 19v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2h2a2 2 0 002-2zM21 19V8a2 2 0 00-2-2h-2a2 2 0 00-2 2v11a2 2 0 002 2h2a2 2 0 002-2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={configStore.openSidebar}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
|
||||
@@ -187,6 +246,45 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => openUrl(DONATE_URL)}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
|
||||
title="Support our work"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showAbout = true)}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
|
||||
title="About Hikari Desktop"
|
||||
>
|
||||
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showHelp = true)}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
|
||||
title="Help"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => openUrl(DISCORD_URL)}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
|
||||
@@ -201,6 +299,12 @@
|
||||
{#if appVersion}
|
||||
<span class="text-xs text-gray-600">v{appVersion}</span>
|
||||
{/if}
|
||||
|
||||
{#if showStats}
|
||||
<div class="absolute top-full right-0 mt-2 mr-4 z-50">
|
||||
<StatsDisplay />
|
||||
</div>
|
||||
{/if}
|
||||
{#if connectionStatus === "connected"}
|
||||
<button
|
||||
onclick={handleDisconnect}
|
||||
@@ -219,3 +323,20 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showStats}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div>
|
||||
<div class="fixed top-14 right-4 z-50">
|
||||
<StatsDisplay />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showAbout}
|
||||
<AboutPanel onClose={() => (showAbout = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showHelp}
|
||||
<HelpPanel onClose={() => (showHelp = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
||||
import { afterUpdate } from "svelte";
|
||||
import ConversationTabs from "./ConversationTabs.svelte";
|
||||
|
||||
let terminalElement: HTMLDivElement;
|
||||
let shouldAutoScroll = true;
|
||||
@@ -25,17 +26,17 @@
|
||||
function getLineClass(type: string): string {
|
||||
switch (type) {
|
||||
case "user":
|
||||
return "text-cyan-400";
|
||||
return "terminal-user";
|
||||
case "assistant":
|
||||
return "text-gray-100";
|
||||
return "terminal-assistant";
|
||||
case "system":
|
||||
return "text-gray-500 italic";
|
||||
return "terminal-system italic";
|
||||
case "tool":
|
||||
return "text-purple-400";
|
||||
return "terminal-tool";
|
||||
case "error":
|
||||
return "text-red-400";
|
||||
return "terminal-error";
|
||||
default:
|
||||
return "text-gray-300";
|
||||
return "terminal-default";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,25 +76,29 @@
|
||||
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
<span class="text-sm text-gray-400 ml-2">Terminal</span>
|
||||
<span class="text-sm terminal-header-text ml-2">Terminal</span>
|
||||
</div>
|
||||
|
||||
<ConversationTabs />
|
||||
|
||||
<div
|
||||
bind:this={terminalElement}
|
||||
onscroll={handleScroll}
|
||||
class="terminal-content h-[calc(100%-40px)] overflow-y-auto p-4 font-mono text-sm"
|
||||
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono text-sm"
|
||||
>
|
||||
{#if lines.length === 0}
|
||||
<div class="text-gray-500 italic">Waiting for Claude... Type a message below to start!</div>
|
||||
<div class="terminal-waiting italic">
|
||||
Waiting for Claude... Type a message below to start!
|
||||
</div>
|
||||
{:else}
|
||||
{#each lines as line (line.id)}
|
||||
<div class="terminal-line mb-2 {getLineClass(line.type)}">
|
||||
<span class="text-gray-600 text-xs mr-2">{formatTime(line.timestamp)}</span>
|
||||
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
|
||||
{#if getLinePrefix(line.type)}
|
||||
<span class="text-gray-500 mr-2">{getLinePrefix(line.type)}</span>
|
||||
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
||||
{/if}
|
||||
{#if line.toolName}
|
||||
<span class="text-purple-300 mr-2">[{line.toolName}]</span>
|
||||
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
||||
{/if}
|
||||
<span class="whitespace-pre-wrap">{line.content}</span>
|
||||
</div>
|
||||
@@ -107,4 +112,49 @@
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) var(--bg-terminal);
|
||||
}
|
||||
|
||||
/* Terminal text colors that adapt to theme */
|
||||
.terminal-user {
|
||||
color: var(--terminal-user, #22d3ee);
|
||||
}
|
||||
|
||||
.terminal-assistant {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.terminal-system {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.terminal-tool {
|
||||
color: var(--terminal-tool, #c084fc);
|
||||
}
|
||||
|
||||
.terminal-error {
|
||||
color: var(--terminal-error, #f87171);
|
||||
}
|
||||
|
||||
.terminal-default {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.terminal-timestamp {
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
}
|
||||
|
||||
.terminal-prefix {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.terminal-tool-name {
|
||||
color: var(--terminal-tool-name, #ddd6fe);
|
||||
}
|
||||
|
||||
.terminal-waiting {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.terminal-header-text {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NOTIFICATION_SOUNDS, type NotificationType } from "./types";
|
||||
|
||||
class SoundPlayer {
|
||||
private audioCache: Map<NotificationType, HTMLAudioElement> = new Map();
|
||||
private enabled: boolean = true;
|
||||
private enabled: boolean = false; // Start disabled until config loads
|
||||
private globalVolume: number = 1.0;
|
||||
|
||||
constructor() {
|
||||
|
||||
@@ -4,6 +4,7 @@ export enum NotificationType {
|
||||
PERMISSION = "permission",
|
||||
CONNECTION = "connection",
|
||||
TASK_START = "task_start",
|
||||
ACHIEVEMENT = "achievement",
|
||||
}
|
||||
|
||||
export interface NotificationSound {
|
||||
@@ -45,4 +46,10 @@ export const NOTIFICATION_SOUNDS: Record<NotificationType, NotificationSound> =
|
||||
phrase: "Working on it!",
|
||||
volume: 0.6,
|
||||
},
|
||||
[NotificationType.ACHIEVEMENT]: {
|
||||
type: NotificationType.ACHIEVEMENT,
|
||||
filename: "achievement.mp3",
|
||||
phrase: "Achievement Get~!",
|
||||
volume: 0.8,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Achievement sound player using the notification system
|
||||
|
||||
import { soundPlayer } from "$lib/notifications";
|
||||
import { NotificationType } from "$lib/notifications/types";
|
||||
|
||||
export function playAchievementSound() {
|
||||
// Use the soundPlayer which respects global notification settings
|
||||
soundPlayer.play(NotificationType.ACHIEVEMENT);
|
||||
}
|
||||
|
||||
// Test function for development
|
||||
export function testAchievementSound() {
|
||||
try {
|
||||
playAchievementSound();
|
||||
console.log("Achievement sound played successfully!");
|
||||
} catch (error) {
|
||||
console.error("Error playing achievement sound:", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Achievement, AchievementUnlockedEvent, AchievementId } from "$lib/types/achievements";
|
||||
import { playAchievementSound } from "$lib/sounds/achievement";
|
||||
|
||||
interface AchievementState {
|
||||
achievements: Record<AchievementId, Achievement>;
|
||||
totalUnlocked: number;
|
||||
lastUnlocked: Achievement | null;
|
||||
}
|
||||
|
||||
// Initial achievement definitions
|
||||
const achievementDefinitions: Record<
|
||||
AchievementId,
|
||||
Omit<Achievement, "unlocked" | "unlockedAt">
|
||||
> = {
|
||||
// Token milestones
|
||||
FirstSteps: {
|
||||
id: "FirstSteps",
|
||||
name: "First Steps",
|
||||
description: "Generated your first 1,000 tokens",
|
||||
icon: "👶",
|
||||
rarity: "common",
|
||||
maxProgress: 1000,
|
||||
},
|
||||
GrowingStrong: {
|
||||
id: "GrowingStrong",
|
||||
name: "Growing Strong",
|
||||
description: "Reached 10,000 tokens total",
|
||||
icon: "🌱",
|
||||
rarity: "common",
|
||||
maxProgress: 10000,
|
||||
},
|
||||
BlossomingCoder: {
|
||||
id: "BlossomingCoder",
|
||||
name: "Blossoming Coder",
|
||||
description: "Generated 100,000 tokens - you're really growing!",
|
||||
icon: "🌸",
|
||||
rarity: "rare",
|
||||
maxProgress: 100000,
|
||||
},
|
||||
TokenMaster: {
|
||||
id: "TokenMaster",
|
||||
name: "Token Master",
|
||||
description: "One million tokens! You're unstoppable!",
|
||||
icon: "👑",
|
||||
rarity: "legendary",
|
||||
maxProgress: 1000000,
|
||||
},
|
||||
|
||||
// Code generation
|
||||
HelloWorld: {
|
||||
id: "HelloWorld",
|
||||
name: "Hello, World!",
|
||||
description: "Generated your first code block",
|
||||
icon: "👋",
|
||||
rarity: "common",
|
||||
maxProgress: 1,
|
||||
},
|
||||
CodeWizard: {
|
||||
id: "CodeWizard",
|
||||
name: "Code Wizard",
|
||||
description: "100 code blocks generated",
|
||||
icon: "🧙♀️",
|
||||
rarity: "rare",
|
||||
maxProgress: 100,
|
||||
},
|
||||
ThousandBlocks: {
|
||||
id: "ThousandBlocks",
|
||||
name: "Thousand Blocks",
|
||||
description: "1,000 code blocks! You're a code machine!",
|
||||
icon: "🏗️",
|
||||
rarity: "epic",
|
||||
maxProgress: 1000,
|
||||
},
|
||||
|
||||
// File operations
|
||||
FileManipulator: {
|
||||
id: "FileManipulator",
|
||||
name: "File Manipulator",
|
||||
description: "Edited 10 files",
|
||||
icon: "📝",
|
||||
rarity: "common",
|
||||
maxProgress: 10,
|
||||
},
|
||||
FileArchitect: {
|
||||
id: "FileArchitect",
|
||||
name: "File Architect",
|
||||
description: "Created or edited 100 files",
|
||||
icon: "🏛️",
|
||||
rarity: "rare",
|
||||
maxProgress: 100,
|
||||
},
|
||||
|
||||
// Conversation milestones
|
||||
ConversationStarter: {
|
||||
id: "ConversationStarter",
|
||||
name: "Conversation Starter",
|
||||
description: "Exchanged 10 messages",
|
||||
icon: "💬",
|
||||
rarity: "common",
|
||||
maxProgress: 10,
|
||||
},
|
||||
ChattyKathy: {
|
||||
id: "ChattyKathy",
|
||||
name: "Chatty Kathy",
|
||||
description: "100 messages exchanged",
|
||||
icon: "🗣️",
|
||||
rarity: "common",
|
||||
maxProgress: 100,
|
||||
},
|
||||
Conversationalist: {
|
||||
id: "Conversationalist",
|
||||
name: "Master Conversationalist",
|
||||
description: "1,000 messages! We're really connecting!",
|
||||
icon: "💖",
|
||||
rarity: "rare",
|
||||
maxProgress: 1000,
|
||||
},
|
||||
|
||||
// Tool usage
|
||||
Toolsmith: {
|
||||
id: "Toolsmith",
|
||||
name: "Toolsmith",
|
||||
description: "Used 5 different tools",
|
||||
icon: "🔨",
|
||||
rarity: "common",
|
||||
maxProgress: 5,
|
||||
},
|
||||
ToolMaster: {
|
||||
id: "ToolMaster",
|
||||
name: "Tool Master",
|
||||
description: "Used 10 different tools efficiently",
|
||||
icon: "🛠️",
|
||||
rarity: "rare",
|
||||
maxProgress: 10,
|
||||
},
|
||||
|
||||
// Time-based achievements
|
||||
EarlyBird: {
|
||||
id: "EarlyBird",
|
||||
name: "Early Bird",
|
||||
description: "Started a session between 5 AM and 7 AM",
|
||||
icon: "🌅",
|
||||
rarity: "common",
|
||||
},
|
||||
NightOwl: {
|
||||
id: "NightOwl",
|
||||
name: "Night Owl",
|
||||
description: "Coding after midnight",
|
||||
icon: "🦉",
|
||||
rarity: "common",
|
||||
},
|
||||
AllNighter: {
|
||||
id: "AllNighter",
|
||||
name: "All Nighter",
|
||||
description: "Worked through the night (2 AM - 5 AM)",
|
||||
icon: "🌙",
|
||||
rarity: "rare",
|
||||
},
|
||||
WeekendWarrior: {
|
||||
id: "WeekendWarrior",
|
||||
name: "Weekend Warrior",
|
||||
description: "Coding on a weekend",
|
||||
icon: "⚔️",
|
||||
rarity: "common",
|
||||
},
|
||||
DedicatedDeveloper: {
|
||||
id: "DedicatedDeveloper",
|
||||
name: "Dedicated Developer",
|
||||
description: "Coded for 30 days in a row",
|
||||
icon: "🏆",
|
||||
rarity: "legendary",
|
||||
},
|
||||
|
||||
// Search and exploration
|
||||
Explorer: {
|
||||
id: "Explorer",
|
||||
name: "Explorer",
|
||||
description: "Used search tools 50 times",
|
||||
icon: "🔍",
|
||||
rarity: "common",
|
||||
maxProgress: 50,
|
||||
},
|
||||
MasterSearcher: {
|
||||
id: "MasterSearcher",
|
||||
name: "Master Searcher",
|
||||
description: "Searched 500 times across files",
|
||||
icon: "🕵️♀️",
|
||||
rarity: "rare",
|
||||
maxProgress: 500,
|
||||
},
|
||||
|
||||
// Session achievements
|
||||
QuickSession: {
|
||||
id: "QuickSession",
|
||||
name: "Quick Session",
|
||||
description: "Completed a productive session in under 5 minutes",
|
||||
icon: "⚡",
|
||||
rarity: "common",
|
||||
},
|
||||
FocusedWork: {
|
||||
id: "FocusedWork",
|
||||
name: "Focused Work",
|
||||
description: "Worked for 30 minutes straight",
|
||||
icon: "🎯",
|
||||
rarity: "common",
|
||||
},
|
||||
DeepDive: {
|
||||
id: "DeepDive",
|
||||
name: "Deep Dive",
|
||||
description: "Worked for 2 hours continuously",
|
||||
icon: "🏊♀️",
|
||||
rarity: "rare",
|
||||
},
|
||||
MarathonSession: {
|
||||
id: "MarathonSession",
|
||||
name: "Marathon Session",
|
||||
description: "5+ hour coding session!",
|
||||
icon: "🏃♀️",
|
||||
rarity: "epic",
|
||||
},
|
||||
|
||||
// Special achievements
|
||||
FirstMessage: {
|
||||
id: "FirstMessage",
|
||||
name: "First Message",
|
||||
description: "Sent your first message to Hikari",
|
||||
icon: "✨",
|
||||
rarity: "common",
|
||||
maxProgress: 1,
|
||||
},
|
||||
FirstTool: {
|
||||
id: "FirstTool",
|
||||
name: "First Tool",
|
||||
description: "Used your first tool",
|
||||
icon: "🔧",
|
||||
rarity: "common",
|
||||
maxProgress: 1,
|
||||
},
|
||||
FirstCodeBlock: {
|
||||
id: "FirstCodeBlock",
|
||||
name: "First Code",
|
||||
description: "Generated your first code block",
|
||||
icon: "📦",
|
||||
rarity: "common",
|
||||
maxProgress: 1,
|
||||
},
|
||||
FirstFileEdit: {
|
||||
id: "FirstFileEdit",
|
||||
name: "First Edit",
|
||||
description: "Made your first file edit",
|
||||
icon: "✏️",
|
||||
rarity: "common",
|
||||
maxProgress: 1,
|
||||
},
|
||||
Polyglot: {
|
||||
id: "Polyglot",
|
||||
name: "Polyglot",
|
||||
description: "Generated code in 5+ languages in one session",
|
||||
icon: "🌍",
|
||||
rarity: "rare",
|
||||
maxProgress: 5,
|
||||
},
|
||||
SpeedCoder: {
|
||||
id: "SpeedCoder",
|
||||
name: "Speed Coder",
|
||||
description: "Generated 10 code blocks in 10 minutes",
|
||||
icon: "🚀",
|
||||
rarity: "rare",
|
||||
},
|
||||
ClaudeConnoisseur: {
|
||||
id: "ClaudeConnoisseur",
|
||||
name: "Claude Connoisseur",
|
||||
description: "Used all available Claude models",
|
||||
icon: "🎨",
|
||||
rarity: "epic",
|
||||
maxProgress: 5, // Adjust based on available models
|
||||
},
|
||||
MarathonCoder: {
|
||||
id: "MarathonCoder",
|
||||
name: "Marathon Coder",
|
||||
description: "10,000 tokens in a single session",
|
||||
icon: "🏃♂️",
|
||||
rarity: "epic",
|
||||
maxProgress: 10000,
|
||||
},
|
||||
|
||||
// Relationship & Greetings
|
||||
GoodMorning: {
|
||||
id: "GoodMorning",
|
||||
name: "Good Morning!",
|
||||
description: "Greeted Hikari with a good morning",
|
||||
icon: "🌅",
|
||||
rarity: "common",
|
||||
maxProgress: 1,
|
||||
},
|
||||
GoodNight: {
|
||||
id: "GoodNight",
|
||||
name: "Sweet Dreams",
|
||||
description: "Said good night to Hikari",
|
||||
icon: "🌙",
|
||||
rarity: "common",
|
||||
maxProgress: 1,
|
||||
},
|
||||
ThankYou: {
|
||||
id: "ThankYou",
|
||||
name: "Grateful Heart",
|
||||
description: "Thanked Hikari for her help",
|
||||
icon: "🙏",
|
||||
rarity: "common",
|
||||
maxProgress: 1,
|
||||
},
|
||||
LoveYou: {
|
||||
id: "LoveYou",
|
||||
name: "Heartfelt",
|
||||
description: "Expressed love to Hikari",
|
||||
icon: "💕",
|
||||
rarity: "rare",
|
||||
maxProgress: 1,
|
||||
},
|
||||
|
||||
// Personality & Fun
|
||||
EmojiUser: {
|
||||
id: "EmojiUser",
|
||||
name: "Emoji Master",
|
||||
description: "Used 20+ emojis in messages",
|
||||
icon: "😄",
|
||||
rarity: "common",
|
||||
maxProgress: 20,
|
||||
},
|
||||
CapsLock: {
|
||||
id: "CapsLock",
|
||||
name: "CAPS LOCK",
|
||||
description: "SENT A MESSAGE IN ALL CAPS",
|
||||
icon: "🔊",
|
||||
rarity: "common",
|
||||
maxProgress: 1,
|
||||
},
|
||||
QuestionMaster: {
|
||||
id: "QuestionMaster",
|
||||
name: "Question Master",
|
||||
description: "Asked 50 questions",
|
||||
icon: "❓",
|
||||
rarity: "common",
|
||||
maxProgress: 50,
|
||||
},
|
||||
PleaseAndThankYou: {
|
||||
id: "PleaseAndThankYou",
|
||||
name: "Polite Programmer",
|
||||
description: "Always says please and thank you",
|
||||
icon: "🎩",
|
||||
rarity: "common",
|
||||
maxProgress: 10,
|
||||
},
|
||||
|
||||
// Git & Development
|
||||
CommitMaster: {
|
||||
id: "CommitMaster",
|
||||
name: "Commit Master",
|
||||
description: "Made 100 commits through Hikari",
|
||||
icon: "📝",
|
||||
rarity: "rare",
|
||||
maxProgress: 100,
|
||||
},
|
||||
PRO: {
|
||||
id: "PRO",
|
||||
name: "PRO",
|
||||
description: "Created 10 pull requests",
|
||||
icon: "🔀",
|
||||
rarity: "rare",
|
||||
maxProgress: 10,
|
||||
},
|
||||
Reviewer: {
|
||||
id: "Reviewer",
|
||||
name: "Code Reviewer",
|
||||
description: "Reviewed 10 pull requests",
|
||||
icon: "👀",
|
||||
rarity: "rare",
|
||||
maxProgress: 10,
|
||||
},
|
||||
IssueTracker: {
|
||||
id: "IssueTracker",
|
||||
name: "Issue Tracker",
|
||||
description: "Created 25 issues",
|
||||
icon: "🎯",
|
||||
rarity: "rare",
|
||||
maxProgress: 25,
|
||||
},
|
||||
GitGuru: {
|
||||
id: "GitGuru",
|
||||
name: "Git Guru",
|
||||
description: "Mastered git operations",
|
||||
icon: "🌲",
|
||||
rarity: "epic",
|
||||
},
|
||||
|
||||
// Tool Mastery
|
||||
BashMaster: {
|
||||
id: "BashMaster",
|
||||
name: "Bash Master",
|
||||
description: "Used bash commands 100 times",
|
||||
icon: "💻",
|
||||
rarity: "rare",
|
||||
maxProgress: 100,
|
||||
},
|
||||
FileExplorer: {
|
||||
id: "FileExplorer",
|
||||
name: "File Explorer",
|
||||
description: "Explored files 100 times",
|
||||
icon: "📂",
|
||||
rarity: "common",
|
||||
maxProgress: 100,
|
||||
},
|
||||
SearchExpert: {
|
||||
id: "SearchExpert",
|
||||
name: "Search Expert",
|
||||
description: "Mastered advanced search queries",
|
||||
icon: "🔎",
|
||||
rarity: "rare",
|
||||
},
|
||||
AgentCommander: {
|
||||
id: "AgentCommander",
|
||||
name: "Agent Commander",
|
||||
description: "Used task agents effectively",
|
||||
icon: "🤖",
|
||||
rarity: "rare",
|
||||
},
|
||||
MCPMaster: {
|
||||
id: "MCPMaster",
|
||||
name: "MCP Master",
|
||||
description: "Mastered MCP tool usage",
|
||||
icon: "🛠️",
|
||||
rarity: "epic",
|
||||
},
|
||||
};
|
||||
|
||||
// Initialize all achievements as locked
|
||||
const initialAchievements: Record<AchievementId, Achievement> = {} as Record<
|
||||
AchievementId,
|
||||
Achievement
|
||||
>;
|
||||
for (const [id, def] of Object.entries(achievementDefinitions)) {
|
||||
initialAchievements[id as AchievementId] = {
|
||||
...def,
|
||||
unlocked: false,
|
||||
progress: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Create the main store
|
||||
function createAchievementsStore() {
|
||||
const { subscribe, update } = writable<AchievementState>({
|
||||
achievements: initialAchievements,
|
||||
totalUnlocked: 0,
|
||||
lastUnlocked: null,
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
unlockAchievement: (event: AchievementUnlockedEvent, playSound: boolean = true) => {
|
||||
update((state) => {
|
||||
const achievement = state.achievements[event.achievement.id];
|
||||
if (achievement && !achievement.unlocked) {
|
||||
achievement.unlocked = true;
|
||||
achievement.unlockedAt = event.achievement.unlocked_at
|
||||
? new Date(event.achievement.unlocked_at)
|
||||
: new Date();
|
||||
state.totalUnlocked++;
|
||||
state.lastUnlocked = achievement;
|
||||
|
||||
// Play achievement sound only for new unlocks, not when loading saved ones
|
||||
if (playSound) {
|
||||
try {
|
||||
playAchievementSound();
|
||||
} catch (error) {
|
||||
console.error("Failed to play achievement sound:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return state;
|
||||
});
|
||||
},
|
||||
updateProgress: (id: AchievementId, progress: number) => {
|
||||
update((state) => {
|
||||
const achievement = state.achievements[id];
|
||||
if (achievement) {
|
||||
achievement.progress = progress;
|
||||
}
|
||||
return state;
|
||||
});
|
||||
},
|
||||
reset: () => {
|
||||
update(() => ({
|
||||
achievements: initialAchievements,
|
||||
totalUnlocked: 0,
|
||||
lastUnlocked: null,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const achievementsStore = createAchievementsStore();
|
||||
|
||||
// Derived stores for different views
|
||||
export const unlockedAchievements = derived(achievementsStore, ($store) =>
|
||||
Object.values($store.achievements).filter((a) => a.unlocked)
|
||||
);
|
||||
|
||||
export const lockedAchievements = derived(achievementsStore, ($store) =>
|
||||
Object.values($store.achievements).filter((a) => !a.unlocked)
|
||||
);
|
||||
|
||||
export const achievementsByRarity = derived(achievementsStore, ($store) => {
|
||||
const byRarity: Record<string, Achievement[]> = {
|
||||
common: [],
|
||||
rare: [],
|
||||
epic: [],
|
||||
legendary: [],
|
||||
};
|
||||
|
||||
for (const achievement of Object.values($store.achievements)) {
|
||||
byRarity[achievement.rarity].push(achievement);
|
||||
}
|
||||
|
||||
return byRarity;
|
||||
});
|
||||
|
||||
export const achievementProgress = derived(achievementsStore, ($store) => ({
|
||||
unlocked: $store.totalUnlocked,
|
||||
total: Object.keys($store.achievements).length,
|
||||
percentage: Math.round(($store.totalUnlocked / Object.keys($store.achievements).length) * 100),
|
||||
}));
|
||||
|
||||
// Initialize achievement listener
|
||||
export async function initAchievementsListener() {
|
||||
// Listen for achievement unlocked events
|
||||
await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
|
||||
achievementsStore.unlockAchievement(event.payload);
|
||||
});
|
||||
|
||||
// Load saved achievements from persistent storage
|
||||
try {
|
||||
const savedAchievements = await invoke<AchievementUnlockedEvent[]>("load_saved_achievements");
|
||||
|
||||
// Update the store with saved achievements (don't play sounds)
|
||||
for (const event of savedAchievements) {
|
||||
achievementsStore.unlockAchievement(event, false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load saved achievements:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Export achievement categories for the display panel
|
||||
export const achievementCategories = [
|
||||
{
|
||||
name: "Token Milestones",
|
||||
description: "Track your token generation progress",
|
||||
ids: ["FirstSteps", "GrowingStrong", "BlossomingCoder", "TokenMaster"] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Code Generation",
|
||||
description: "Achievements for generating code",
|
||||
ids: ["HelloWorld", "CodeWizard", "ThousandBlocks"] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "File Operations",
|
||||
description: "Working with files and projects",
|
||||
ids: ["FileManipulator", "FileArchitect"] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Conversations",
|
||||
description: "Building our relationship through chat",
|
||||
ids: ["ConversationStarter", "ChattyKathy", "Conversationalist"] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Tools & Skills",
|
||||
description: "Mastering different tools",
|
||||
ids: ["Toolsmith", "ToolMaster"] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Time-Based",
|
||||
description: "When you code matters too!",
|
||||
ids: [
|
||||
"EarlyBird",
|
||||
"NightOwl",
|
||||
"AllNighter",
|
||||
"WeekendWarrior",
|
||||
"DedicatedDeveloper",
|
||||
] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Search & Explore",
|
||||
description: "Finding what you need",
|
||||
ids: ["Explorer", "MasterSearcher"] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Session Records",
|
||||
description: "Your coding session achievements",
|
||||
ids: [
|
||||
"QuickSession",
|
||||
"FocusedWork",
|
||||
"DeepDive",
|
||||
"MarathonSession",
|
||||
"MarathonCoder",
|
||||
] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Relationship & Greetings",
|
||||
description: "Our special moments together",
|
||||
ids: ["GoodMorning", "GoodNight", "ThankYou", "LoveYou"] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Personality & Fun",
|
||||
description: "Express yourself!",
|
||||
ids: ["EmojiUser", "CapsLock", "QuestionMaster", "PleaseAndThankYou"] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Git & Development",
|
||||
description: "Version control mastery",
|
||||
ids: ["CommitMaster", "PRO", "Reviewer", "IssueTracker", "GitGuru"] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Tool Mastery",
|
||||
description: "Master of all tools",
|
||||
ids: [
|
||||
"BashMaster",
|
||||
"FileExplorer",
|
||||
"SearchExpert",
|
||||
"AgentCommander",
|
||||
"MCPMaster",
|
||||
] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Special",
|
||||
description: "Unique accomplishments",
|
||||
ids: [
|
||||
"FirstMessage",
|
||||
"FirstTool",
|
||||
"FirstCodeBlock",
|
||||
"FirstFileEdit",
|
||||
"Polyglot",
|
||||
"SpeedCoder",
|
||||
"ClaudeConnoisseur",
|
||||
] as AchievementId[],
|
||||
},
|
||||
];
|
||||
@@ -1,127 +1,100 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import type { ConnectionStatus, PermissionRequest } from "$lib/types/messages";
|
||||
import { derived } from "svelte/store";
|
||||
import { conversationsStore } from "./conversations";
|
||||
import type { TerminalLine } from "$lib/types/messages";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import {
|
||||
setShouldRestoreHistory,
|
||||
setSavedHistory,
|
||||
getShouldRestoreHistory,
|
||||
getSavedHistory,
|
||||
clearHistoryRestore,
|
||||
} from "./historyRestore";
|
||||
|
||||
export interface TerminalLine {
|
||||
id: string;
|
||||
type: "user" | "assistant" | "system" | "tool" | "error";
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolName?: string;
|
||||
}
|
||||
// Re-export TerminalLine type for backwards compatibility
|
||||
export type { TerminalLine };
|
||||
|
||||
function createClaudeStore() {
|
||||
const connectionStatus = writable<ConnectionStatus>("disconnected");
|
||||
const sessionId = writable<string | null>(null);
|
||||
const currentWorkingDirectory = writable<string>("");
|
||||
const terminalLines = writable<TerminalLine[]>([]);
|
||||
const pendingPermission = writable<PermissionRequest | null>(null);
|
||||
const isProcessing = writable<boolean>(false);
|
||||
const grantedTools = writable<Set<string>>(new Set());
|
||||
const pendingRetryMessage = writable<string | null>(null);
|
||||
// Re-export from conversations store for backwards compatibility
|
||||
export const claudeStore = {
|
||||
// Existing subscriptions
|
||||
connectionStatus: conversationsStore.connectionStatus,
|
||||
sessionId: conversationsStore.sessionId,
|
||||
currentWorkingDirectory: conversationsStore.currentWorkingDirectory,
|
||||
terminalLines: conversationsStore.terminalLines,
|
||||
pendingPermission: conversationsStore.pendingPermission,
|
||||
isProcessing: conversationsStore.isProcessing,
|
||||
grantedTools: conversationsStore.grantedTools,
|
||||
pendingRetryMessage: conversationsStore.pendingRetryMessage,
|
||||
|
||||
let lineIdCounter = 0;
|
||||
// New conversation-aware subscriptions
|
||||
conversations: conversationsStore.conversations,
|
||||
activeConversationId: conversationsStore.activeConversationId,
|
||||
activeConversation: conversationsStore.activeConversation,
|
||||
|
||||
function generateLineId(): string {
|
||||
return `line-${Date.now()}-${lineIdCounter++}`;
|
||||
}
|
||||
// Methods
|
||||
setConnectionStatus: conversationsStore.setConnectionStatus,
|
||||
setConnectionStatusForConversation: conversationsStore.setConnectionStatusForConversation,
|
||||
setCharacterStateForConversation: conversationsStore.setCharacterStateForConversation,
|
||||
setSessionId: conversationsStore.setSessionId,
|
||||
setSessionIdForConversation: conversationsStore.setSessionIdForConversation,
|
||||
setWorkingDirectory: conversationsStore.setWorkingDirectory,
|
||||
setWorkingDirectoryForConversation: conversationsStore.setWorkingDirectoryForConversation,
|
||||
setProcessing: conversationsStore.setProcessing,
|
||||
addLine: conversationsStore.addLine,
|
||||
addLineToConversation: conversationsStore.addLineToConversation,
|
||||
updateLine: conversationsStore.updateLine,
|
||||
appendToLine: conversationsStore.appendToLine,
|
||||
clearTerminal: conversationsStore.clearTerminal,
|
||||
getConversationHistory: conversationsStore.getConversationHistory,
|
||||
requestPermission: conversationsStore.requestPermission,
|
||||
clearPermission: conversationsStore.clearPermission,
|
||||
grantTool: conversationsStore.grantTool,
|
||||
revokeAllTools: conversationsStore.revokeAllTools,
|
||||
isToolGranted: conversationsStore.isToolGranted,
|
||||
setPendingRetryMessage: conversationsStore.setPendingRetryMessage,
|
||||
|
||||
return {
|
||||
connectionStatus: { subscribe: connectionStatus.subscribe },
|
||||
sessionId: { subscribe: sessionId.subscribe },
|
||||
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
|
||||
terminalLines: { subscribe: terminalLines.subscribe },
|
||||
pendingPermission: { subscribe: pendingPermission.subscribe },
|
||||
isProcessing: { subscribe: isProcessing.subscribe },
|
||||
grantedTools: { subscribe: grantedTools.subscribe },
|
||||
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
|
||||
|
||||
setConnectionStatus: (status: ConnectionStatus) => connectionStatus.set(status),
|
||||
setSessionId: (id: string | null) => sessionId.set(id),
|
||||
setWorkingDirectory: (dir: string) => currentWorkingDirectory.set(dir),
|
||||
setProcessing: (processing: boolean) => isProcessing.set(processing),
|
||||
|
||||
addLine: (type: TerminalLine["type"], content: string, toolName?: string) => {
|
||||
const line: TerminalLine = {
|
||||
id: generateLineId(),
|
||||
type,
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
toolName,
|
||||
};
|
||||
terminalLines.update((lines) => [...lines, line]);
|
||||
return line.id;
|
||||
},
|
||||
|
||||
updateLine: (id: string, content: string) => {
|
||||
terminalLines.update((lines) =>
|
||||
lines.map((line) => (line.id === id ? { ...line, content } : line))
|
||||
);
|
||||
},
|
||||
|
||||
appendToLine: (id: string, additionalContent: string) => {
|
||||
terminalLines.update((lines) =>
|
||||
lines.map((line) =>
|
||||
line.id === id ? { ...line, content: line.content + additionalContent } : line
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
clearTerminal: () => terminalLines.set([]),
|
||||
|
||||
getConversationHistory: (): string => {
|
||||
let lines: TerminalLine[] = [];
|
||||
terminalLines.subscribe((l) => (lines = l))();
|
||||
|
||||
// Filter to just user and assistant messages, skip system/tool noise
|
||||
const relevantLines = lines.filter(
|
||||
(line) => line.type === "user" || line.type === "assistant"
|
||||
);
|
||||
|
||||
if (relevantLines.length === 0) return "";
|
||||
|
||||
return relevantLines
|
||||
.map((line) => {
|
||||
const role = line.type === "user" ? "User" : "Assistant";
|
||||
return `${role}: ${line.content}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
},
|
||||
|
||||
requestPermission: (request: PermissionRequest) => pendingPermission.set(request),
|
||||
clearPermission: () => pendingPermission.set(null),
|
||||
|
||||
grantTool: (toolName: string) => {
|
||||
grantedTools.update((tools) => {
|
||||
const newTools = new Set(tools);
|
||||
newTools.add(toolName);
|
||||
return newTools;
|
||||
});
|
||||
},
|
||||
// Conversation management
|
||||
createConversation: conversationsStore.createConversation,
|
||||
deleteConversation: conversationsStore.deleteConversation,
|
||||
switchConversation: conversationsStore.switchConversation,
|
||||
renameConversation: conversationsStore.renameConversation,
|
||||
|
||||
getGrantedTools: (): string[] => {
|
||||
let tools: string[] = [];
|
||||
grantedTools.subscribe((t) => (tools = Array.from(t)))();
|
||||
conversationsStore.grantedTools.subscribe((t) => (tools = Array.from(t)))();
|
||||
return tools;
|
||||
},
|
||||
|
||||
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
|
||||
// History restoration methods from main branch
|
||||
setShouldRestoreHistory: setShouldRestoreHistory,
|
||||
setSavedConversationHistory: setSavedHistory,
|
||||
getShouldRestoreHistory: getShouldRestoreHistory,
|
||||
getSavedConversationHistory: getSavedHistory,
|
||||
|
||||
reset: () => {
|
||||
connectionStatus.set("disconnected");
|
||||
sessionId.set(null);
|
||||
currentWorkingDirectory.set("");
|
||||
terminalLines.set([]);
|
||||
pendingPermission.set(null);
|
||||
isProcessing.set(false);
|
||||
grantedTools.set(new Set());
|
||||
pendingRetryMessage.set(null);
|
||||
// Reset only the active conversation
|
||||
conversationsStore.clearTerminal();
|
||||
conversationsStore.setSessionId(null);
|
||||
conversationsStore.setWorkingDirectory("");
|
||||
conversationsStore.setProcessing(false);
|
||||
conversationsStore.revokeAllTools();
|
||||
// Also clear history restoration
|
||||
clearHistoryRestore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const claudeStore = createClaudeStore();
|
||||
};
|
||||
|
||||
export const hasPermissionPending = derived(
|
||||
claudeStore.pendingPermission,
|
||||
($permission) => $permission !== null
|
||||
);
|
||||
|
||||
// Derived store to check if Claude is currently processing (can be interrupted)
|
||||
export const isClaudeProcessing = derived(
|
||||
[claudeStore.connectionStatus, characterState],
|
||||
([$connectionStatus, $characterState]) => {
|
||||
// Must be connected and in one of the processing states
|
||||
return (
|
||||
$connectionStatus === "connected" &&
|
||||
["thinking", "typing", "searching", "coding", "mcp"].includes($characterState)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import { writable, derived, get } from "svelte/store";
|
||||
import type { TerminalLine, ConnectionStatus, PermissionRequest } from "$lib/types/messages";
|
||||
import type { CharacterState } from "$lib/types/states";
|
||||
import { cleanupConversationTracking } from "$lib/tauri";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
name: string;
|
||||
terminalLines: TerminalLine[];
|
||||
sessionId: string | null;
|
||||
connectionStatus: ConnectionStatus;
|
||||
workingDirectory: string;
|
||||
characterState: CharacterState;
|
||||
isProcessing: boolean;
|
||||
grantedTools: Set<string>;
|
||||
createdAt: Date;
|
||||
lastActivityAt: Date;
|
||||
}
|
||||
|
||||
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;
|
||||
let lineIdCounter = 0;
|
||||
|
||||
function generateConversationId(): string {
|
||||
return `conv-${Date.now()}-${conversationCounter++}`;
|
||||
}
|
||||
|
||||
function generateLineId(): string {
|
||||
return `line-${Date.now()}-${lineIdCounter++}`;
|
||||
}
|
||||
|
||||
function createNewConversation(name?: string): Conversation {
|
||||
const id = generateConversationId();
|
||||
return {
|
||||
id,
|
||||
name: name || `Conversation ${conversationCounter}`,
|
||||
terminalLines: [],
|
||||
sessionId: null,
|
||||
connectionStatus: "disconnected",
|
||||
workingDirectory: "",
|
||||
characterState: "idle",
|
||||
isProcessing: false,
|
||||
grantedTools: new Set(),
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize with first conversation lazily
|
||||
let initialized = false;
|
||||
function ensureInitialized() {
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
const initialConversation = createNewConversation("Main");
|
||||
conversations.update((convs) => {
|
||||
convs.set(initialConversation.id, initialConversation);
|
||||
return convs;
|
||||
});
|
||||
activeConversationId.set(initialConversation.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Derived store for current conversation
|
||||
const activeConversation = derived(
|
||||
[conversations, activeConversationId],
|
||||
([$conversations, $activeId]) => {
|
||||
if (!$activeId) return null;
|
||||
return $conversations.get($activeId) || null;
|
||||
}
|
||||
);
|
||||
|
||||
// Derived stores for compatibility with existing code
|
||||
const connectionStatus = derived(
|
||||
activeConversation,
|
||||
($conv) => $conv?.connectionStatus || "disconnected"
|
||||
);
|
||||
const terminalLines = derived(activeConversation, ($conv) => {
|
||||
return $conv?.terminalLines || [];
|
||||
});
|
||||
const sessionId = derived(activeConversation, ($conv) => $conv?.sessionId || null);
|
||||
const currentWorkingDirectory = derived(
|
||||
activeConversation,
|
||||
($conv) => $conv?.workingDirectory || ""
|
||||
);
|
||||
const isProcessing = derived(activeConversation, ($conv) => $conv?.isProcessing || false);
|
||||
const grantedTools = derived(
|
||||
activeConversation,
|
||||
($conv) => $conv?.grantedTools || new Set<string>()
|
||||
);
|
||||
|
||||
return {
|
||||
// Expose derived stores for compatibility
|
||||
connectionStatus: { subscribe: connectionStatus.subscribe },
|
||||
sessionId: { subscribe: sessionId.subscribe },
|
||||
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
|
||||
terminalLines: { subscribe: terminalLines.subscribe },
|
||||
pendingPermission: { subscribe: pendingPermission.subscribe },
|
||||
isProcessing: { subscribe: isProcessing.subscribe },
|
||||
grantedTools: { subscribe: grantedTools.subscribe },
|
||||
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
|
||||
|
||||
// New conversation-specific stores
|
||||
conversations: { subscribe: conversations.subscribe },
|
||||
activeConversationId: { subscribe: activeConversationId.subscribe },
|
||||
activeConversation: { subscribe: activeConversation.subscribe },
|
||||
|
||||
// Connection management
|
||||
setConnectionStatus: (status: ConnectionStatus) => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.connectionStatus = status;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
setConnectionStatusForConversation: (conversationId: string, status: ConnectionStatus) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.connectionStatus = status;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
setCharacterStateForConversation: (conversationId: string, state: CharacterState) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.characterState = state;
|
||||
// If this is the active conversation, update the global character state
|
||||
if (conversationId === get(activeConversationId)) {
|
||||
characterState.setState(state);
|
||||
}
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
requestPermission: (request: PermissionRequest) => pendingPermission.set(request),
|
||||
clearPermission: () => pendingPermission.set(null),
|
||||
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
|
||||
|
||||
// Conversation management
|
||||
createConversation: (name?: string) => {
|
||||
ensureInitialized();
|
||||
const newConv = createNewConversation(name);
|
||||
conversations.update((convs) => {
|
||||
convs.set(newConv.id, newConv);
|
||||
return convs;
|
||||
});
|
||||
activeConversationId.set(newConv.id);
|
||||
return newConv.id;
|
||||
},
|
||||
|
||||
deleteConversation: (id: string) => {
|
||||
ensureInitialized();
|
||||
const convs = get(conversations);
|
||||
const activeId = get(activeConversationId);
|
||||
|
||||
if (convs.size <= 1) {
|
||||
// Don't delete the last conversation
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clean up tracking for this conversation
|
||||
cleanupConversationTracking(id);
|
||||
|
||||
conversations.update((c) => {
|
||||
c.delete(id);
|
||||
return c;
|
||||
});
|
||||
|
||||
// If we deleted the active conversation, switch to another
|
||||
if (activeId === id) {
|
||||
const remaining = Array.from(get(conversations).keys());
|
||||
if (remaining.length > 0) {
|
||||
activeConversationId.set(remaining[0]);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
switchConversation: async (id: string) => {
|
||||
const convs = get(conversations);
|
||||
if (!convs.has(id)) return;
|
||||
|
||||
const currentId = get(activeConversationId);
|
||||
const targetConv = convs.get(id);
|
||||
|
||||
// If switching to a different conversation
|
||||
if (currentId !== id) {
|
||||
activeConversationId.set(id);
|
||||
|
||||
// Update the global character state to match the conversation's state
|
||||
if (targetConv) {
|
||||
characterState.setState(targetConv.characterState);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
renameConversation: (id: string, newName: string) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(id);
|
||||
if (conv) {
|
||||
conv.name = newName;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
// Methods that operate on the active conversation
|
||||
setSessionId: (id: string | null) => {
|
||||
ensureInitialized();
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.sessionId = id;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
setSessionIdForConversation: (conversationId: string, id: string | null) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.sessionId = id;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
setWorkingDirectory: (dir: string) => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.workingDirectory = dir;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
setWorkingDirectoryForConversation: (conversationId: string, dir: string) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.workingDirectory = dir;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
setProcessing: (processing: boolean) => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.isProcessing = processing;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
addLine: (type: TerminalLine["type"], content: string, toolName?: string) => {
|
||||
ensureInitialized();
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return "";
|
||||
|
||||
const line: TerminalLine = {
|
||||
id: generateLineId(),
|
||||
type,
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
toolName,
|
||||
};
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.terminalLines.push(line);
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
|
||||
return line.id;
|
||||
},
|
||||
|
||||
addLineToConversation: (
|
||||
conversationId: string,
|
||||
type: TerminalLine["type"],
|
||||
content: string,
|
||||
toolName?: string
|
||||
) => {
|
||||
ensureInitialized();
|
||||
|
||||
const line: TerminalLine = {
|
||||
id: generateLineId(),
|
||||
type,
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
toolName,
|
||||
};
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.terminalLines.push(line);
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
|
||||
return line.id;
|
||||
},
|
||||
|
||||
updateLine: (id: string, content: string) => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
const line = conv.terminalLines.find((l) => l.id === id);
|
||||
if (line) {
|
||||
line.content = content;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
appendToLine: (id: string, additionalContent: string) => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
const line = conv.terminalLines.find((l) => l.id === id);
|
||||
if (line) {
|
||||
line.content += additionalContent;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
clearTerminal: () => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.terminalLines = [];
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
getConversationHistory: (): string => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return "";
|
||||
|
||||
const convs = get(conversations);
|
||||
const conv = convs.get(activeId);
|
||||
if (!conv) return "";
|
||||
|
||||
const relevantLines = conv.terminalLines.filter(
|
||||
(line) => line.type === "user" || line.type === "assistant"
|
||||
);
|
||||
|
||||
if (relevantLines.length === 0) return "";
|
||||
|
||||
return relevantLines
|
||||
.map((line) => {
|
||||
const role = line.type === "user" ? "User" : "Assistant";
|
||||
return `${role}: ${line.content}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
},
|
||||
|
||||
grantTool: (toolName: string) => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.grantedTools.add(toolName);
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
revokeAllTools: () => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.grantedTools.clear();
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
isToolGranted: (toolName: string): boolean => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return false;
|
||||
|
||||
const convs = get(conversations);
|
||||
const conv = convs.get(activeId);
|
||||
return conv?.grantedTools.has(toolName) || false;
|
||||
},
|
||||
|
||||
// Add initialization helper
|
||||
initialize: () => {
|
||||
ensureInitialized();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const conversationsStore = createConversationsStore();
|
||||
// Initialize immediately
|
||||
conversationsStore.initialize();
|
||||
@@ -0,0 +1,24 @@
|
||||
// Separate module for history restoration to ensure persistence across reconnects
|
||||
let shouldRestore = false;
|
||||
let savedHistory: string | null = null;
|
||||
|
||||
export function setShouldRestoreHistory(should: boolean) {
|
||||
shouldRestore = should;
|
||||
}
|
||||
|
||||
export function setSavedHistory(history: string | null) {
|
||||
savedHistory = history;
|
||||
}
|
||||
|
||||
export function getShouldRestoreHistory(): boolean {
|
||||
return shouldRestore;
|
||||
}
|
||||
|
||||
export function getSavedHistory(): string | null {
|
||||
return savedHistory;
|
||||
}
|
||||
|
||||
export function clearHistoryRestore() {
|
||||
shouldRestore = false;
|
||||
savedHistory = null;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
// Default to chat mode
|
||||
const messageModeStore = writable<string>("chat");
|
||||
|
||||
export const messageMode = {
|
||||
subscribe: messageModeStore.subscribe,
|
||||
set: (mode: string) => {
|
||||
messageModeStore.set(mode);
|
||||
},
|
||||
reset: () => messageModeStore.set("chat"),
|
||||
};
|
||||
|
||||
// Helper to get current mode
|
||||
export function getCurrentMode(): string {
|
||||
let currentMode = "chat";
|
||||
messageMode.subscribe((mode) => (currentMode = mode))();
|
||||
return currentMode;
|
||||
}
|
||||
@@ -1,22 +1,23 @@
|
||||
import { derived } from "svelte/store";
|
||||
import { configStore } from "./config";
|
||||
import { soundPlayer } from "$lib/notifications";
|
||||
|
||||
// Sync notification settings with config
|
||||
export const notificationSettings = derived(configStore.config, ($config) => {
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
// Initialize notification settings sync - call this once on app startup
|
||||
export function initNotificationSync() {
|
||||
// Prevent duplicate subscriptions
|
||||
if (unsubscribe) return;
|
||||
|
||||
unsubscribe = configStore.config.subscribe(($config) => {
|
||||
soundPlayer.setEnabled($config.notifications_enabled);
|
||||
soundPlayer.setGlobalVolume($config.notification_volume);
|
||||
|
||||
return {
|
||||
enabled: $config.notifications_enabled,
|
||||
volume: $config.notification_volume,
|
||||
};
|
||||
});
|
||||
|
||||
// Helper to update notification settings
|
||||
export async function updateNotificationSettings(enabled: boolean, volume: number) {
|
||||
await configStore.updateConfig({
|
||||
notifications_enabled: enabled,
|
||||
notification_volume: volume,
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup function if needed
|
||||
export function cleanupNotificationSync() {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface UsageStats {
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_cost_usd: number;
|
||||
session_input_tokens: number;
|
||||
session_output_tokens: number;
|
||||
session_cost_usd: number;
|
||||
model: string | null;
|
||||
|
||||
// New fields
|
||||
messages_exchanged: number;
|
||||
session_messages_exchanged: number;
|
||||
code_blocks_generated: number;
|
||||
session_code_blocks_generated: number;
|
||||
files_edited: number;
|
||||
session_files_edited: number;
|
||||
files_created: number;
|
||||
session_files_created: number;
|
||||
tools_usage: Record<string, number>;
|
||||
session_tools_usage: Record<string, number>;
|
||||
session_duration_seconds: number;
|
||||
}
|
||||
|
||||
// Main stats store
|
||||
export const stats = writable<UsageStats>({
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cost_usd: 0,
|
||||
session_input_tokens: 0,
|
||||
session_output_tokens: 0,
|
||||
session_cost_usd: 0,
|
||||
model: null,
|
||||
messages_exchanged: 0,
|
||||
session_messages_exchanged: 0,
|
||||
code_blocks_generated: 0,
|
||||
session_code_blocks_generated: 0,
|
||||
files_edited: 0,
|
||||
session_files_edited: 0,
|
||||
files_created: 0,
|
||||
session_files_created: 0,
|
||||
tools_usage: {},
|
||||
session_tools_usage: {},
|
||||
session_duration_seconds: 0,
|
||||
});
|
||||
|
||||
// Derived store for formatted display values
|
||||
export const formattedStats = derived(stats, ($stats) => {
|
||||
const formatNumber = (num: number) => num.toLocaleString();
|
||||
const formatCost = (cost: number) => `$${cost.toFixed(4)}`;
|
||||
const formatDuration = (seconds: number) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${secs}s`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${secs}s`;
|
||||
} else {
|
||||
return `${secs}s`;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
totalTokens: formatNumber($stats.total_input_tokens + $stats.total_output_tokens),
|
||||
totalInputTokens: formatNumber($stats.total_input_tokens),
|
||||
totalOutputTokens: formatNumber($stats.total_output_tokens),
|
||||
totalCost: formatCost($stats.total_cost_usd),
|
||||
sessionTokens: formatNumber($stats.session_input_tokens + $stats.session_output_tokens),
|
||||
sessionInputTokens: formatNumber($stats.session_input_tokens),
|
||||
sessionOutputTokens: formatNumber($stats.session_output_tokens),
|
||||
sessionCost: formatCost($stats.session_cost_usd),
|
||||
model: $stats.model || "No model selected",
|
||||
|
||||
// New formatted fields
|
||||
messagesTotal: formatNumber($stats.messages_exchanged),
|
||||
messagesSession: formatNumber($stats.session_messages_exchanged),
|
||||
codeBlocksTotal: formatNumber($stats.code_blocks_generated),
|
||||
codeBlocksSession: formatNumber($stats.session_code_blocks_generated),
|
||||
filesEditedTotal: formatNumber($stats.files_edited),
|
||||
filesEditedSession: formatNumber($stats.session_files_edited),
|
||||
filesCreatedTotal: formatNumber($stats.files_created),
|
||||
filesCreatedSession: formatNumber($stats.session_files_created),
|
||||
sessionDuration: formatDuration($stats.session_duration_seconds),
|
||||
toolsUsage: $stats.tools_usage,
|
||||
sessionToolsUsage: $stats.session_tools_usage,
|
||||
};
|
||||
});
|
||||
|
||||
// Note: Cost calculation is now done in the Rust backend
|
||||
|
||||
// Initialize stats listener
|
||||
export async function initStatsListener() {
|
||||
// Listen for stats updates from the backend
|
||||
await listen("claude:stats", (event) => {
|
||||
const payload = event.payload as { stats: UsageStats };
|
||||
const { stats: newStats } = payload;
|
||||
|
||||
// The backend already tracks all totals - just set the stats directly
|
||||
stats.set(newStats);
|
||||
});
|
||||
|
||||
// Load initial stats from backend
|
||||
try {
|
||||
const initialStats = await invoke<UsageStats>("get_usage_stats");
|
||||
stats.set(initialStats);
|
||||
} catch (error) {
|
||||
console.error("Failed to load initial stats:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset session stats (call when starting new session)
|
||||
export function resetSessionStats() {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
session_input_tokens: 0,
|
||||
session_output_tokens: 0,
|
||||
session_cost_usd: 0,
|
||||
session_messages_exchanged: 0,
|
||||
session_code_blocks_generated: 0,
|
||||
session_files_edited: 0,
|
||||
session_files_created: 0,
|
||||
session_tools_usage: {},
|
||||
session_duration_seconds: 0,
|
||||
}));
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
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 { CharacterState } from "$lib/types/states";
|
||||
import {
|
||||
@@ -15,10 +18,16 @@ import {
|
||||
interface StateChangePayload {
|
||||
state: CharacterState;
|
||||
tool_name: string | null;
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
let hasConnectedThisSession = false;
|
||||
const connectedConversations = new Set<string>();
|
||||
let unlisteners: Array<() => void> = [];
|
||||
let skipNextGreeting = false;
|
||||
|
||||
export function setSkipNextGreeting(skip: boolean) {
|
||||
skipNextGreeting = skip;
|
||||
}
|
||||
|
||||
function getTimeOfDay(): string {
|
||||
const hour = new Date().getHours();
|
||||
@@ -39,7 +48,13 @@ function generateGreetingPrompt(): string {
|
||||
return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`;
|
||||
}
|
||||
|
||||
async function sendGreeting() {
|
||||
async function sendGreeting(conversationId: string) {
|
||||
// Check if we should skip this greeting
|
||||
if (skipNextGreeting) {
|
||||
skipNextGreeting = false; // Reset the flag
|
||||
return;
|
||||
}
|
||||
|
||||
const config = configStore.getConfig();
|
||||
|
||||
if (!config.greeting_enabled) {
|
||||
@@ -55,10 +70,13 @@ async function sendGreeting() {
|
||||
handleNewUserMessage();
|
||||
|
||||
try {
|
||||
await invoke("send_prompt", { message: greetingPrompt });
|
||||
await invoke("send_prompt", {
|
||||
conversationId,
|
||||
message: greetingPrompt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send greeting:", error);
|
||||
claudeStore.addLine("error", `Failed to send greeting: ${error}`);
|
||||
claudeStore.addLineToConversation(conversationId, "error", `Failed to send greeting: ${error}`);
|
||||
characterState.setTemporaryState("error", 3000);
|
||||
}
|
||||
}
|
||||
@@ -67,6 +85,26 @@ interface OutputPayload {
|
||||
line_type: string;
|
||||
content: string;
|
||||
tool_name: string | null;
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
interface ConnectionPayload {
|
||||
status: ConnectionStatus;
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
interface SessionPayload {
|
||||
session_id: string;
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
interface WorkingDirectoryPayload {
|
||||
directory: string;
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
export function cleanupConversationTracking(conversationId: string) {
|
||||
connectedConversations.delete(conversationId);
|
||||
}
|
||||
|
||||
export async function initializeTauriListeners() {
|
||||
@@ -76,34 +114,84 @@ export async function initializeTauriListeners() {
|
||||
// Initialize notification rules
|
||||
initializeNotificationRules();
|
||||
|
||||
const connectionUnlisten = await listen<string>("claude:connection", async (event) => {
|
||||
const status = event.payload as ConnectionStatus;
|
||||
// Initialize stats listener
|
||||
await initStatsListener();
|
||||
|
||||
// Initialize achievements listener
|
||||
await initAchievementsListener();
|
||||
|
||||
const connectionUnlisten = await listen<ConnectionPayload>("claude:connection", async (event) => {
|
||||
const { status, conversation_id } = event.payload;
|
||||
|
||||
// Update connection status for the specific conversation
|
||||
if (conversation_id) {
|
||||
claudeStore.setConnectionStatusForConversation(conversation_id, status);
|
||||
} else {
|
||||
// Fallback to active conversation if no conversation_id
|
||||
claudeStore.setConnectionStatus(status);
|
||||
}
|
||||
|
||||
// Handle notification for connection status
|
||||
handleConnectionStatusChange(status);
|
||||
|
||||
if (status === "connected") {
|
||||
claudeStore.addLine("system", "Connected to Claude Code");
|
||||
characterState.setState("idle");
|
||||
if (!hasConnectedThisSession) {
|
||||
hasConnectedThisSession = true;
|
||||
await sendGreeting();
|
||||
// Get the actual conversation ID (fallback to active if not provided)
|
||||
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||
|
||||
if (targetConversationId) {
|
||||
// Add system message to the correct conversation
|
||||
claudeStore.addLineToConversation(
|
||||
targetConversationId,
|
||||
"system",
|
||||
"Connected to Claude Code"
|
||||
);
|
||||
|
||||
// Update character state for this conversation
|
||||
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
|
||||
|
||||
// Check if this specific conversation has connected before
|
||||
if (!connectedConversations.has(targetConversationId)) {
|
||||
connectedConversations.add(targetConversationId);
|
||||
resetSessionStats(); // Reset session stats on new connection
|
||||
await sendGreeting(targetConversationId);
|
||||
}
|
||||
}
|
||||
} else if (status === "disconnected") {
|
||||
hasConnectedThisSession = false;
|
||||
claudeStore.addLine("system", "Disconnected from Claude Code");
|
||||
characterState.setState("idle");
|
||||
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||
|
||||
// Only remove from connected set if we're not about to reconnect
|
||||
if (!skipNextGreeting && targetConversationId) {
|
||||
connectedConversations.delete(targetConversationId);
|
||||
}
|
||||
|
||||
// Don't add system message if we're about to reconnect
|
||||
if (!skipNextGreeting && targetConversationId) {
|
||||
claudeStore.addLineToConversation(
|
||||
targetConversationId,
|
||||
"system",
|
||||
"Disconnected from Claude Code"
|
||||
);
|
||||
}
|
||||
|
||||
// Update character state for this conversation
|
||||
if (targetConversationId) {
|
||||
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
|
||||
}
|
||||
} else if (status === "error") {
|
||||
hasConnectedThisSession = false;
|
||||
claudeStore.addLine("error", "Connection error");
|
||||
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||
|
||||
if (targetConversationId) {
|
||||
connectedConversations.delete(targetConversationId);
|
||||
claudeStore.addLineToConversation(targetConversationId, "error", "Connection error");
|
||||
}
|
||||
|
||||
characterState.setTemporaryState("error", 3000);
|
||||
}
|
||||
});
|
||||
unlisteners.push(connectionUnlisten);
|
||||
|
||||
const stateUnlisten = await listen<StateChangePayload>("claude:state", (event) => {
|
||||
const { state } = event.payload;
|
||||
const { state, conversation_id } = event.payload;
|
||||
|
||||
const stateMap: Record<string, CharacterState> = {
|
||||
idle: "idle",
|
||||
@@ -119,21 +207,48 @@ export async function initializeTauriListeners() {
|
||||
|
||||
const mappedState = stateMap[state.toLowerCase()] || "idle";
|
||||
|
||||
// Always update the conversation's state
|
||||
if (conversation_id) {
|
||||
claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
|
||||
} else {
|
||||
// Fallback to active conversation if no conversation_id
|
||||
const activeConversationId = get(claudeStore.activeConversationId);
|
||||
if (activeConversationId) {
|
||||
claudeStore.setCharacterStateForConversation(activeConversationId, mappedState);
|
||||
}
|
||||
}
|
||||
|
||||
// Only update global character state for active conversation
|
||||
const activeConversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversation_id || conversation_id === activeConversationId) {
|
||||
if (mappedState === "success" || mappedState === "error") {
|
||||
characterState.setTemporaryState(mappedState, 3000);
|
||||
} else {
|
||||
characterState.setState(mappedState);
|
||||
}
|
||||
}
|
||||
});
|
||||
unlisteners.push(stateUnlisten);
|
||||
|
||||
const outputUnlisten = await listen<OutputPayload>("claude:output", (event) => {
|
||||
const { line_type, content, tool_name } = event.payload;
|
||||
const { line_type, content, tool_name, conversation_id } = event.payload;
|
||||
|
||||
// Always store the output to the correct conversation
|
||||
if (conversation_id) {
|
||||
claudeStore.addLineToConversation(
|
||||
conversation_id,
|
||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
||||
content,
|
||||
tool_name || undefined
|
||||
);
|
||||
} else {
|
||||
// Fallback to active conversation if no conversation_id provided
|
||||
claudeStore.addLine(
|
||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
||||
content,
|
||||
tool_name || undefined
|
||||
);
|
||||
}
|
||||
});
|
||||
unlisteners.push(outputUnlisten);
|
||||
|
||||
@@ -142,30 +257,64 @@ export async function initializeTauriListeners() {
|
||||
});
|
||||
unlisteners.push(streamUnlisten);
|
||||
|
||||
const sessionUnlisten = await listen<string>("claude:session", (event) => {
|
||||
claudeStore.setSessionId(event.payload);
|
||||
claudeStore.addLine("system", `Session: ${event.payload.substring(0, 8)}...`);
|
||||
const sessionUnlisten = await listen<SessionPayload>("claude:session", (event) => {
|
||||
const { session_id, conversation_id } = event.payload;
|
||||
|
||||
// Store session ID for the correct conversation
|
||||
if (conversation_id) {
|
||||
claudeStore.setSessionIdForConversation(conversation_id, session_id);
|
||||
claudeStore.addLineToConversation(
|
||||
conversation_id,
|
||||
"system",
|
||||
`Session: ${session_id.substring(0, 8)}...`
|
||||
);
|
||||
} else {
|
||||
// Fallback to active conversation if no conversation_id
|
||||
claudeStore.setSessionId(session_id);
|
||||
claudeStore.addLine("system", `Session: ${session_id.substring(0, 8)}...`);
|
||||
}
|
||||
});
|
||||
unlisteners.push(sessionUnlisten);
|
||||
|
||||
const cwdUnlisten = await listen<string>("claude:cwd", (event) => {
|
||||
claudeStore.setWorkingDirectory(event.payload);
|
||||
const cwdUnlisten = await listen<WorkingDirectoryPayload>("claude:cwd", (event) => {
|
||||
const { directory, conversation_id } = event.payload;
|
||||
|
||||
// Store working directory for the correct conversation
|
||||
if (conversation_id) {
|
||||
claudeStore.setWorkingDirectoryForConversation(conversation_id, directory);
|
||||
} else {
|
||||
// Fallback to active conversation if no conversation_id
|
||||
claudeStore.setWorkingDirectory(directory);
|
||||
}
|
||||
});
|
||||
unlisteners.push(cwdUnlisten);
|
||||
|
||||
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
|
||||
const { id, tool_name, tool_input, description } = event.payload;
|
||||
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) {
|
||||
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);
|
||||
|
||||
console.log("Tauri event listeners initialized");
|
||||
}
|
||||
|
||||
export function cleanupTauriListeners() {
|
||||
@@ -175,6 +324,4 @@ export function cleanupTauriListeners() {
|
||||
|
||||
// Cleanup notification rules
|
||||
cleanupNotificationRules();
|
||||
|
||||
console.log("Tauri event listeners cleaned up");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
export interface AchievementUnlockedEvent {
|
||||
achievement: {
|
||||
id: AchievementId;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
unlocked_at: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type AchievementId =
|
||||
// Token Milestones
|
||||
| "FirstSteps" // 1,000 tokens
|
||||
| "GrowingStrong" // 10,000 tokens
|
||||
| "BlossomingCoder" // 100,000 tokens
|
||||
| "TokenMaster" // 1,000,000 tokens
|
||||
// Code Generation
|
||||
| "HelloWorld" // First code block
|
||||
| "CodeWizard" // 100 code blocks
|
||||
| "ThousandBlocks" // 1,000 code blocks
|
||||
// File Operations
|
||||
| "FileManipulator" // 10 files edited
|
||||
| "FileArchitect" // 100 files edited
|
||||
// Conversation milestones
|
||||
| "ConversationStarter" // 10 messages
|
||||
| "ChattyKathy" // 100 messages
|
||||
| "Conversationalist" // 1,000 messages
|
||||
// Tool usage
|
||||
| "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
|
||||
| "DedicatedDeveloper" // 30 days in a row
|
||||
// Search and exploration
|
||||
| "Explorer" // 50 searches
|
||||
| "MasterSearcher" // 500 searches
|
||||
// Session achievements
|
||||
| "QuickSession" // Productive session < 5 min
|
||||
| "FocusedWork" // 30 min session
|
||||
| "DeepDive" // 2 hour session
|
||||
| "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
|
||||
| "ClaudeConnoisseur" // Used all Claude models
|
||||
| "MarathonCoder" // 10k tokens in one session
|
||||
// Relationship & Greetings
|
||||
| "GoodMorning" // Said good morning
|
||||
| "GoodNight" // Said good night
|
||||
| "ThankYou" // Said thank you
|
||||
| "LoveYou" // Said love you
|
||||
// Personality & Fun
|
||||
| "EmojiUser" // Used 20+ emojis
|
||||
| "CapsLock" // ALL CAPS MESSAGE
|
||||
| "QuestionMaster" // Asked 50 questions
|
||||
| "PleaseAndThankYou" // Polite user
|
||||
// Git & Development
|
||||
| "CommitMaster" // 100 commits
|
||||
| "PRO" // Created 10 PRs
|
||||
| "Reviewer" // Reviewed 10 PRs
|
||||
| "IssueTracker" // Created 25 issues
|
||||
| "GitGuru" // Used git commands
|
||||
// Tool Mastery
|
||||
| "BashMaster" // Used bash 100 times
|
||||
| "FileExplorer" // Searched files 100 times
|
||||
| "SearchExpert" // Advanced searches
|
||||
| "AgentCommander" // Used task agents
|
||||
| "MCPMaster"; // Used MCP tools
|
||||
|
||||
export interface Achievement {
|
||||
id: AchievementId;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
rarity: "common" | "rare" | "epic" | "legendary";
|
||||
unlocked: boolean;
|
||||
unlockedAt?: Date;
|
||||
progress?: number;
|
||||
maxProgress?: number;
|
||||
}
|
||||
|
||||
export interface AchievementCategory {
|
||||
name: string;
|
||||
description: string;
|
||||
achievements: Achievement[];
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
export interface MessageMode {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
prefix?: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const MESSAGE_MODES: MessageMode[] = [
|
||||
{
|
||||
id: "chat",
|
||||
name: "Chat",
|
||||
description: "Normal conversation mode",
|
||||
icon: "💬",
|
||||
},
|
||||
{
|
||||
id: "architect",
|
||||
name: "Architect",
|
||||
description: "High-level design and architecture planning",
|
||||
prefix: "[Architect Mode] ",
|
||||
icon: "🏗️",
|
||||
},
|
||||
{
|
||||
id: "code",
|
||||
name: "Code",
|
||||
description: "Focused on writing and editing code",
|
||||
prefix: "[Code Mode] ",
|
||||
icon: "💻",
|
||||
},
|
||||
{
|
||||
id: "debug",
|
||||
name: "Debug",
|
||||
description: "Help with debugging and troubleshooting",
|
||||
prefix: "[Debug Mode] ",
|
||||
icon: "🐛",
|
||||
},
|
||||
{
|
||||
id: "ask",
|
||||
name: "Ask",
|
||||
description: "Technical questions and explanations",
|
||||
prefix: "[Ask Mode] ",
|
||||
icon: "❓",
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
name: "Review",
|
||||
description: "Code review and feedback",
|
||||
prefix: "[Review Mode] ",
|
||||
icon: "👀",
|
||||
},
|
||||
];
|
||||
|
||||
export function getMessageMode(id: string): MessageMode | undefined {
|
||||
return MESSAGE_MODES.find((mode) => mode.id === id);
|
||||
}
|
||||
|
||||
export function formatMessageWithMode(message: string, modeId: string): string {
|
||||
const mode = getMessageMode(modeId);
|
||||
if (!mode || !mode.prefix) {
|
||||
return message;
|
||||
}
|
||||
|
||||
// Don't double-prefix if the message already starts with the prefix
|
||||
if (message.startsWith(mode.prefix)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return mode.prefix + message;
|
||||
}
|
||||
@@ -1,3 +1,11 @@
|
||||
export interface TerminalLine {
|
||||
id: string;
|
||||
type: "user" | "assistant" | "system" | "tool" | "error";
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolName?: string;
|
||||
}
|
||||
|
||||
export interface SystemInitMessage {
|
||||
type: "system";
|
||||
subtype: "init";
|
||||
@@ -115,6 +123,7 @@ export interface PermissionPromptEvent {
|
||||
tool_name: string;
|
||||
tool_input: Record<string, unknown>;
|
||||
description: string;
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
|
||||
import { configStore, applyTheme } from "$lib/stores/config";
|
||||
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import "$lib/notifications/testNotifications";
|
||||
import Terminal from "$lib/components/Terminal.svelte";
|
||||
import InputBar from "$lib/components/InputBar.svelte";
|
||||
@@ -9,31 +11,42 @@
|
||||
import AnimeGirl from "$lib/components/AnimeGirl.svelte";
|
||||
import PermissionModal from "$lib/components/PermissionModal.svelte";
|
||||
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
|
||||
import AchievementNotification from "$lib/components/AchievementNotification.svelte";
|
||||
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
|
||||
|
||||
let initialized = false;
|
||||
let achievementPanelOpen = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
|
||||
// Initialize conversations store first to ensure activeConversationId is set
|
||||
conversationsStore.initialize();
|
||||
|
||||
await initializeTauriListeners();
|
||||
await configStore.loadConfig();
|
||||
|
||||
// Apply saved theme on startup
|
||||
const config = configStore.getConfig();
|
||||
applyTheme(config.theme);
|
||||
|
||||
// Initialize notification settings sync
|
||||
initNotificationSync();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (initialized) {
|
||||
cleanupTauriListeners();
|
||||
cleanupNotificationSync();
|
||||
initialized = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden">
|
||||
<StatusBar />
|
||||
<StatusBar onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)} />
|
||||
|
||||
<main class="flex-1 flex overflow-hidden">
|
||||
<!-- Left panel: Character display -->
|
||||
@@ -52,6 +65,11 @@
|
||||
|
||||
<PermissionModal />
|
||||
<ConfigSidebar />
|
||||
<AchievementNotification />
|
||||
<AchievementsPanel
|
||||
bind:isOpen={achievementPanelOpen}
|
||||
onClose={() => (achievementPanelOpen = false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { testAchievementSound } from "$lib/sounds/achievement";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
async function testSound() {
|
||||
testAchievementSound();
|
||||
}
|
||||
|
||||
async function triggerAchievement() {
|
||||
// This will trigger an achievement that hasn't been unlocked yet
|
||||
try {
|
||||
await invoke("check_achievements", {
|
||||
eventType: "message_sent",
|
||||
data: {},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to trigger achievement:", error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto p-8">
|
||||
<h1 class="text-2xl font-bold mb-6">Achievement Sound Test</h1>
|
||||
|
||||
<div class="space-y-4">
|
||||
<button
|
||||
onclick={testSound}
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Test Achievement Sound
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={triggerAchievement}
|
||||
class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
|
||||
>
|
||||
Trigger Test Achievement
|
||||
</button>
|
||||
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-4">
|
||||
Click the first button to test just the sound effect.<br />
|
||||
Click the second button to trigger a real achievement (if any are available to unlock).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
Before Width: | Height: | Size: 680 KiB After Width: | Height: | Size: 680 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 442 KiB |
|
Before Width: | Height: | Size: 414 KiB After Width: | Height: | Size: 413 KiB |
|
Before Width: | Height: | Size: 778 KiB After Width: | Height: | Size: 777 KiB |