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