feat: add font size and zoom settings

- Add font_size config field (10-24px, default 14px)
- Add keyboard shortcuts: Ctrl++/- to adjust, Ctrl+0 to reset
- Add font size slider in Settings > Appearance
- Apply font size to Terminal and InputBar via CSS variable
- Persist font size preference between sessions

Closes #19
This commit is contained in:
2026-01-23 18:16:45 -08:00
committed by Naomi Carrigan
parent 13c96a973a
commit 2db858080d
17 changed files with 596 additions and 255 deletions
+78 -53
View File
@@ -1,6 +1,6 @@
use chrono::{DateTime, Datelike, Timelike, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashSet; use std::collections::HashSet;
use chrono::{DateTime, Utc, Timelike, Datelike};
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
@@ -12,9 +12,9 @@ pub enum AchievementId {
TokenMaster, // 1,000,000 tokens TokenMaster, // 1,000,000 tokens
// Code Generation // Code Generation
HelloWorld, // First code block HelloWorld, // First code block
CodeWizard, // 100 code blocks CodeWizard, // 100 code blocks
ThousandBlocks, // 1,000 code blocks ThousandBlocks, // 1,000 code blocks
// File Operations // File Operations
FileManipulator, // 10 files edited FileManipulator, // 10 files edited
@@ -22,23 +22,23 @@ pub enum AchievementId {
// Conversation milestones // Conversation milestones
ConversationStarter, // 10 messages ConversationStarter, // 10 messages
ChattyKathy, // 100 messages ChattyKathy, // 100 messages
Conversationalist, // 1,000 messages Conversationalist, // 1,000 messages
// Tool usage // Tool usage
Toolsmith, // 5 different tools Toolsmith, // 5 different tools
ToolMaster, // 10 different tools ToolMaster, // 10 different tools
// Time-based achievements // Time-based achievements
EarlyBird, // Started session 5-7 AM EarlyBird, // Started session 5-7 AM
NightOwl, // Coding after midnight NightOwl, // Coding after midnight
AllNighter, // Worked 2-5 AM AllNighter, // Worked 2-5 AM
WeekendWarrior, // Coding on weekend WeekendWarrior, // Coding on weekend
DedicatedDeveloper, // 30 days in a row DedicatedDeveloper, // 30 days in a row
// Search and exploration // Search and exploration
Explorer, // 50 searches Explorer, // 50 searches
MasterSearcher, // 500 searches MasterSearcher, // 500 searches
// Session achievements // Session achievements
QuickSession, // Productive session < 5 min QuickSession, // Productive session < 5 min
@@ -47,36 +47,36 @@ pub enum AchievementId {
MarathonSession, // 5+ hour session MarathonSession, // 5+ hour session
// Special achievements // Special achievements
FirstMessage, // First message sent FirstMessage, // First message sent
FirstTool, // First tool used FirstTool, // First tool used
FirstCodeBlock, // First code generated FirstCodeBlock, // First code generated
FirstFileEdit, // First file edit FirstFileEdit, // First file edit
Polyglot, // 5+ languages in one session Polyglot, // 5+ languages in one session
SpeedCoder, // 10 code blocks in 10 minutes SpeedCoder, // 10 code blocks in 10 minutes
ClaudeConnoisseur, // Used all Claude models ClaudeConnoisseur, // Used all Claude models
MarathonCoder, // 10k tokens in one session MarathonCoder, // 10k tokens in one session
// Relationship & Greetings // Relationship & Greetings
GoodMorning, // Say "good morning" GoodMorning, // Say "good morning"
GoodNight, // Say "good night" or "goodnight" GoodNight, // Say "good night" or "goodnight"
ThankYou, // Say "thank you" or "thanks" ThankYou, // Say "thank you" or "thanks"
LoveYou, // Say "love you" or "ily" LoveYou, // Say "love you" or "ily"
// Personality & Fun // Personality & Fun
EmojiUser, // Use an emoji in a message EmojiUser, // Use an emoji in a message
QuestionMaster, // Use "?" in 20 messages QuestionMaster, // Use "?" in 20 messages
CapsLock, // Send a message in ALL CAPS CapsLock, // Send a message in ALL CAPS
PleaseAndThankYou, // Use "please" in messages PleaseAndThankYou, // Use "please" in messages
// Git & Development // Git & Development
GitGuru, // Use git commands 10 times GitGuru, // Use git commands 10 times
TestWriter, // Create test files TestWriter, // Create test files
Debugger, // Fix bugs (messages with "fix", "bug", "error") Debugger, // Fix bugs (messages with "fix", "bug", "error")
// Tool Mastery // Tool Mastery
BashMaster, // Use Bash tool 50 times BashMaster, // Use Bash tool 50 times
FileExplorer, // Use Read tool 100 times FileExplorer, // Use Read tool 100 times
SearchExpert, // Use Grep tool 50 times SearchExpert, // Use Grep tool 50 times
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -509,15 +509,20 @@ pub fn check_message_achievements(
newly_unlocked.push(AchievementId::GoodMorning); newly_unlocked.push(AchievementId::GoodMorning);
} }
if (message_lower.contains("good night") || message_lower.contains("goodnight")) if (message_lower.contains("good night") || message_lower.contains("goodnight"))
&& progress.unlock(AchievementId::GoodNight) { && progress.unlock(AchievementId::GoodNight)
{
newly_unlocked.push(AchievementId::GoodNight); newly_unlocked.push(AchievementId::GoodNight);
} }
if (message_lower.contains("thank you") || message_lower.contains("thanks") || message_lower.contains("thx")) if (message_lower.contains("thank you")
&& progress.unlock(AchievementId::ThankYou) { || message_lower.contains("thanks")
|| message_lower.contains("thx"))
&& progress.unlock(AchievementId::ThankYou)
{
newly_unlocked.push(AchievementId::ThankYou); newly_unlocked.push(AchievementId::ThankYou);
} }
if (message_lower.contains("love you") || message_lower.contains("ily")) if (message_lower.contains("love you") || message_lower.contains("ily"))
&& progress.unlock(AchievementId::LoveYou) { && progress.unlock(AchievementId::LoveYou)
{
newly_unlocked.push(AchievementId::LoveYou); newly_unlocked.push(AchievementId::LoveYou);
} }
@@ -525,9 +530,11 @@ pub fn check_message_achievements(
if message.chars().any(|c| c as u32 >= 0x1F300) && progress.unlock(AchievementId::EmojiUser) { if message.chars().any(|c| c as u32 >= 0x1F300) && progress.unlock(AchievementId::EmojiUser) {
newly_unlocked.push(AchievementId::EmojiUser); newly_unlocked.push(AchievementId::EmojiUser);
} }
if message == message.to_uppercase() && message.len() > 5 if message == message.to_uppercase()
&& message.len() > 5
&& message.chars().any(|c| c.is_alphabetic()) && message.chars().any(|c| c.is_alphabetic())
&& progress.unlock(AchievementId::CapsLock) { && progress.unlock(AchievementId::CapsLock)
{
newly_unlocked.push(AchievementId::CapsLock); newly_unlocked.push(AchievementId::CapsLock);
} }
if message_lower.contains("please") && progress.unlock(AchievementId::PleaseAndThankYou) { if message_lower.contains("please") && progress.unlock(AchievementId::PleaseAndThankYou) {
@@ -535,8 +542,11 @@ pub fn check_message_achievements(
} }
// Git & Development patterns in messages // Git & Development patterns in messages
if (message_lower.contains("fix") || message_lower.contains("bug") || message_lower.contains("error")) if (message_lower.contains("fix")
&& progress.unlock(AchievementId::Debugger) { || message_lower.contains("bug")
|| message_lower.contains("error"))
&& progress.unlock(AchievementId::Debugger)
{
newly_unlocked.push(AchievementId::Debugger); newly_unlocked.push(AchievementId::Debugger);
} }
@@ -550,10 +560,12 @@ pub fn check_achievements(
) -> Vec<AchievementId> { ) -> Vec<AchievementId> {
let mut newly_unlocked = Vec::new(); let mut newly_unlocked = Vec::new();
println!("Checking achievements with stats: messages={}, tokens={}, code_blocks={}", println!(
"Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
stats.messages_exchanged, stats.messages_exchanged,
stats.total_input_tokens + stats.total_output_tokens, stats.total_input_tokens + stats.total_output_tokens,
stats.code_blocks_generated); stats.code_blocks_generated
);
println!("Currently unlocked: {:?}", progress.unlocked); println!("Currently unlocked: {:?}", progress.unlocked);
// Token milestones // Token milestones
@@ -617,7 +629,8 @@ pub fn check_achievements(
// Search and exploration // Search and exploration
let search_tools = ["Glob", "Grep", "search", "Task"]; let search_tools = ["Glob", "Grep", "search", "Task"];
let search_count: u64 = search_tools.iter() let search_count: u64 = search_tools
.iter()
.filter_map(|tool| stats.tools_usage.get(*tool)) .filter_map(|tool| stats.tools_usage.get(*tool))
.sum(); .sum();
if search_count >= 50 && progress.unlock(AchievementId::Explorer) { if search_count >= 50 && progress.unlock(AchievementId::Explorer) {
@@ -629,7 +642,10 @@ pub fn check_achievements(
// Session duration achievements // Session duration achievements
let session_secs = stats.session_duration_seconds; let session_secs = stats.session_duration_seconds;
if session_secs < 300 && stats.session_messages_exchanged >= 5 && progress.unlock(AchievementId::QuickSession) { if session_secs < 300
&& stats.session_messages_exchanged >= 5
&& progress.unlock(AchievementId::QuickSession)
{
newly_unlocked.push(AchievementId::QuickSession); newly_unlocked.push(AchievementId::QuickSession);
} }
if session_secs >= 1800 && progress.unlock(AchievementId::FocusedWork) { if session_secs >= 1800 && progress.unlock(AchievementId::FocusedWork) {
@@ -716,7 +732,9 @@ pub fn check_achievements(
// Weekend warrior // Weekend warrior
use chrono::Weekday; use chrono::Weekday;
if (weekday == Weekday::Sat || weekday == Weekday::Sun) && progress.unlock(AchievementId::WeekendWarrior) { if (weekday == Weekday::Sat || weekday == Weekday::Sun)
&& progress.unlock(AchievementId::WeekendWarrior)
{
newly_unlocked.push(AchievementId::WeekendWarrior); newly_unlocked.push(AchievementId::WeekendWarrior);
} }
} }
@@ -733,16 +751,21 @@ pub struct AchievementUnlockedEvent {
} }
// Save achievements to persistent store // Save achievements to persistent store
pub async fn save_achievements(app: &tauri::AppHandle, progress: &AchievementProgress) -> Result<(), String> { pub async fn save_achievements(
let store = app.store("achievements.json") app: &tauri::AppHandle,
.map_err(|e| e.to_string())?; 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 // Create a serializable version with just the unlocked achievement IDs
let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect(); let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect();
println!("Saving achievements: {:?}", unlocked_list); println!("Saving achievements: {:?}", unlocked_list);
store.set("unlocked", serde_json::to_value(unlocked_list).map_err(|e| e.to_string())?); store.set(
"unlocked",
serde_json::to_value(unlocked_list).map_err(|e| e.to_string())?,
);
store.save().map_err(|e| e.to_string())?; store.save().map_err(|e| e.to_string())?;
println!("Achievements saved successfully"); println!("Achievements saved successfully");
@@ -766,7 +789,9 @@ pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
// Get unlocked achievements // Get unlocked achievements
if let Some(unlocked_value) = store.get("unlocked") { if let Some(unlocked_value) = store.get("unlocked") {
println!("Found unlocked value in store: {:?}", unlocked_value); println!("Found unlocked value in store: {:?}", unlocked_value);
if let Ok(unlocked_list) = serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone()) { if let Ok(unlocked_list) =
serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone())
{
println!("Loaded {} achievements", unlocked_list.len()); println!("Loaded {} achievements", unlocked_list.len());
for achievement_id in unlocked_list { for achievement_id in unlocked_list {
progress.unlocked.insert(achievement_id); progress.unlocked.insert(achievement_id);
@@ -805,4 +830,4 @@ mod tests {
let newly = progress.take_newly_unlocked(); let newly = progress.take_newly_unlocked();
assert!(newly.is_empty()); assert!(newly.is_empty());
} }
} }
+38 -12
View File
@@ -30,17 +30,25 @@ impl BridgeManager {
options: ClaudeStartOptions, options: ClaudeStartOptions,
) -> Result<(), String> { ) -> Result<(), String> {
// Check if a bridge already exists and is running for this conversation // Check if a bridge already exists and is running for this conversation
if self.bridges.get(conversation_id).map(|b| b.is_running()).unwrap_or(false) { 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()); return Err("Claude is already running for this conversation".to_string());
} }
let app = self.app_handle.as_ref() let app = self
.app_handle
.as_ref()
.ok_or_else(|| "App handle not set".to_string())? .ok_or_else(|| "App handle not set".to_string())?
.clone(); .clone();
// Reuse existing bridge if it exists (preserves stats across reconnects) // Reuse existing bridge if it exists (preserves stats across reconnects)
// Only create a new bridge if one doesn't exist for this conversation // Only create a new bridge if one doesn't exist for this conversation
let bridge = self.bridges let bridge = self
.bridges
.entry(conversation_id.to_string()) .entry(conversation_id.to_string())
.or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string())); .or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string()));
@@ -52,7 +60,9 @@ impl BridgeManager {
pub fn stop_claude(&mut self, conversation_id: &str) -> Result<(), String> { pub fn stop_claude(&mut self, conversation_id: &str) -> Result<(), String> {
if let Some(bridge) = self.bridges.get_mut(conversation_id) { if let Some(bridge) = self.bridges.get_mut(conversation_id) {
let app = self.app_handle.as_ref() let app = self
.app_handle
.as_ref()
.ok_or_else(|| "App handle not set".to_string())?; .ok_or_else(|| "App handle not set".to_string())?;
bridge.stop(app); bridge.stop(app);
Ok(()) Ok(())
@@ -63,7 +73,9 @@ impl BridgeManager {
pub fn interrupt_claude(&mut self, conversation_id: &str) -> Result<(), String> { pub fn interrupt_claude(&mut self, conversation_id: &str) -> Result<(), String> {
if let Some(bridge) = self.bridges.get_mut(conversation_id) { if let Some(bridge) = self.bridges.get_mut(conversation_id) {
let app = self.app_handle.as_ref() let app = self
.app_handle
.as_ref()
.ok_or_else(|| "App handle not set".to_string())?; .ok_or_else(|| "App handle not set".to_string())?;
bridge.interrupt(app) bridge.interrupt(app)
} else { } else {
@@ -79,7 +91,12 @@ impl BridgeManager {
} }
} }
pub fn send_tool_result(&mut self, conversation_id: &str, tool_use_id: &str, result: serde_json::Value) -> Result<(), String> { pub fn send_tool_result(
&mut self,
conversation_id: &str,
tool_use_id: &str,
result: serde_json::Value,
) -> Result<(), String> {
if let Some(bridge) = self.bridges.get_mut(conversation_id) { if let Some(bridge) = self.bridges.get_mut(conversation_id) {
bridge.send_tool_result(tool_use_id, result) bridge.send_tool_result(tool_use_id, result)
} else { } else {
@@ -88,19 +105,22 @@ impl BridgeManager {
} }
pub fn is_claude_running(&self, conversation_id: &str) -> bool { pub fn is_claude_running(&self, conversation_id: &str) -> bool {
self.bridges.get(conversation_id) self.bridges
.get(conversation_id)
.map(|b| b.is_running()) .map(|b| b.is_running())
.unwrap_or(false) .unwrap_or(false)
} }
pub fn get_working_directory(&self, conversation_id: &str) -> Result<String, String> { pub fn get_working_directory(&self, conversation_id: &str) -> Result<String, String> {
self.bridges.get(conversation_id) self.bridges
.get(conversation_id)
.map(|b| b.get_working_directory().to_string()) .map(|b| b.get_working_directory().to_string())
.ok_or_else(|| "No Claude instance found for this conversation".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> { pub fn get_usage_stats(&self, conversation_id: &str) -> Result<UsageStats, String> {
self.bridges.get(conversation_id) self.bridges
.get(conversation_id)
.map(|b| b.get_stats()) .map(|b| b.get_stats())
.ok_or_else(|| "No Claude instance found for this conversation".to_string()) .ok_or_else(|| "No Claude instance found for this conversation".to_string())
} }
@@ -123,8 +143,14 @@ impl BridgeManager {
#[allow(dead_code)] #[allow(dead_code)]
pub fn get_active_conversations(&self) -> Vec<String> { pub fn get_active_conversations(&self) -> Vec<String> {
self.bridges.keys() self.bridges
.filter(|id| self.bridges.get(*id).map(|b| b.is_running()).unwrap_or(false)) .keys()
.filter(|id| {
self.bridges
.get(*id)
.map(|b| b.is_running())
.unwrap_or(false)
})
.cloned() .cloned()
.collect() .collect()
} }
@@ -140,4 +166,4 @@ pub type SharedBridgeManager = Arc<Mutex<BridgeManager>>;
pub fn create_shared_bridge_manager() -> SharedBridgeManager { pub fn create_shared_bridge_manager() -> SharedBridgeManager {
Arc::new(Mutex::new(BridgeManager::new())) Arc::new(Mutex::new(BridgeManager::new()))
} }
+31 -27
View File
@@ -1,11 +1,11 @@
use tauri::{AppHandle, State}; use tauri::{AppHandle, State};
use tauri_plugin_store::StoreExt;
use tauri_plugin_http::reqwest; use tauri_plugin_http::reqwest;
use tauri_plugin_store::StoreExt;
use crate::achievements::{get_achievement_info, load_achievements, AchievementUnlockedEvent};
use crate::bridge_manager::SharedBridgeManager;
use crate::config::{ClaudeStartOptions, HikariConfig}; use crate::config::{ClaudeStartOptions, HikariConfig};
use crate::stats::UsageStats; use crate::stats::UsageStats;
use crate::bridge_manager::SharedBridgeManager;
use crate::achievements::{load_achievements, get_achievement_info, AchievementUnlockedEvent};
const CONFIG_STORE_KEY: &str = "config"; const CONFIG_STORE_KEY: &str = "config";
@@ -72,23 +72,17 @@ pub async fn select_wsl_directory() -> Result<String, String> {
#[tauri::command] #[tauri::command]
pub async fn get_config(app: AppHandle) -> Result<HikariConfig, String> { pub async fn get_config(app: AppHandle) -> Result<HikariConfig, String> {
let store = app let store = app.store("hikari-config.json").map_err(|e| e.to_string())?;
.store("hikari-config.json")
.map_err(|e| e.to_string())?;
match store.get(CONFIG_STORE_KEY) { match store.get(CONFIG_STORE_KEY) {
Some(value) => { Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()),
serde_json::from_value(value.clone()).map_err(|e| e.to_string())
}
None => Ok(HikariConfig::default()), None => Ok(HikariConfig::default()),
} }
} }
#[tauri::command] #[tauri::command]
pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), String> { pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), String> {
let store = app let store = app.store("hikari-config.json").map_err(|e| e.to_string())?;
.store("hikari-config.json")
.map_err(|e| e.to_string())?;
let value = serde_json::to_value(&config).map_err(|e| e.to_string())?; let value = serde_json::to_value(&config).map_err(|e| e.to_string())?;
store.set(CONFIG_STORE_KEY, value); store.set(CONFIG_STORE_KEY, value);
@@ -107,7 +101,10 @@ pub async fn get_usage_stats(
} }
#[tauri::command] #[tauri::command]
pub async fn validate_directory(path: String, current_dir: Option<String>) -> Result<String, String> { pub async fn validate_directory(
path: String,
current_dir: Option<String>,
) -> Result<String, String> {
use std::path::Path; use std::path::Path;
let path = Path::new(&path); let path = Path::new(&path);
@@ -137,11 +134,17 @@ pub async fn validate_directory(path: String, current_dir: Option<String>) -> Re
// Check if the path exists and is a directory // Check if the path exists and is a directory
if !expanded_path.exists() { if !expanded_path.exists() {
return Err(format!("Directory does not exist: {}", expanded_path.display())); return Err(format!(
"Directory does not exist: {}",
expanded_path.display()
));
} }
if !expanded_path.is_dir() { if !expanded_path.is_dir() {
return Err(format!("Path is not a directory: {}", expanded_path.display())); return Err(format!(
"Path is not a directory: {}",
expanded_path.display()
));
} }
// Return the canonicalized (absolute) path // Return the canonicalized (absolute) path
@@ -152,7 +155,9 @@ pub async fn validate_directory(path: String, current_dir: Option<String>) -> Re
} }
#[tauri::command] #[tauri::command]
pub async fn load_saved_achievements(app: AppHandle) -> Result<Vec<AchievementUnlockedEvent>, String> { pub async fn load_saved_achievements(
app: AppHandle,
) -> Result<Vec<AchievementUnlockedEvent>, String> {
use chrono::Utc; use chrono::Utc;
// Load achievements from persistent store // Load achievements from persistent store
@@ -163,9 +168,7 @@ pub async fn load_saved_achievements(app: AppHandle) -> Result<Vec<AchievementUn
for achievement_id in &progress.unlocked { for achievement_id in &progress.unlocked {
let mut info = get_achievement_info(achievement_id); let mut info = get_achievement_info(achievement_id);
info.unlocked_at = Some(Utc::now()); // We don't store timestamps, so just use now info.unlocked_at = Some(Utc::now()); // We don't store timestamps, so just use now
events.push(AchievementUnlockedEvent { events.push(AchievementUnlockedEvent { achievement: info });
achievement: info,
});
} }
Ok(events) Ok(events)
@@ -184,12 +187,12 @@ pub async fn answer_question(
#[tauri::command] #[tauri::command]
pub async fn list_skills() -> Result<Vec<String>, String> { pub async fn list_skills() -> Result<Vec<String>, String> {
use std::path::Path;
use std::fs; use std::fs;
use std::path::Path;
// Get the home directory // Get the home directory
let home = std::env::var_os("HOME") let home =
.ok_or_else(|| "Could not determine home directory".to_string())?; std::env::var_os("HOME").ok_or_else(|| "Could not determine home directory".to_string())?;
let skills_dir = Path::new(&home).join(".claude").join("skills"); let skills_dir = Path::new(&home).join(".claude").join("skills");
@@ -200,8 +203,8 @@ pub async fn list_skills() -> Result<Vec<String>, String> {
// Read the directory and collect skill names // Read the directory and collect skill names
let mut skills = Vec::new(); let mut skills = Vec::new();
let entries = fs::read_dir(&skills_dir) let entries =
.map_err(|e| format!("Failed to read skills directory: {}", e))?; fs::read_dir(&skills_dir).map_err(|e| format!("Failed to read skills directory: {}", e))?;
for entry in entries { for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
@@ -244,7 +247,8 @@ struct GiteaRelease {
#[tauri::command] #[tauri::command]
pub async fn check_for_updates() -> Result<UpdateInfo, String> { pub async fn check_for_updates() -> Result<UpdateInfo, String> {
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const RELEASES_API: &str = "https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/hikari-desktop/releases"; const RELEASES_API: &str =
"https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/hikari-desktop/releases";
// Fetch releases from Gitea API // Fetch releases from Gitea API
let client = reqwest::Client::new(); let client = reqwest::Client::new();
@@ -264,8 +268,8 @@ pub async fn check_for_updates() -> Result<UpdateInfo, String> {
.await .await
.map_err(|e| format!("Failed to read response: {}", e))?; .map_err(|e| format!("Failed to read response: {}", e))?;
let releases: Vec<GiteaRelease> = serde_json::from_str(&text) let releases: Vec<GiteaRelease> =
.map_err(|e| format!("Failed to parse releases: {}", e))?; serde_json::from_str(&text).map_err(|e| format!("Failed to parse releases: {}", e))?;
// Find the latest non-prerelease, or fall back to latest prerelease // Find the latest non-prerelease, or fall back to latest prerelease
let latest = releases let latest = releases
+14 -1
View File
@@ -67,6 +67,9 @@ pub struct HikariConfig {
#[serde(default)] #[serde(default)]
pub character_panel_width: Option<u32>, pub character_panel_width: Option<u32>,
#[serde(default = "default_font_size")]
pub font_size: u32,
} }
impl Default for HikariConfig { impl Default for HikariConfig {
@@ -85,6 +88,7 @@ impl Default for HikariConfig {
always_on_top: false, always_on_top: false,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: None, character_panel_width: None,
font_size: 14,
} }
} }
} }
@@ -105,6 +109,10 @@ fn default_notification_volume() -> f32 {
0.7 0.7
} }
fn default_font_size() -> u32 {
14
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum Theme { pub enum Theme {
@@ -131,6 +139,7 @@ mod tests {
assert!(!config.always_on_top); assert!(!config.always_on_top);
assert!(config.update_checks_enabled); assert!(config.update_checks_enabled);
assert!(config.character_panel_width.is_none()); assert!(config.character_panel_width.is_none());
assert_eq!(config.font_size, 14);
} }
#[test] #[test]
@@ -149,6 +158,7 @@ mod tests {
always_on_top: true, always_on_top: true,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: Some(400), character_panel_width: Some(400),
font_size: 16,
}; };
let json = serde_json::to_string(&config).unwrap(); let json = serde_json::to_string(&config).unwrap();
@@ -159,7 +169,10 @@ mod tests {
assert_eq!(deserialized.auto_granted_tools, config.auto_granted_tools); assert_eq!(deserialized.auto_granted_tools, config.auto_granted_tools);
assert_eq!(deserialized.theme, Theme::Light); assert_eq!(deserialized.theme, Theme::Light);
assert!(deserialized.greeting_enabled); assert!(deserialized.greeting_enabled);
assert_eq!(deserialized.greeting_custom_prompt, Some("Hello!".to_string())); assert_eq!(
deserialized.greeting_custom_prompt,
Some("Hello!".to_string())
);
} }
#[test] #[test]
+5 -5
View File
@@ -5,18 +5,18 @@ mod config;
mod notifications; mod notifications;
mod stats; mod stats;
mod types; mod types;
mod wsl_bridge;
mod wsl_notifications;
mod vbs_notification; mod vbs_notification;
mod windows_toast; mod windows_toast;
mod wsl_bridge;
mod wsl_notifications;
use commands::*;
use notifications::*;
use bridge_manager::create_shared_bridge_manager; use bridge_manager::create_shared_bridge_manager;
use commands::load_saved_achievements; use commands::load_saved_achievements;
use wsl_notifications::*; use commands::*;
use notifications::*;
use vbs_notification::*; use vbs_notification::*;
use windows_toast::*; use windows_toast::*;
use wsl_notifications::*;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
+8 -3
View File
@@ -1,5 +1,5 @@
use tauri::command;
use std::process::Command; use std::process::Command;
use tauri::command;
#[command] #[command]
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> { pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
@@ -10,7 +10,12 @@ pub async fn send_notify_send(title: String, body: String) -> Result<(), String>
.arg("--urgency=normal") .arg("--urgency=normal")
.arg("--app-name=Hikari Desktop") .arg("--app-name=Hikari Desktop")
.output() .output()
.map_err(|e| format!("Failed to execute notify-send: {}. Make sure libnotify-bin is installed.", e))?; .map_err(|e| {
format!(
"Failed to execute notify-send: {}. Make sure libnotify-bin is installed.",
e
)
})?;
if !output.status.success() { if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr); let error = String::from_utf8_lossy(&output.stderr);
@@ -93,4 +98,4 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(),
.map_err(|e| format!("Failed to send message: {}", e))?; .map_err(|e| format!("Failed to send message: {}", e))?;
Ok(()) Ok(())
} }
+6 -3
View File
@@ -1,7 +1,7 @@
use crate::achievements::{check_achievements, AchievementProgress};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Instant; use std::time::Instant;
use crate::achievements::{AchievementProgress, check_achievements};
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UsageStats { pub struct UsageStats {
@@ -89,7 +89,10 @@ impl UsageStats {
pub fn increment_tool_usage(&mut self, tool_name: &str) { pub fn increment_tool_usage(&mut self, tool_name: &str) {
*self.tools_usage.entry(tool_name.to_string()).or_insert(0) += 1; *self.tools_usage.entry(tool_name.to_string()).or_insert(0) += 1;
*self.session_tools_usage.entry(tool_name.to_string()).or_insert(0) += 1; *self
.session_tools_usage
.entry(tool_name.to_string())
.or_insert(0) += 1;
} }
pub fn get_session_duration(&mut self) -> u64 { pub fn get_session_duration(&mut self) -> u64 {
@@ -213,4 +216,4 @@ mod tests {
assert_eq!(stats.session_cost_usd, 0.0); assert_eq!(stats.session_cost_usd, 0.0);
assert!(stats.total_cost_usd > 0.0); assert!(stats.total_cost_usd > 0.0);
} }
} }
+6 -9
View File
@@ -1,7 +1,7 @@
use std::process::Command;
use std::io::Write; use std::io::Write;
use tempfile::NamedTempFile; use std::process::Command;
use tauri::command; use tauri::command;
use tempfile::NamedTempFile;
#[command] #[command]
pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> { pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> {
@@ -17,8 +17,8 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
); );
// Create a temporary VBS file // Create a temporary VBS file
let mut temp_file = NamedTempFile::new() let mut temp_file =
.map_err(|e| format!("Failed to create temp file: {}", e))?; NamedTempFile::new().map_err(|e| format!("Failed to create temp file: {}", e))?;
temp_file temp_file
.write_all(vbs_content.as_bytes()) .write_all(vbs_content.as_bytes())
@@ -40,10 +40,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
} else if temp_path.starts_with("/tmp/") { } else if temp_path.starts_with("/tmp/") {
// WSL temp files might be in a different location // WSL temp files might be in a different location
// Try to use wslpath to convert // Try to use wslpath to convert
let output = Command::new("wslpath") let output = Command::new("wslpath").arg("-w").arg(&temp_path).output();
.arg("-w")
.arg(&temp_path)
.output();
if let Ok(result) = output { if let Ok(result) = output {
if result.status.success() { if result.status.success() {
@@ -71,4 +68,4 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
} }
Ok(()) Ok(())
} }
+4 -3
View File
@@ -2,7 +2,7 @@ use tauri::command;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
use windows::{ use windows::{
core::{HSTRING, Result as WindowsResult}, core::{Result as WindowsResult, HSTRING},
Data::Xml::Dom::*, Data::Xml::Dom::*,
UI::Notifications::*, UI::Notifications::*,
}; };
@@ -38,7 +38,8 @@ fn show_toast_notification(title: &str, body: &str) -> WindowsResult<()> {
let toast = ToastNotification::CreateToastNotification(&xml_doc)?; let toast = ToastNotification::CreateToastNotification(&xml_doc)?;
// Create a toast notifier with an application ID // Create a toast notifier with an application ID
let notifier = ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from("Hikari Desktop"))?; let notifier =
ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from("Hikari Desktop"))?;
// Show the notification // Show the notification
notifier.Show(&toast)?; notifier.Show(&toast)?;
@@ -60,4 +61,4 @@ fn escape_xml(text: &str) -> String {
#[command] #[command]
pub async fn send_windows_toast(_title: String, _body: String) -> Result<(), String> { pub async fn send_windows_toast(_title: String, _body: String) -> Result<(), String> {
Err("Windows toast notifications are only available on Windows".to_string()) Err("Windows toast notifications are only available on Windows".to_string())
} }
+267 -117
View File
@@ -8,11 +8,15 @@ use tempfile::NamedTempFile;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
use crate::config::ClaudeStartOptions;
use crate::stats::{UsageStats, StatsUpdateEvent};
use parking_lot::RwLock;
use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent, ConnectionEvent, SessionEvent, WorkingDirectoryEvent, UserQuestionEvent, QuestionOption};
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent}; use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
use crate::config::ClaudeStartOptions;
use crate::stats::{StatsUpdateEvent, UsageStats};
use crate::types::{
CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, OutputEvent,
PermissionPromptEvent, QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent,
WorkingDirectoryEvent,
};
use parking_lot::RwLock;
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"]; const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"]; const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
@@ -103,7 +107,6 @@ impl WslBridge {
} }
} }
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> { pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
if self.process.is_some() { if self.process.is_some() {
return Err("Process already running".to_string()); return Err("Process already running".to_string());
@@ -115,14 +118,21 @@ impl WslBridge {
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
println!("Loading saved achievements..."); println!("Loading saved achievements...");
let achievements = crate::achievements::load_achievements(&app_clone).await; let achievements = crate::achievements::load_achievements(&app_clone).await;
println!("Loaded {} unlocked achievements", achievements.unlocked.len()); println!(
"Loaded {} unlocked achievements",
achievements.unlocked.len()
);
stats.write().achievements = achievements; stats.write().achievements = achievements;
}); });
let working_dir = &options.working_dir; let working_dir = &options.working_dir;
self.working_directory = working_dir.clone(); self.working_directory = working_dir.clone();
emit_connection_status(&app, ConnectionStatus::Connecting, self.conversation_id.clone()); emit_connection_status(
&app,
ConnectionStatus::Connecting,
self.conversation_id.clone(),
);
// Create temp file for MCP config if provided // Create temp file for MCP config if provided
let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json { let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json {
@@ -158,16 +168,19 @@ impl WslBridge {
let mut command = if is_wsl { let mut command = if is_wsl {
// Running inside WSL - call claude directly // Running inside WSL - call claude directly
// Try to find claude in common locations since GUI apps may not inherit shell PATH // Try to find claude in common locations since GUI apps may not inherit shell PATH
let claude_path = find_claude_binary() let claude_path = find_claude_binary().ok_or_else(|| {
.ok_or_else(|| "Could not find claude binary. Is Claude Code installed?".to_string())?; "Could not find claude binary. Is Claude Code installed?".to_string()
})?;
eprintln!("[DEBUG] Found claude at: {}", claude_path); eprintln!("[DEBUG] Found claude at: {}", claude_path);
eprintln!("[DEBUG] Working dir: {}", working_dir); eprintln!("[DEBUG] Working dir: {}", working_dir);
let mut cmd = Command::new(&claude_path); let mut cmd = Command::new(&claude_path);
cmd.args([ cmd.args([
"--output-format", "stream-json", "--output-format",
"--input-format", "stream-json", "stream-json",
"--input-format",
"stream-json",
"--verbose", "--verbose",
]); ]);
@@ -218,10 +231,7 @@ impl WslBridge {
let mut cmd = Command::new("wsl"); let mut cmd = Command::new("wsl");
// Build the claude command with all arguments // Build the claude command with all arguments
let mut claude_cmd = format!( let mut claude_cmd = format!("cd '{}' && ", working_dir);
"cd '{}' && ",
working_dir
);
// Set API key as environment variable if specified // Set API key as environment variable if specified
if let Some(ref api_key) = options.api_key { if let Some(ref api_key) = options.api_key {
@@ -230,7 +240,9 @@ impl WslBridge {
} }
} }
claude_cmd.push_str("claude --output-format stream-json --input-format stream-json --verbose"); claude_cmd.push_str(
"claude --output-format stream-json --input-format stream-json --verbose",
);
// Add model if specified // Add model if specified
if let Some(ref model) = options.model { if let Some(ref model) = options.model {
@@ -320,7 +332,11 @@ impl WslBridge {
}); });
} }
emit_connection_status(&app, ConnectionStatus::Connected, self.conversation_id.clone()); emit_connection_status(
&app,
ConnectionStatus::Connected,
self.conversation_id.clone(),
);
Ok(()) Ok(())
} }
@@ -345,12 +361,18 @@ impl WslBridge {
.write_all(format!("{}\n", json_line).as_bytes()) .write_all(format!("{}\n", json_line).as_bytes())
.map_err(|e| format!("Failed to write to stdin: {}", e))?; .map_err(|e| format!("Failed to write to stdin: {}", e))?;
stdin.flush().map_err(|e| format!("Failed to flush stdin: {}", e))?; stdin
.flush()
.map_err(|e| format!("Failed to flush stdin: {}", e))?;
Ok(()) Ok(())
} }
pub fn send_tool_result(&mut self, tool_use_id: &str, result: serde_json::Value) -> Result<(), String> { pub fn send_tool_result(
&mut self,
tool_use_id: &str,
result: serde_json::Value,
) -> Result<(), String> {
let stdin = self.stdin.as_mut().ok_or("Process not running")?; let stdin = self.stdin.as_mut().ok_or("Process not running")?;
// The content should be a JSON string representation of the result // The content should be a JSON string representation of the result
@@ -374,7 +396,9 @@ impl WslBridge {
.write_all(format!("{}\n", json_line).as_bytes()) .write_all(format!("{}\n", json_line).as_bytes())
.map_err(|e| format!("Failed to write to stdin: {}", e))?; .map_err(|e| format!("Failed to write to stdin: {}", e))?;
stdin.flush().map_err(|e| format!("Failed to flush stdin: {}", e))?; stdin
.flush()
.map_err(|e| format!("Failed to flush stdin: {}", e))?;
Ok(()) Ok(())
} }
@@ -395,7 +419,11 @@ impl WslBridge {
// The user will see what session was interrupted // The user will see what session was interrupted
// Emit disconnected status // Emit disconnected status
emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone()); emit_connection_status(
app,
ConnectionStatus::Disconnected,
self.conversation_id.clone(),
);
Ok(()) Ok(())
} else { } else {
@@ -415,7 +443,11 @@ impl WslBridge {
// Reset session stats on explicit disconnect // Reset session stats on explicit disconnect
self.stats.write().reset_session(); self.stats.write().reset_session();
emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone()); emit_connection_status(
app,
ConnectionStatus::Disconnected,
self.conversation_id.clone(),
);
} }
pub fn is_running(&self) -> bool { pub fn is_running(&self) -> bool {
@@ -429,7 +461,6 @@ impl WslBridge {
pub fn get_stats(&self) -> UsageStats { pub fn get_stats(&self) -> UsageStats {
self.stats.read().clone() self.stats.read().clone()
} }
} }
impl Default for WslBridge { impl Default for WslBridge {
@@ -438,7 +469,12 @@ impl Default for WslBridge {
} }
} }
fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<RwLock<UsageStats>>, conversation_id: Option<String>) { fn handle_stdout(
stdout: std::process::ChildStdout,
app: AppHandle,
stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>,
) {
let reader = BufReader::new(stdout); let reader = BufReader::new(stdout);
for line in reader.lines() { for line in reader.lines() {
@@ -459,18 +495,25 @@ fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<R
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id); emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
} }
fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle, conversation_id: Option<String>) { fn handle_stderr(
stderr: std::process::ChildStderr,
app: AppHandle,
conversation_id: Option<String>,
) {
let reader = BufReader::new(stderr); let reader = BufReader::new(stderr);
for line in reader.lines() { for line in reader.lines() {
match line { match line {
Ok(line) if !line.is_empty() => { Ok(line) if !line.is_empty() => {
let _ = app.emit("claude:output", OutputEvent { let _ = app.emit(
line_type: "error".to_string(), "claude:output",
content: line, OutputEvent {
tool_name: None, line_type: "error".to_string(),
conversation_id: conversation_id.clone(), content: line,
}); tool_name: None,
conversation_id: conversation_id.clone(),
},
);
} }
Err(_) => break, Err(_) => break,
_ => {} _ => {}
@@ -478,24 +521,40 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle, conversation
} }
} }
fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>>, conversation_id: &Option<String>) -> Result<(), String> { fn process_json_line(
line: &str,
app: &AppHandle,
stats: &Arc<RwLock<UsageStats>>,
conversation_id: &Option<String>,
) -> Result<(), String> {
let message: ClaudeMessage = serde_json::from_str(line) let message: ClaudeMessage = serde_json::from_str(line)
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?; .map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
match &message { match &message {
ClaudeMessage::System { subtype, session_id, cwd, .. } => { ClaudeMessage::System {
subtype,
session_id,
cwd,
..
} => {
if subtype == "init" { if subtype == "init" {
if let Some(id) = session_id { if let Some(id) = session_id {
let _ = app.emit("claude:session", SessionEvent { let _ = app.emit(
session_id: id.clone(), "claude:session",
conversation_id: conversation_id.clone(), SessionEvent {
}); session_id: id.clone(),
conversation_id: conversation_id.clone(),
},
);
} }
if let Some(dir) = cwd { if let Some(dir) = cwd {
let _ = app.emit("claude:cwd", WorkingDirectoryEvent { let _ = app.emit(
directory: dir.clone(), "claude:cwd",
conversation_id: conversation_id.clone(), WorkingDirectoryEvent {
}); directory: dir.clone(),
conversation_id: conversation_id.clone(),
},
);
} }
emit_state_change(app, CharacterState::Idle, None, conversation_id.clone()); emit_state_change(app, CharacterState::Idle, None, conversation_id.clone());
} }
@@ -547,12 +606,15 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
} }
let desc = format_tool_description(name, input); let desc = format_tool_description(name, input);
let _ = app.emit("claude:output", OutputEvent { let _ = app.emit(
line_type: "tool".to_string(), "claude:output",
content: desc, OutputEvent {
tool_name: Some(name.clone()), line_type: "tool".to_string(),
conversation_id: conversation_id.clone(), content: desc,
}); tool_name: Some(name.clone()),
conversation_id: conversation_id.clone(),
},
);
} }
ContentBlock::Text { text } => { ContentBlock::Text { text } => {
// Count code blocks in the text // Count code blocks in the text
@@ -561,21 +623,27 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
stats.write().increment_code_blocks(); stats.write().increment_code_blocks();
} }
let _ = app.emit("claude:output", OutputEvent { let _ = app.emit(
line_type: "assistant".to_string(), "claude:output",
content: text.clone(), OutputEvent {
tool_name: None, line_type: "assistant".to_string(),
conversation_id: conversation_id.clone(), content: text.clone(),
}); tool_name: None,
conversation_id: conversation_id.clone(),
},
);
} }
ContentBlock::Thinking { thinking } => { ContentBlock::Thinking { thinking } => {
state = CharacterState::Thinking; state = CharacterState::Thinking;
let _ = app.emit("claude:output", OutputEvent { let _ = app.emit(
line_type: "system".to_string(), "claude:output",
content: format!("[Thinking] {}", thinking), OutputEvent {
tool_name: None, line_type: "system".to_string(),
conversation_id: conversation_id.clone(), content: format!("[Thinking] {}", thinking),
}); tool_name: None,
conversation_id: conversation_id.clone(),
},
);
} }
_ => {} _ => {}
} }
@@ -610,7 +678,13 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
} }
} }
ClaudeMessage::Result { subtype, result, permission_denials, usage: _, .. } => { ClaudeMessage::Result {
subtype,
result,
permission_denials,
usage: _,
..
} => {
let state = if subtype == "success" { let state = if subtype == "success" {
CharacterState::Success CharacterState::Success
} else { } else {
@@ -631,9 +705,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
// Emit achievement events for any newly unlocked achievements // Emit achievement events for any newly unlocked achievements
for achievement_id in &newly_unlocked { for achievement_id in &newly_unlocked {
let info = get_achievement_info(achievement_id); let info = get_achievement_info(achievement_id);
let _ = app.emit("achievement:unlocked", AchievementUnlockedEvent { let _ = app.emit(
achievement: info, "achievement:unlocked",
}); AchievementUnlockedEvent { achievement: info },
);
} }
// Save achievements after unlocking new ones // Save achievements after unlocking new ones
@@ -645,7 +720,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
// Use Tauri's async runtime instead of tokio::spawn // Use Tauri's async runtime instead of tokio::spawn
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
println!("Spawned save task for achievements"); println!("Spawned save task for achievements");
if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress).await { if let Err(e) =
crate::achievements::save_achievements(&app_handle, &achievements_progress)
.await
{
eprintln!("Failed to save achievements: {}", e); eprintln!("Failed to save achievements: {}", e);
} else { } else {
println!("Achievement save task completed successfully"); println!("Achievement save task completed successfully");
@@ -662,12 +740,15 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
// Only emit error results - success content is already sent via Assistant message // Only emit error results - success content is already sent via Assistant message
if subtype != "success" { if subtype != "success" {
if let Some(text) = result { if let Some(text) = result {
let _ = app.emit("claude:output", OutputEvent { let _ = app.emit(
line_type: "error".to_string(), "claude:output",
content: text.clone(), OutputEvent {
tool_name: None, line_type: "error".to_string(),
conversation_id: conversation_id.clone(), content: text.clone(),
}); tool_name: None,
conversation_id: conversation_id.clone(),
},
);
} }
} }
@@ -678,64 +759,88 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
for denial in denials { for denial in denials {
// Special handling for AskUserQuestion tool // Special handling for AskUserQuestion tool
if denial.tool_name == "AskUserQuestion" { if denial.tool_name == "AskUserQuestion" {
if let Some(questions) = denial.tool_input.get("questions").and_then(|q| q.as_array()) { if let Some(questions) = denial
.tool_input
.get("questions")
.and_then(|q| q.as_array())
{
// For now, handle the first question (most common case) // For now, handle the first question (most common case)
if let Some(first_question) = questions.first() { if let Some(first_question) = questions.first() {
let question_text = first_question.get("question") let question_text = first_question
.get("question")
.and_then(|q| q.as_str()) .and_then(|q| q.as_str())
.unwrap_or("Claude has a question for you") .unwrap_or("Claude has a question for you")
.to_string(); .to_string();
let header = first_question.get("header") let header = first_question
.get("header")
.and_then(|h| h.as_str()) .and_then(|h| h.as_str())
.map(|s| s.to_string()); .map(|s| s.to_string());
let multi_select = first_question.get("multiSelect") let multi_select = first_question
.get("multiSelect")
.and_then(|m| m.as_bool()) .and_then(|m| m.as_bool())
.unwrap_or(false); .unwrap_or(false);
let options: Vec<QuestionOption> = first_question.get("options") let options: Vec<QuestionOption> = first_question
.get("options")
.and_then(|opts| opts.as_array()) .and_then(|opts| opts.as_array())
.map(|opts| { .map(|opts| {
opts.iter().filter_map(|opt| { opts.iter()
let label = opt.get("label").and_then(|l| l.as_str())?; .filter_map(|opt| {
let description = opt.get("description") let label =
.and_then(|d| d.as_str()) opt.get("label").and_then(|l| l.as_str())?;
.map(|s| s.to_string()); let description = opt
Some(QuestionOption { .get("description")
label: label.to_string(), .and_then(|d| d.as_str())
description, .map(|s| s.to_string());
Some(QuestionOption {
label: label.to_string(),
description,
})
}) })
}).collect() .collect()
}) })
.unwrap_or_default(); .unwrap_or_default();
let _ = app.emit("claude:question", UserQuestionEvent { let _ = app.emit(
id: denial.tool_use_id.clone(), "claude:question",
question: question_text, UserQuestionEvent {
header, id: denial.tool_use_id.clone(),
options, question: question_text,
multi_select, header,
conversation_id: conversation_id.clone(), options,
}); multi_select,
conversation_id: conversation_id.clone(),
},
);
} }
} }
} else { } else {
has_regular_denials = true; has_regular_denials = true;
let description = format_tool_description(&denial.tool_name, &denial.tool_input); let description =
let _ = app.emit("claude:permission", PermissionPromptEvent { format_tool_description(&denial.tool_name, &denial.tool_input);
id: denial.tool_use_id.clone(), let _ = app.emit(
tool_name: denial.tool_name.clone(), "claude:permission",
tool_input: denial.tool_input.clone(), PermissionPromptEvent {
description, id: denial.tool_use_id.clone(),
conversation_id: conversation_id.clone(), tool_name: denial.tool_name.clone(),
}); tool_input: denial.tool_input.clone(),
description,
conversation_id: conversation_id.clone(),
},
);
} }
} }
// Show permission state if there were any denials (questions or regular) // Show permission state if there were any denials (questions or regular)
if has_regular_denials || !denials.is_empty() { if has_regular_denials || !denials.is_empty() {
emit_state_change(app, CharacterState::Permission, None, conversation_id.clone()); emit_state_change(
app,
CharacterState::Permission,
None,
conversation_id.clone(),
);
return Ok(()); return Ok(());
} }
} }
@@ -748,7 +853,9 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
stats.write().increment_messages(); stats.write().increment_messages();
// Extract text content from the message // Extract text content from the message
let message_text = message.content.iter() let message_text = message
.content
.iter()
.filter_map(|block| match block { .filter_map(|block| match block {
crate::types::ContentBlock::Text { text } => Some(text.clone()), crate::types::ContentBlock::Text { text } => Some(text.clone()),
_ => None, _ => None,
@@ -778,9 +885,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
for achievement_id in &newly_unlocked { for achievement_id in &newly_unlocked {
println!("User message unlocked achievement: {:?}", achievement_id); println!("User message unlocked achievement: {:?}", achievement_id);
let info = get_achievement_info(achievement_id); let info = get_achievement_info(achievement_id);
let _ = app.emit("achievement:unlocked", AchievementUnlockedEvent { let _ = app.emit(
achievement: info, "achievement:unlocked",
}); AchievementUnlockedEvent { achievement: info },
);
} }
// Save achievements after unlocking new ones // Save achievements after unlocking new ones
@@ -789,7 +897,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
let app_handle = app.clone(); let app_handle = app.clone();
let achievements_progress = stats.read().achievements.clone(); let achievements_progress = stats.read().achievements.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress).await { if let Err(e) =
crate::achievements::save_achievements(&app_handle, &achievements_progress)
.await
{
eprintln!("Failed to save achievements: {}", e); eprintln!("Failed to save achievements: {}", e);
} else { } else {
println!("Achievements saved after user message"); println!("Achievements saved after user message");
@@ -864,15 +975,36 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
} }
} }
fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option<String>, conversation_id: Option<String>) { fn emit_state_change(
let _ = app.emit("claude:state", StateChangeEvent { state, tool_name, conversation_id }); app: &AppHandle,
state: CharacterState,
tool_name: Option<String>,
conversation_id: Option<String>,
) {
let _ = app.emit(
"claude:state",
StateChangeEvent {
state,
tool_name,
conversation_id,
},
);
} }
fn emit_connection_status(app: &AppHandle, status: ConnectionStatus, conversation_id: Option<String>) { fn emit_connection_status(
let _ = app.emit("claude:connection", ConnectionEvent { status, conversation_id }); app: &AppHandle,
status: ConnectionStatus,
conversation_id: Option<String>,
) {
let _ = app.emit(
"claude:connection",
ConnectionEvent {
status,
conversation_id,
},
);
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -882,21 +1014,36 @@ mod tests {
assert!(matches!(get_tool_state("Read"), CharacterState::Searching)); assert!(matches!(get_tool_state("Read"), CharacterState::Searching));
assert!(matches!(get_tool_state("Glob"), CharacterState::Searching)); assert!(matches!(get_tool_state("Glob"), CharacterState::Searching));
assert!(matches!(get_tool_state("Grep"), CharacterState::Searching)); assert!(matches!(get_tool_state("Grep"), CharacterState::Searching));
assert!(matches!(get_tool_state("WebSearch"), CharacterState::Searching)); assert!(matches!(
assert!(matches!(get_tool_state("WebFetch"), CharacterState::Searching)); get_tool_state("WebSearch"),
CharacterState::Searching
));
assert!(matches!(
get_tool_state("WebFetch"),
CharacterState::Searching
));
} }
#[test] #[test]
fn test_get_tool_state_coding_tools() { fn test_get_tool_state_coding_tools() {
assert!(matches!(get_tool_state("Edit"), CharacterState::Coding)); assert!(matches!(get_tool_state("Edit"), CharacterState::Coding));
assert!(matches!(get_tool_state("Write"), CharacterState::Coding)); assert!(matches!(get_tool_state("Write"), CharacterState::Coding));
assert!(matches!(get_tool_state("NotebookEdit"), CharacterState::Coding)); assert!(matches!(
get_tool_state("NotebookEdit"),
CharacterState::Coding
));
} }
#[test] #[test]
fn test_get_tool_state_mcp_tools() { fn test_get_tool_state_mcp_tools() {
assert!(matches!(get_tool_state("mcp__github__create_issue"), CharacterState::Mcp)); assert!(matches!(
assert!(matches!(get_tool_state("mcp__notion__search"), CharacterState::Mcp)); get_tool_state("mcp__github__create_issue"),
CharacterState::Mcp
));
assert!(matches!(
get_tool_state("mcp__notion__search"),
CharacterState::Mcp
));
} }
#[test] #[test]
@@ -906,7 +1053,10 @@ mod tests {
#[test] #[test]
fn test_get_tool_state_unknown() { fn test_get_tool_state_unknown() {
assert!(matches!(get_tool_state("SomeUnknownTool"), CharacterState::Typing)); assert!(matches!(
get_tool_state("SomeUnknownTool"),
CharacterState::Typing
));
assert!(matches!(get_tool_state("Bash"), CharacterState::Typing)); assert!(matches!(get_tool_state("Bash"), CharacterState::Typing));
} }
+1 -1
View File
@@ -81,4 +81,4 @@ $notifier.Show($toast)
// If all methods fail, return an error // If all methods fail, return an error
Err("All WSL notification methods failed".to_string()) Err("All WSL notification methods failed".to_string())
} }
+65 -18
View File
@@ -1,5 +1,13 @@
<script lang="ts"> <script lang="ts">
import { configStore, type HikariConfig, type Theme } from "$lib/stores/config"; import {
configStore,
type HikariConfig,
type Theme,
applyFontSize,
MIN_FONT_SIZE,
MAX_FONT_SIZE,
DEFAULT_FONT_SIZE,
} from "$lib/stores/config";
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
@@ -17,6 +25,7 @@
always_on_top: false, always_on_top: false,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null, character_panel_width: null,
font_size: 14,
}); });
let isOpen = $state(false); let isOpen = $state(false);
@@ -388,23 +397,61 @@
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3"> <h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Appearance Appearance
</h3> </h3>
<div class="flex gap-2">
<button <!-- Theme Selection -->
onclick={() => handleThemeChange("dark")} <div class="mb-4">
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'dark' <label class="block text-sm text-[var(--text-secondary)] mb-2">Theme</label>
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white' <div class="flex gap-2">
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}" <button
> onclick={() => handleThemeChange("dark")}
Dark class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'dark'
</button> ? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
<button : 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
onclick={() => handleThemeChange("light")} >
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'light' Dark
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white' </button>
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}" <button
> onclick={() => handleThemeChange("light")}
Light class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'light'
</button> ? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
>
Light
</button>
</div>
</div>
<!-- Font Size -->
<div class="mb-4">
<label for="font-size" class="block text-sm text-[var(--text-secondary)] mb-2">
Terminal Font Size
</label>
<div class="flex items-center gap-3">
<input
id="font-size"
type="range"
bind:value={config.font_size}
oninput={() => applyFontSize(config.font_size)}
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
step="1"
class="flex-1 h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
/>
<span class="text-sm text-gray-300 w-12 text-right">{config.font_size}px</span>
<button
onclick={() => {
config.font_size = DEFAULT_FONT_SIZE;
applyFontSize(DEFAULT_FONT_SIZE);
}}
class="px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:border-[var(--accent-primary)] text-[var(--text-secondary)] transition-colors"
title="Reset to default (14px)"
>
Reset
</button>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">
Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset
</p>
</div> </div>
</section> </section>
+1 -1
View File
@@ -375,7 +375,7 @@ User: ${formattedMessage}`;
: "Connect to Claude first..."} : "Connect to Claude first..."}
disabled={isSubmitting} disabled={isSubmitting}
rows={1} rows={1}
style="height: {textareaHeight}px" style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px);"
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none 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)] focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]
+1
View File
@@ -48,6 +48,7 @@
always_on_top: false, always_on_top: false,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null, character_panel_width: null,
font_size: 14,
}); });
onMount(async () => { onMount(async () => {
+2 -1
View File
@@ -143,7 +143,8 @@
<div <div
bind:this={terminalElement} bind:this={terminalElement}
onscroll={handleScroll} onscroll={handleScroll}
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono text-sm" class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono"
style="font-size: var(--terminal-font-size, 14px);"
> >
{#if lines.length === 0} {#if lines.length === 0}
<div class="terminal-waiting italic"> <div class="terminal-waiting italic">
+46
View File
@@ -17,6 +17,7 @@ export interface HikariConfig {
always_on_top: boolean; always_on_top: boolean;
update_checks_enabled: boolean; update_checks_enabled: boolean;
character_panel_width: number | null; character_panel_width: number | null;
font_size: number;
} }
const defaultConfig: HikariConfig = { const defaultConfig: HikariConfig = {
@@ -33,6 +34,7 @@ const defaultConfig: HikariConfig = {
always_on_top: false, always_on_top: false,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null, character_panel_width: null,
font_size: 14,
}; };
function createConfigStore() { function createConfigStore() {
@@ -93,6 +95,33 @@ function createConfigStore() {
applyTheme(theme); applyTheme(theme);
}, },
setFontSize: async (size: number) => {
const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
await updateConfig({ font_size: clampedSize });
applyFontSize(clampedSize);
},
increaseFontSize: async () => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2);
await updateConfig({ font_size: newSize });
applyFontSize(newSize);
},
decreaseFontSize: async () => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2);
await updateConfig({ font_size: newSize });
applyFontSize(newSize);
},
resetFontSize: async () => {
await updateConfig({ font_size: DEFAULT_FONT_SIZE });
applyFontSize(DEFAULT_FONT_SIZE);
},
addAutoGrantedTool: async (tool: string) => { addAutoGrantedTool: async (tool: string) => {
let currentConfig: HikariConfig = defaultConfig; let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))(); config.subscribe((c) => (currentConfig = c))();
@@ -123,6 +152,23 @@ export function applyTheme(theme: Theme) {
} }
} }
const MIN_FONT_SIZE = 10;
const MAX_FONT_SIZE = 24;
const DEFAULT_FONT_SIZE = 14;
export function applyFontSize(size: number) {
if (typeof document !== "undefined") {
const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
document.documentElement.style.setProperty("--terminal-font-size", `${clampedSize}px`);
}
}
export function clampFontSize(size: number): number {
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
}
export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
export const configStore = createConfigStore(); export const configStore = createConfigStore();
export const isDarkTheme = derived(configStore.config, ($config) => $config.theme === "dark"); export const isDarkTheme = derived(configStore.config, ($config) => $config.theme === "dark");
+23 -1
View File
@@ -3,7 +3,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri"; import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
import { configStore, applyTheme } from "$lib/stores/config"; import { configStore, applyTheme, applyFontSize } from "$lib/stores/config";
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications"; import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
import { conversationsStore } from "$lib/stores/conversations"; import { conversationsStore } from "$lib/stores/conversations";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude"; import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
@@ -100,6 +100,27 @@
return; return;
} }
} }
// Ctrl++ or Ctrl+= - Increase font size
if (event.ctrlKey && (event.key === "+" || event.key === "=")) {
event.preventDefault();
configStore.increaseFontSize();
return;
}
// Ctrl+- - Decrease font size
if (event.ctrlKey && event.key === "-") {
event.preventDefault();
configStore.decreaseFontSize();
return;
}
// Ctrl+0 - Reset font size
if (event.ctrlKey && event.key === "0") {
event.preventDefault();
configStore.resetFontSize();
return;
}
} }
async function handleInterrupt() { async function handleInterrupt() {
@@ -127,6 +148,7 @@
// Apply saved settings on startup // Apply saved settings on startup
const config = configStore.getConfig(); const config = configStore.getConfig();
applyTheme(config.theme); applyTheme(config.theme);
applyFontSize(config.font_size);
// Apply always-on-top setting // Apply always-on-top setting
if (config.always_on_top) { if (config.always_on_top) {