feat: another wave of features (#61)
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled

## 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:
2026-01-23 19:07:22 -08:00
committed by Naomi Carrigan
parent 06810537a9
commit 3f30997f0e
34 changed files with 1562 additions and 306 deletions
+273 -119
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 {
@@ -292,8 +304,8 @@ impl WslBridge {
self.stdin = stdin;
self.process = Some(child);
// Reset session stats when starting new session
self.stats.write().reset_session();
// Note: We no longer reset stats here - stats persist across reconnects
// Stats are only reset when explicitly disconnecting via stop()
// Load saved achievements
let app_handle = app.clone();
@@ -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 {
@@ -411,7 +439,15 @@ impl WslBridge {
self.stdin = None;
self.session_id = None;
self.mcp_config_file = None; // Temp file is automatically deleted when dropped
emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone());
// Reset session stats on explicit disconnect
self.stats.write().reset_session();
emit_connection_status(
app,
ConnectionStatus::Disconnected,
self.conversation_id.clone(),
);
}
pub fn is_running(&self) -> bool {
@@ -425,7 +461,6 @@ impl WslBridge {
pub fn get_stats(&self) -> UsageStats {
self.stats.read().clone()
}
}
impl Default for WslBridge {
@@ -434,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() {
@@ -455,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,
_ => {}
@@ -474,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());
}
@@ -543,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
@@ -557,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(),
},
);
}
_ => {}
}
@@ -606,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 {
@@ -627,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
@@ -641,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");
@@ -658,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(),
},
);
}
}
@@ -674,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(());
}
}
@@ -744,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,
@@ -774,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
@@ -785,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");
@@ -860,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::*;
@@ -878,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]
@@ -902,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));
}