generated from nhcarrigan/template
feat: another wave of features (#61)
## Explanation This PR bundles several user-facing improvements and feature additions for the v0.3.0 release, including quality-of-life improvements to the UI, new slash commands, better state persistence, and auto-update checking. ## Included Changes - **Resizable chat input** with drag handle (#58 partial) - **Arrow key navigation fix** - cursor keys now navigate text when user has typed input (#58) - **Scroll position persistence** per conversation tab - **/skill command** for invoking Claude Code skills (#57) - **Stats persistence fix** - stats now persist across session changes, only reset on disconnect (#59) - **Auto-update checker** on startup (#17) - **Resizable character panel** with full-height sprites (#10) - **Font size and zoom settings** with keyboard shortcuts (Ctrl++/Ctrl+-/Ctrl+0) (#19) ## Closes Closes #10, #17, #19, #57, #58, #59 ## Attestations - [x] I have read and agree to the Code of Conduct - [x] I have read and agree to the Community Guidelines - [x] My contribution complies with the Contributor Covenant - [x] I have run the linter and resolved any errors - [x] My pull request uses an appropriate title, matching the conventional commit standards - [x] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request - [x] All new and existing tests pass locally with my changes - [x] Code coverage remains at or above the configured threshold ## Documentation N/A - Internal app features ## Versioning Minor - My pull request introduces new non-breaking features. --- ✨ This PR was created with help from Hikari~ 🌸 Co-authored-by: Hikari <hikari@nhcarrigan.com> Reviewed-on: #61 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #61.
This commit is contained in:
+136
-18
@@ -1,10 +1,11 @@
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_http::reqwest;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
use crate::achievements::{get_achievement_info, load_achievements, AchievementUnlockedEvent};
|
||||
use crate::bridge_manager::SharedBridgeManager;
|
||||
use crate::config::{ClaudeStartOptions, HikariConfig};
|
||||
use crate::stats::UsageStats;
|
||||
use crate::bridge_manager::SharedBridgeManager;
|
||||
use crate::achievements::{load_achievements, get_achievement_info, AchievementUnlockedEvent};
|
||||
|
||||
const CONFIG_STORE_KEY: &str = "config";
|
||||
|
||||
@@ -71,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);
|
||||
@@ -106,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);
|
||||
@@ -136,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
|
||||
@@ -151,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
|
||||
@@ -162,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)
|
||||
@@ -180,3 +184,117 @@ pub async fn answer_question(
|
||||
let mut manager = bridge_manager.lock();
|
||||
manager.send_tool_result(&conversation_id, &tool_use_id, answers)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_skills() -> Result<Vec<String>, String> {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
// Get the home directory
|
||||
let home =
|
||||
std::env::var_os("HOME").ok_or_else(|| "Could not determine home directory".to_string())?;
|
||||
|
||||
let skills_dir = Path::new(&home).join(".claude").join("skills");
|
||||
|
||||
// If the skills directory doesn't exist, return empty list
|
||||
if !skills_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Read the directory and collect skill names
|
||||
let mut skills = Vec::new();
|
||||
let entries =
|
||||
fs::read_dir(&skills_dir).map_err(|e| format!("Failed to read skills directory: {}", e))?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
|
||||
let path = entry.path();
|
||||
|
||||
// Only include directories that contain a SKILL.md file
|
||||
if path.is_dir() {
|
||||
let skill_file = path.join("SKILL.md");
|
||||
if skill_file.exists() {
|
||||
if let Some(name) = path.file_name() {
|
||||
skills.push(name.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort alphabetically
|
||||
skills.sort();
|
||||
|
||||
Ok(skills)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UpdateInfo {
|
||||
pub current_version: String,
|
||||
pub latest_version: String,
|
||||
pub has_update: bool,
|
||||
pub release_url: String,
|
||||
pub release_notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct GiteaRelease {
|
||||
tag_name: String,
|
||||
html_url: String,
|
||||
body: Option<String>,
|
||||
prerelease: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_updates() -> Result<UpdateInfo, String> {
|
||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const RELEASES_API: &str =
|
||||
"https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/hikari-desktop/releases";
|
||||
|
||||
// Fetch releases from Gitea API
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(RELEASES_API)
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch releases: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("API returned status: {}", response.status()));
|
||||
}
|
||||
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response: {}", e))?;
|
||||
|
||||
let releases: Vec<GiteaRelease> =
|
||||
serde_json::from_str(&text).map_err(|e| format!("Failed to parse releases: {}", e))?;
|
||||
|
||||
// Find the latest non-prerelease, or fall back to latest prerelease
|
||||
let latest = releases
|
||||
.iter()
|
||||
.find(|r| !r.prerelease)
|
||||
.or_else(|| releases.first());
|
||||
|
||||
let latest = match latest {
|
||||
Some(r) => r,
|
||||
None => return Err("No releases found".to_string()),
|
||||
};
|
||||
|
||||
// Parse version strings (remove 'v' prefix if present)
|
||||
let current = semver::Version::parse(CURRENT_VERSION)
|
||||
.map_err(|e| format!("Failed to parse current version: {}", e))?;
|
||||
|
||||
let latest_tag = latest.tag_name.trim_start_matches('v');
|
||||
let latest_ver = semver::Version::parse(latest_tag)
|
||||
.map_err(|e| format!("Failed to parse latest version: {}", e))?;
|
||||
|
||||
Ok(UpdateInfo {
|
||||
current_version: CURRENT_VERSION.to_string(),
|
||||
latest_version: latest.tag_name.clone(),
|
||||
has_update: latest_ver > current,
|
||||
release_url: latest.html_url.clone(),
|
||||
release_notes: latest.body.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user