Compare commits

..

5 Commits

Author SHA1 Message Date
naomi bc596867d4 release: v0.2.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 51s
CI / Lint & Test (push) Successful in 14m13s
CI / Build Linux (push) Successful in 16m38s
CI / Build Windows (cross-compile) (push) Successful in 26m43s
2026-01-20 20:37:36 -08:00
naomi e877f4aaf2 chore: clean up the sprites (#49)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m1s
CI / Lint & Test (push) Successful in 14m51s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
### Explanation

_No response_

### Issue

Closes #9

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #49
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-20 20:19:34 -08:00
naomi 377f81d978 feat: add about and help panels, donate button, and live setting update (#48)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 52s
CI / Lint & Test (push) Successful in 14m11s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
### Explanation

_No response_

### Issue

Closes #26 Closes #27

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #48
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-20 20:04:03 -08:00
naomi d83697e5cf feat: add ability to run multiple agents via tabbed views (#47)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 54s
CI / Lint & Test (push) Successful in 14m18s
CI / Build Linux (push) Successful in 16m46s
CI / Build Windows (cross-compile) (push) Successful in 26m39s
### Explanation

_No response_

### Issue

Closes #30 Closes #41

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #47
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-20 13:57:48 -08:00
naomi 2d3adcab1c feat: add chat modes and interrupt feature (#46)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 52s
CI / Lint & Test (push) Successful in 14m15s
CI / Build Linux (push) Successful in 16m37s
CI / Build Windows (cross-compile) (push) Successful in 26m35s
### Explanation

_No response_

### Issue

Closes #40

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #46
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-20 08:33:39 -08:00
33 changed files with 1575 additions and 216 deletions
Executable
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to run a command and check its status
run_check() {
local desc=$1
local cmd=$2
echo -e "\n${YELLOW}Running: ${desc}${NC}"
echo -e "${YELLOW}Command: ${cmd}${NC}"
if eval "$cmd"; then
echo -e "${GREEN}${desc} passed${NC}"
return 0
else
echo -e "${RED}${desc} failed${NC}"
return 1
fi
}
# Track if any checks fail
failed=0
echo -e "${YELLOW}🔍 Running all checks for Hikari Desktop...${NC}"
# Frontend checks
run_check "Frontend lint" "pnpm lint" || failed=1
run_check "Frontend format check" "pnpm format:check" || failed=1
run_check "Frontend type check" "pnpm check" || failed=1
run_check "Frontend tests" "pnpm test" || failed=1
# Backend checks
run_check "Backend clippy (strict)" "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings" || failed=1
run_check "Backend tests" "cargo test" || failed=1
# Summary
echo -e "\n${YELLOW}========================================${NC}"
if [ $failed -eq 0 ]; then
echo -e "${GREEN}✨ All checks passed! The code is looking great!${NC}"
echo -e "${GREEN} Naomi would be so proud of us! 💖${NC}"
exit 0
else
echo -e "${RED}❌ Some checks failed. Let's fix them together!${NC}"
echo -e "${RED} Don't worry, we'll get through this! 💪${NC}"
exit 1
fi
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hikari-desktop", "name": "hikari-desktop",
"version": "0.1.0", "version": "0.2.0",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1411,7 +1411,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]] [[package]]
name = "hikari-desktop" name = "hikari-desktop"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"parking_lot", "parking_lot",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "hikari-desktop" name = "hikari-desktop"
version = "0.1.0" version = "0.2.0"
description = "Hikari - Claude Code Visual Assistant" description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"] authors = ["Naomi Carrigan"]
edition = "2021" edition = "2021"
+135
View File
@@ -0,0 +1,135 @@
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::Arc;
use tauri::AppHandle;
use crate::config::ClaudeStartOptions;
use crate::stats::UsageStats;
use crate::wsl_bridge::WslBridge;
pub struct BridgeManager {
bridges: HashMap<String, WslBridge>,
app_handle: Option<AppHandle>,
}
impl BridgeManager {
pub fn new() -> Self {
BridgeManager {
bridges: HashMap::new(),
app_handle: None,
}
}
pub fn set_app_handle(&mut self, app: AppHandle) {
self.app_handle = Some(app);
}
pub fn start_claude(
&mut self,
conversation_id: &str,
options: ClaudeStartOptions,
) -> Result<(), String> {
// Check if a bridge already exists for this conversation
if self.bridges.get(conversation_id).map(|b| b.is_running()).unwrap_or(false) {
return Err("Claude is already running for this conversation".to_string());
}
let app = self.app_handle.as_ref()
.ok_or_else(|| "App handle not set".to_string())?
.clone();
// Create a new bridge for this conversation
let mut bridge = WslBridge::new_with_conversation_id(conversation_id.to_string());
// Start the Claude process
bridge.start(app, options)?;
// Store the bridge
self.bridges.insert(conversation_id.to_string(), bridge);
Ok(())
}
pub fn stop_claude(&mut self, conversation_id: &str) -> Result<(), String> {
if let Some(bridge) = self.bridges.get_mut(conversation_id) {
let app = self.app_handle.as_ref()
.ok_or_else(|| "App handle not set".to_string())?;
bridge.stop(app);
Ok(())
} else {
Err("No Claude instance found for this conversation".to_string())
}
}
pub fn interrupt_claude(&mut self, conversation_id: &str) -> Result<(), String> {
if let Some(bridge) = self.bridges.get_mut(conversation_id) {
let app = self.app_handle.as_ref()
.ok_or_else(|| "App handle not set".to_string())?;
bridge.interrupt(app)
} else {
Err("No Claude instance found for this conversation".to_string())
}
}
pub fn send_prompt(&mut self, conversation_id: &str, message: String) -> Result<(), String> {
if let Some(bridge) = self.bridges.get_mut(conversation_id) {
bridge.send_message(&message)
} else {
Err("No Claude instance found for this conversation".to_string())
}
}
pub fn is_claude_running(&self, conversation_id: &str) -> bool {
self.bridges.get(conversation_id)
.map(|b| b.is_running())
.unwrap_or(false)
}
pub fn get_working_directory(&self, conversation_id: &str) -> Result<String, String> {
self.bridges.get(conversation_id)
.map(|b| b.get_working_directory().to_string())
.ok_or_else(|| "No Claude instance found for this conversation".to_string())
}
pub fn get_usage_stats(&self, conversation_id: &str) -> Result<UsageStats, String> {
self.bridges.get(conversation_id)
.map(|b| b.get_stats())
.ok_or_else(|| "No Claude instance found for this conversation".to_string())
}
#[allow(dead_code)]
pub fn cleanup_stopped_bridges(&mut self) {
// Remove bridges that are no longer running
self.bridges.retain(|_, bridge| bridge.is_running());
}
#[allow(dead_code)]
pub fn stop_all(&mut self) {
if let Some(app) = &self.app_handle {
for (_, bridge) in self.bridges.iter_mut() {
bridge.stop(app);
}
}
self.bridges.clear();
}
#[allow(dead_code)]
pub fn get_active_conversations(&self) -> Vec<String> {
self.bridges.keys()
.filter(|id| self.bridges.get(*id).map(|b| b.is_running()).unwrap_or(false))
.cloned()
.collect()
}
}
impl Default for BridgeManager {
fn default() -> Self {
Self::new()
}
}
pub type SharedBridgeManager = Arc<Mutex<BridgeManager>>;
pub fn create_shared_bridge_manager() -> SharedBridgeManager {
Arc::new(Mutex::new(BridgeManager::new()))
}
+45 -21
View File
@@ -3,44 +3,65 @@ use tauri_plugin_store::StoreExt;
use crate::config::{ClaudeStartOptions, HikariConfig}; use crate::config::{ClaudeStartOptions, HikariConfig};
use crate::stats::UsageStats; use crate::stats::UsageStats;
use crate::wsl_bridge::SharedBridge; use crate::bridge_manager::SharedBridgeManager;
use crate::achievements::{load_achievements, get_achievement_info, AchievementUnlockedEvent}; use crate::achievements::{load_achievements, get_achievement_info, AchievementUnlockedEvent};
const CONFIG_STORE_KEY: &str = "config"; const CONFIG_STORE_KEY: &str = "config";
#[tauri::command] #[tauri::command]
pub async fn start_claude( pub async fn start_claude(
app: AppHandle, bridge_manager: State<'_, SharedBridgeManager>,
bridge: State<'_, SharedBridge>, conversation_id: String,
options: ClaudeStartOptions, options: ClaudeStartOptions,
) -> Result<(), String> { ) -> Result<(), String> {
let mut bridge = bridge.lock(); let mut manager = bridge_manager.lock();
bridge.start(app, options) manager.start_claude(&conversation_id, options)
} }
#[tauri::command] #[tauri::command]
pub async fn stop_claude(app: AppHandle, bridge: State<'_, SharedBridge>) -> Result<(), String> { pub async fn stop_claude(
let mut bridge = bridge.lock(); bridge_manager: State<'_, SharedBridgeManager>,
bridge.stop(&app); conversation_id: String,
Ok(()) ) -> Result<(), String> {
let mut manager = bridge_manager.lock();
manager.stop_claude(&conversation_id)
} }
#[tauri::command] #[tauri::command]
pub async fn send_prompt(bridge: State<'_, SharedBridge>, message: String) -> Result<(), String> { pub async fn interrupt_claude(
let mut bridge = bridge.lock(); bridge_manager: State<'_, SharedBridgeManager>,
bridge.send_message(&message) conversation_id: String,
) -> Result<(), String> {
let mut manager = bridge_manager.lock();
manager.interrupt_claude(&conversation_id)
} }
#[tauri::command] #[tauri::command]
pub async fn is_claude_running(bridge: State<'_, SharedBridge>) -> Result<bool, String> { pub async fn send_prompt(
let bridge = bridge.lock(); bridge_manager: State<'_, SharedBridgeManager>,
Ok(bridge.is_running()) conversation_id: String,
message: String,
) -> Result<(), String> {
let mut manager = bridge_manager.lock();
manager.send_prompt(&conversation_id, message)
} }
#[tauri::command] #[tauri::command]
pub async fn get_working_directory(bridge: State<'_, SharedBridge>) -> Result<String, String> { pub async fn is_claude_running(
let bridge = bridge.lock(); bridge_manager: State<'_, SharedBridgeManager>,
Ok(bridge.get_working_directory().to_string()) conversation_id: String,
) -> Result<bool, String> {
let manager = bridge_manager.lock();
Ok(manager.is_claude_running(&conversation_id))
}
#[tauri::command]
pub async fn get_working_directory(
bridge_manager: State<'_, SharedBridgeManager>,
conversation_id: String,
) -> Result<String, String> {
let manager = bridge_manager.lock();
manager.get_working_directory(&conversation_id)
} }
#[tauri::command] #[tauri::command]
@@ -76,9 +97,12 @@ pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), Str
} }
#[tauri::command] #[tauri::command]
pub async fn get_usage_stats(bridge: State<'_, SharedBridge>) -> Result<UsageStats, String> { pub async fn get_usage_stats(
let bridge = bridge.lock(); bridge_manager: State<'_, SharedBridgeManager>,
Ok(bridge.get_stats()) conversation_id: String,
) -> Result<UsageStats, String> {
let manager = bridge_manager.lock();
manager.get_usage_stats(&conversation_id)
} }
#[tauri::command] #[tauri::command]
+3
View File
@@ -19,6 +19,9 @@ pub struct ClaudeStartOptions {
#[serde(default)] #[serde(default)]
pub allowed_tools: Vec<String>, pub allowed_tools: Vec<String>,
#[serde(default)]
pub skip_greeting: bool,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
+10 -3
View File
@@ -1,4 +1,5 @@
mod achievements; mod achievements;
mod bridge_manager;
mod commands; mod commands;
mod config; mod config;
mod notifications; mod notifications;
@@ -11,7 +12,7 @@ mod windows_toast;
use commands::*; use commands::*;
use notifications::*; use notifications::*;
use wsl_bridge::create_shared_bridge; use bridge_manager::create_shared_bridge_manager;
use commands::load_saved_achievements; use commands::load_saved_achievements;
use wsl_notifications::*; use wsl_notifications::*;
use vbs_notification::*; use vbs_notification::*;
@@ -19,7 +20,7 @@ use windows_toast::*;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let bridge = create_shared_bridge(); let bridge_manager = create_shared_bridge_manager();
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
@@ -28,10 +29,16 @@ pub fn run() {
.plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.manage(bridge) .manage(bridge_manager.clone())
.setup(move |app| {
// Initialize the app handle in the bridge manager
bridge_manager.lock().set_app_handle(app.handle().clone());
Ok(())
})
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
start_claude, start_claude,
stop_claude, stop_claude,
interrupt_claude,
send_prompt, send_prompt,
is_claude_running, is_claude_running,
get_working_directory, get_working_directory,
+29
View File
@@ -172,6 +172,8 @@ pub struct DeltaContent {
pub struct StateChangeEvent { pub struct StateChangeEvent {
pub state: CharacterState, pub state: CharacterState,
pub tool_name: Option<String>, pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -179,6 +181,8 @@ pub struct OutputEvent {
pub line_type: String, pub line_type: String,
pub content: String, pub content: String,
pub tool_name: Option<String>, pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -187,6 +191,29 @@ pub struct PermissionPromptEvent {
pub tool_name: String, pub tool_name: String,
pub tool_input: serde_json::Value, pub tool_input: serde_json::Value,
pub description: String, pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionEvent {
pub status: ConnectionStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionEvent {
pub session_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkingDirectoryEvent {
pub directory: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
} }
#[cfg(test)] #[cfg(test)]
@@ -294,6 +321,7 @@ mod tests {
let event = StateChangeEvent { let event = StateChangeEvent {
state: CharacterState::Coding, state: CharacterState::Coding,
tool_name: Some("Edit".to_string()), tool_name: Some("Edit".to_string()),
conversation_id: None,
}; };
let serialized = serde_json::to_string(&event).unwrap(); let serialized = serde_json::to_string(&event).unwrap();
@@ -307,6 +335,7 @@ mod tests {
line_type: "assistant".to_string(), line_type: "assistant".to_string(),
content: "Test output".to_string(), content: "Test output".to_string(),
tool_name: None, tool_name: None,
conversation_id: None,
}; };
let serialized = serde_json::to_string(&event).unwrap(); let serialized = serde_json::to_string(&event).unwrap();
+79 -52
View File
@@ -1,4 +1,3 @@
use parking_lot::Mutex;
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio}; use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::Arc; use std::sync::Arc;
@@ -12,7 +11,7 @@ use std::os::windows::process::CommandExt;
use crate::config::ClaudeStartOptions; use crate::config::ClaudeStartOptions;
use crate::stats::{UsageStats, StatsUpdateEvent}; use crate::stats::{UsageStats, StatsUpdateEvent};
use parking_lot::RwLock; use parking_lot::RwLock;
use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent}; use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent, ConnectionEvent, SessionEvent, WorkingDirectoryEvent};
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent}; use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"]; const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
@@ -76,6 +75,7 @@ pub struct WslBridge {
session_id: Option<String>, session_id: Option<String>,
mcp_config_file: Option<NamedTempFile>, mcp_config_file: Option<NamedTempFile>,
stats: Arc<RwLock<UsageStats>>, stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>,
} }
impl WslBridge { impl WslBridge {
@@ -87,20 +87,22 @@ impl WslBridge {
session_id: None, session_id: None,
mcp_config_file: None, mcp_config_file: None,
stats: Arc::new(RwLock::new(UsageStats::new())), stats: Arc::new(RwLock::new(UsageStats::new())),
conversation_id: None,
} }
} }
#[allow(dead_code)] pub fn new_with_conversation_id(conversation_id: String) -> Self {
pub async fn new_with_loaded_achievements(app: &tauri::AppHandle) -> Self { WslBridge {
let bridge = Self::new(); process: None,
stdin: None,
// Load saved achievements into the stats working_directory: String::new(),
let achievements = crate::achievements::load_achievements(app).await; session_id: None,
println!("Loaded achievements into bridge: {} unlocked", achievements.unlocked.len()); mcp_config_file: None,
bridge.stats.write().achievements = achievements; stats: Arc::new(RwLock::new(UsageStats::new())),
conversation_id: Some(conversation_id),
bridge
} }
}
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() {
@@ -120,7 +122,7 @@ impl WslBridge {
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); 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 {
@@ -290,19 +292,21 @@ impl WslBridge {
if let Some(stdout) = stdout { if let Some(stdout) = stdout {
let app_clone = app.clone(); let app_clone = app.clone();
let stats_clone = self.stats.clone(); let stats_clone = self.stats.clone();
let conv_id = self.conversation_id.clone();
thread::spawn(move || { thread::spawn(move || {
handle_stdout(stdout, app_clone, stats_clone); handle_stdout(stdout, app_clone, stats_clone, conv_id);
}); });
} }
if let Some(stderr) = stderr { if let Some(stderr) = stderr {
let app_clone = app.clone(); let app_clone = app.clone();
let conv_id = self.conversation_id.clone();
thread::spawn(move || { thread::spawn(move || {
handle_stderr(stderr, app_clone); handle_stderr(stderr, app_clone, conv_id);
}); });
} }
emit_connection_status(&app, ConnectionStatus::Connected); emit_connection_status(&app, ConnectionStatus::Connected, self.conversation_id.clone());
Ok(()) Ok(())
} }
@@ -332,6 +336,30 @@ impl WslBridge {
Ok(()) Ok(())
} }
pub fn interrupt(&mut self, app: &AppHandle) -> Result<(), String> {
// Due to persistent bug in Claude Code where ESC/Ctrl+C doesn't work,
// we have to kill the process. This is the only reliable way to stop it.
// See: https://github.com/anthropics/claude-code/issues/3455
if let Some(mut process) = self.process.take() {
// Kill the process immediately
let _ = process.kill();
let _ = process.wait();
// Clear stdin
self.stdin = None;
// Keep session_id and working directory for user reference
// The user will see what session was interrupted
// Emit disconnected status
emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone());
Ok(())
} else {
Err("No active process to interrupt".to_string())
}
}
pub fn stop(&mut self, app: &AppHandle) { pub fn stop(&mut self, app: &AppHandle) {
if let Some(mut process) = self.process.take() { if let Some(mut process) = self.process.take() {
let _ = process.kill(); let _ = process.kill();
@@ -340,7 +368,7 @@ impl WslBridge {
self.stdin = None; self.stdin = None;
self.session_id = None; self.session_id = None;
self.mcp_config_file = None; // Temp file is automatically deleted when dropped self.mcp_config_file = None; // Temp file is automatically deleted when dropped
emit_connection_status(app, ConnectionStatus::Disconnected); emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone());
} }
pub fn is_running(&self) -> bool { pub fn is_running(&self) -> bool {
@@ -355,15 +383,6 @@ impl WslBridge {
self.stats.read().clone() self.stats.read().clone()
} }
#[allow(dead_code)]
pub fn update_stats(&mut self, input_tokens: u64, output_tokens: u64, model: &str) {
self.stats.write().add_usage(input_tokens, output_tokens, model);
}
#[allow(dead_code)]
pub fn reset_session_stats(&mut self) {
self.stats.write().reset_session();
}
} }
impl Default for WslBridge { impl Default for WslBridge {
@@ -372,13 +391,13 @@ impl Default for WslBridge {
} }
} }
fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<RwLock<UsageStats>>) { 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() {
match line { match line {
Ok(line) if !line.is_empty() => { Ok(line) if !line.is_empty() => {
if let Err(e) = process_json_line(&line, &app, &stats) { if let Err(e) = process_json_line(&line, &app, &stats, &conversation_id) {
eprintln!("Error processing line: {}", e); eprintln!("Error processing line: {}", e);
} }
} }
@@ -390,10 +409,10 @@ fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<R
} }
} }
emit_connection_status(&app, ConnectionStatus::Disconnected); emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
} }
fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) { fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle, conversation_id: Option<String>) {
let reader = BufReader::new(stderr); let reader = BufReader::new(stderr);
for line in reader.lines() { for line in reader.lines() {
@@ -403,6 +422,7 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) {
line_type: "error".to_string(), line_type: "error".to_string(),
content: line, content: line,
tool_name: None, tool_name: None,
conversation_id: conversation_id.clone(),
}); });
} }
Err(_) => break, Err(_) => break,
@@ -411,7 +431,7 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) {
} }
} }
fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>>) -> 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))?;
@@ -419,12 +439,18 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
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", id.clone()); let _ = app.emit("claude:session", 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", dir.clone()); let _ = app.emit("claude:cwd", WorkingDirectoryEvent {
directory: dir.clone(),
conversation_id: conversation_id.clone(),
});
} }
emit_state_change(app, CharacterState::Idle, None); emit_state_change(app, CharacterState::Idle, None, conversation_id.clone());
} }
} }
@@ -478,6 +504,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
line_type: "tool".to_string(), line_type: "tool".to_string(),
content: desc, content: desc,
tool_name: Some(name.clone()), tool_name: Some(name.clone()),
conversation_id: conversation_id.clone(),
}); });
} }
ContentBlock::Text { text } => { ContentBlock::Text { text } => {
@@ -491,6 +518,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
line_type: "assistant".to_string(), line_type: "assistant".to_string(),
content: text.clone(), content: text.clone(),
tool_name: None, tool_name: None,
conversation_id: conversation_id.clone(),
}); });
} }
ContentBlock::Thinking { thinking } => { ContentBlock::Thinking { thinking } => {
@@ -499,13 +527,14 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
line_type: "system".to_string(), line_type: "system".to_string(),
content: format!("[Thinking] {}", thinking), content: format!("[Thinking] {}", thinking),
tool_name: None, tool_name: None,
conversation_id: conversation_id.clone(),
}); });
} }
_ => {} _ => {}
} }
} }
emit_state_change(app, state, tool_name); emit_state_change(app, state, tool_name, conversation_id.clone());
} }
ClaudeMessage::StreamEvent { event } => { ClaudeMessage::StreamEvent { event } => {
@@ -523,7 +552,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
} }
_ => CharacterState::Typing, _ => CharacterState::Typing,
}; };
emit_state_change(app, state, block.name.clone()); emit_state_change(app, state, block.name.clone(), conversation_id.clone());
} }
} else if event.event_type == "content_block_delta" { } else if event.event_type == "content_block_delta" {
if let Some(delta) = &event.delta { if let Some(delta) = &event.delta {
@@ -590,6 +619,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
line_type: "error".to_string(), line_type: "error".to_string(),
content: text.clone(), content: text.clone(),
tool_name: None, tool_name: None,
conversation_id: conversation_id.clone(),
}); });
} }
} }
@@ -603,17 +633,18 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
tool_name: denial.tool_name.clone(), tool_name: denial.tool_name.clone(),
tool_input: denial.tool_input.clone(), tool_input: denial.tool_input.clone(),
description, description,
conversation_id: conversation_id.clone(),
}); });
} }
// Show permission state if there were denials // Show permission state if there were denials
if !denials.is_empty() { if !denials.is_empty() {
emit_state_change(app, CharacterState::Permission, None); emit_state_change(app, CharacterState::Permission, None, conversation_id.clone());
return Ok(()); return Ok(());
} }
} }
emit_state_change(app, state, None); emit_state_change(app, state, None, conversation_id.clone());
} }
ClaudeMessage::User { message } => { ClaudeMessage::User { message } => {
@@ -670,7 +701,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
}); });
} }
emit_state_change(app, CharacterState::Thinking, None); emit_state_change(app, CharacterState::Thinking, None, conversation_id.clone());
} }
} }
@@ -737,19 +768,14 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
} }
} }
fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option<String>) { 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 }); let _ = app.emit("claude:state", StateChangeEvent { state, tool_name, conversation_id });
} }
fn emit_connection_status(app: &AppHandle, status: ConnectionStatus) { fn emit_connection_status(app: &AppHandle, status: ConnectionStatus, conversation_id: Option<String>) {
let _ = app.emit("claude:connection", status); let _ = app.emit("claude:connection", ConnectionEvent { status, conversation_id });
} }
pub type SharedBridge = Arc<Mutex<WslBridge>>;
pub fn create_shared_bridge() -> SharedBridge {
Arc::new(Mutex::new(WslBridge::new()))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -868,9 +894,10 @@ mod tests {
} }
#[test] #[test]
fn test_create_shared_bridge() { fn test_create_shared_bridge_manager() {
let shared = create_shared_bridge(); use crate::bridge_manager::create_shared_bridge_manager;
let bridge = shared.lock(); let shared = create_shared_bridge_manager();
assert!(!bridge.is_running()); let manager = shared.lock();
assert!(manager.get_active_conversations().is_empty());
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop", "productName": "hikari-desktop",
"version": "0.1.0", "version": "0.2.0",
"identifier": "com.naomi.hikari-desktop", "identifier": "com.naomi.hikari-desktop",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
+151
View File
@@ -0,0 +1,151 @@
<script lang="ts">
import { openUrl } from "@tauri-apps/plugin-opener";
import { getVersion } from "@tauri-apps/api/app";
import { onMount } from "svelte";
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
let appVersion = $state("");
onMount(async () => {
appVersion = await getVersion();
});
const links = {
source: "https://git.nhcarrigan.com/nhcarrigan/hikari-desktop",
discord: "https://chat.nhcarrigan.com",
website: "https://nhcarrigan.com",
license: "https://docs.nhcarrigan.com/legal/license/",
changelog: "https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/releases",
};
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-md w-full p-6"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="about-title"
tabindex="-1"
>
<div class="flex items-center justify-between mb-4">
<h2 id="about-title" class="text-xl font-semibold text-gray-100">About Hikari Desktop</h2>
<button
onclick={onClose}
class="p-1 text-gray-500 hover:text-gray-300 transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="space-y-4 text-sm">
<div>
<h3 class="font-medium text-gray-200 mb-2">What is Hikari Desktop?</h3>
<p class="text-gray-400">
Hikari Desktop is an AI-powered desktop assistant that brings Claude directly to your
desktop. Built with love using Tauri, Svelte, and Rust for a fast, native experience.
</p>
</div>
<div>
<h3 class="font-medium text-gray-200 mb-2">Version</h3>
<p class="text-gray-400 mb-1">
{appVersion || "Loading..."}
</p>
<button
onclick={() => openUrl(links.changelog)}
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
>
View Changelog
</button>
</div>
<div>
<h3 class="font-medium text-gray-200 mb-2">Source Code</h3>
<button
onclick={() => openUrl(links.source)}
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
>
View on Git
</button>
</div>
<div>
<h3 class="font-medium text-gray-200 mb-2">Support & Community</h3>
<p class="text-gray-400 mb-1">Found a bug or have a suggestion?</p>
<button
onclick={() => openUrl(links.discord)}
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
>
Join our Discord
</button>
</div>
<div>
<h3 class="font-medium text-gray-200 mb-2">Built with 💕 by</h3>
<button
onclick={() => openUrl(links.website)}
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
>
Naomi Carrigan
</button>
</div>
<div>
<h3 class="font-medium text-gray-200 mb-2">License</h3>
<p class="text-gray-400 mb-1">
This project is open source and available under our license terms.
</p>
<button
onclick={() => openUrl(links.license)}
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
>
View License
</button>
</div>
<div class="pt-4 mt-4 border-t border-[var(--border-color)]">
<p class="text-xs text-gray-500 text-center">
Copyright © {new Date().getFullYear()} Naomi Carrigan. All rights reserved.
</p>
</div>
</div>
</div>
</div>
<style>
/* Ensure the panel appears above other content */
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>
+59 -12
View File
@@ -2,6 +2,7 @@
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { Conversation } from "$lib/stores/conversations"; import type { Conversation } from "$lib/stores/conversations";
import { SvelteMap } from "svelte/reactivity";
let conversations: Map<string, Conversation> = new Map(); let conversations: Map<string, Conversation> = new Map();
let activeConversationId: string | null = null; let activeConversationId: string | null = null;
@@ -11,8 +12,19 @@
// Track which conversation actually has the Claude connection // Track which conversation actually has the Claude connection
let connectedConversationId: string | null = null; let connectedConversationId: string | null = null;
// Track last seen message count for each conversation
let lastSeenMessageCount = new SvelteMap<string, number>();
claudeStore.conversations.subscribe((convs) => { claudeStore.conversations.subscribe((convs) => {
conversations = convs; conversations = convs;
// Update the last seen count for the active conversation
if (activeConversationId) {
const activeConv = convs.get(activeConversationId);
if (activeConv) {
lastSeenMessageCount.set(activeConversationId, activeConv.terminalLines.length);
}
}
}); });
claudeStore.activeConversationId.subscribe((id) => { claudeStore.activeConversationId.subscribe((id) => {
@@ -43,6 +55,14 @@
saveTabName(); saveTabName();
} }
await claudeStore.switchConversation(id); await claudeStore.switchConversation(id);
// Mark messages as seen when switching to this tab
const conv = conversations.get(id);
if (conv) {
lastSeenMessageCount.set(id, conv.terminalLines.length);
// Trigger reactivity
lastSeenMessageCount = lastSeenMessageCount;
}
} }
function deleteTab(id: string, event: MouseEvent) { function deleteTab(id: string, event: MouseEvent) {
@@ -56,6 +76,11 @@
event.stopPropagation(); event.stopPropagation();
editingTabId = id; editingTabId = id;
editingName = name; editingName = name;
// Focus input after DOM update
setTimeout(() => {
const input = document.querySelector('.tab-item input[type="text"]') as HTMLInputElement;
if (input) input.focus();
}, 0);
} }
function saveTabName() { function saveTabName() {
@@ -79,6 +104,12 @@
} }
} }
function hasUnreadMessages(id: string, conversation: Conversation): boolean {
if (id === activeConversationId) return false; // Active tab never has unread
const lastSeen = lastSeenMessageCount.get(id) || 0;
return conversation.terminalLines.length > lastSeen;
}
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
if (event.key === "Enter") { if (event.key === "Enter") {
saveTabName(); saveTabName();
@@ -88,6 +119,13 @@
} }
} }
function handleTabKeydown(id: string, event: KeyboardEvent) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
switchTab(id);
}
}
// Keyboard shortcuts // Keyboard shortcuts
onMount(() => { onMount(() => {
function handleGlobalKeydown(event: KeyboardEvent) { function handleGlobalKeydown(event: KeyboardEvent) {
@@ -140,6 +178,7 @@
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]' ? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}" : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
onclick={() => switchTab(id)} onclick={() => switchTab(id)}
onkeydown={(e) => handleTabKeydown(id, e)}
role="tab" role="tab"
tabindex={0} tabindex={0}
aria-selected={id === activeConversationId} aria-selected={id === activeConversationId}
@@ -152,25 +191,38 @@
onkeydown={handleKeydown} onkeydown={handleKeydown}
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32" class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32"
autofocus
/> />
{:else} {:else}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
class="w-2 h-2 rounded-full {getConnectionStatusColor(conversation.connectionStatus)}" class="w-2 h-2 rounded-full {getConnectionStatusColor(conversation.connectionStatus)}"
title="Connection: {conversation.connectionStatus}{id !== connectedConversationId && connectedConversationId ? ' (Another tab is connected)' : ''}" title="Connection: {conversation.connectionStatus}{id !== connectedConversationId &&
/> connectedConversationId
? ' (Another tab is connected)'
: ''}"
></div>
<span <span
class="text-sm pr-6 max-w-[150px] truncate" class="text-sm pr-6 max-w-[150px] truncate"
ondblclick={(e) => startEditing(id, conversation.name, e)} ondblclick={(e) => startEditing(id, conversation.name, e)}
role="button"
tabindex={-1}
> >
{conversation.name} {conversation.name}
</span> </span>
{#if id !== activeConversationId && id === connectedConversationId} {#if id !== activeConversationId && id === connectedConversationId}
<span class="text-xs text-[var(--text-tertiary)]" title="This tab has the active Claude connection"> <span
(active) class="text-xs text-[var(--text-tertiary)]"
title="This tab has the Claude connection"
>
(connected)
</span> </span>
{/if} {/if}
{#if hasUnreadMessages(id, conversation)}
<div
class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse"
title="New messages"
></div>
{/if}
</div> </div>
{/if} {/if}
@@ -202,7 +254,7 @@
<button <button
onclick={createNewTab} onclick={createNewTab}
class="new-tab-btn flex items-center justify-center w-7 h-7 rounded hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)] transition-colors" class="new-tab-btn flex items-center justify-center w-7 h-7 rounded hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)] transition-colors"
title="New conversation (Ctrl+T)&#10;Note: Only one tab can be connected at a time" title="New conversation (Ctrl+T)"
> >
<svg <svg
class="w-4 h-4" class="w-4 h-4"
@@ -211,12 +263,7 @@
viewBox="0 0 24 24" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg> </svg>
</button> </button>
</div> </div>
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts">
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
const sections = [
{
title: "Getting Started",
items: [
"Enter your Claude API key in Settings (gear icon)",
"Set your working directory and click Connect",
"Start chatting with Hikari - your AI assistant!",
],
},
{
title: "Key Features",
items: [
"🗂️ File Management: Hikari can read, write, and edit files in your project",
"💻 Terminal Access: Execute commands and run scripts",
"🔍 Code Search: Find files and search through code",
"🌐 Web Access: Fetch information from the web",
"📊 MCP Servers: Connect to external tools via Model Context Protocol",
"📁 Multi-tab Support: Work on multiple conversations simultaneously",
],
},
{
title: "Available Commands",
items: [
"Type naturally - Hikari understands context!",
"Ask to read, create, or modify files",
"Request code explanations or debugging help",
"Have Hikari run tests or build commands",
"Search for specific functions or patterns",
],
},
{
title: "Tips & Tricks",
items: [
"💡 Use the stats panel to track your usage",
"🎯 Be specific about file paths and requirements",
"🔒 Grant tool permissions as needed for security",
"📌 Pin important conversations for quick access",
"🎨 Customize your theme and preferences in Settings",
],
},
{
title: "Keyboard Shortcuts",
items: [
"Ctrl/Cmd + Enter: Send message",
"Ctrl/Cmd + K: Clear chat (when supported)",
"Escape: Close modals and panels",
],
},
];
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="help-title"
tabindex="-1"
>
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<h2 id="help-title" class="text-xl font-semibold text-gray-100">How to Use Hikari Desktop</h2>
<button
onclick={onClose}
class="p-1 text-gray-500 hover:text-gray-300 transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="overflow-y-auto flex-1 p-6 space-y-6">
{#each sections as section (section.title)}
<div>
<h3 class="font-medium text-gray-200 mb-3">{section.title}</h3>
<ul class="space-y-2 text-sm text-gray-400">
{#each section.items as item (item)}
<li class="flex items-start">
<span class="text-[var(--accent-primary)] mr-2 mt-0.5"></span>
<span>{item}</span>
</li>
{/each}
</ul>
</div>
{/each}
<div class="pt-4 border-t border-[var(--border-color)]">
<p class="text-sm text-gray-500">
<strong>Need more help?</strong> Join our Discord community for support and updates!
</p>
</div>
</div>
</div>
</div>
<style>
/* Ensure the panel appears above other content */
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* Custom scrollbar styling */
.overflow-y-auto {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.overflow-y-auto::-webkit-scrollbar {
width: 8px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 4px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background-color: var(--accent-primary);
}
</style>
+143 -4
View File
@@ -1,17 +1,34 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { claudeStore } from "$lib/stores/claude"; import { get } from "svelte/store";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character"; import { characterState } from "$lib/stores/character";
import { handleNewUserMessage } from "$lib/notifications/rules"; import { handleNewUserMessage } from "$lib/notifications/rules";
import { setSkipNextGreeting } from "$lib/tauri";
import {
setShouldRestoreHistory,
setSavedHistory,
getShouldRestoreHistory,
getSavedHistory,
clearHistoryRestore,
} from "$lib/stores/historyRestore";
import MessageModeSelector from "$lib/components/MessageModeSelector.svelte";
import { getCurrentMode } from "$lib/stores/messageMode";
import { formatMessageWithMode } from "$lib/types/messageMode";
let inputValue = $state(""); let inputValue = $state("");
let isSubmitting = $state(false); let isSubmitting = $state(false);
let isConnected = $state(false); let isConnected = $state(false);
let isProcessing = $state(false);
claudeStore.connectionStatus.subscribe((status) => { claudeStore.connectionStatus.subscribe((status) => {
isConnected = status === "connected"; isConnected = status === "connected";
}); });
isClaudeProcessing.subscribe((processing) => {
isProcessing = processing;
});
async function handleSubmit(event: Event) { async function handleSubmit(event: Event) {
event.preventDefault(); event.preventDefault();
@@ -21,14 +38,43 @@
isSubmitting = true; isSubmitting = true;
inputValue = ""; inputValue = "";
// Apply mode prefix if needed
const currentMode = getCurrentMode();
const formattedMessage = formatMessageWithMode(message, currentMode);
// Check if we need to restore conversation history
let messageToSend = formattedMessage;
if (getShouldRestoreHistory()) {
const savedHistory = getSavedHistory();
if (savedHistory) {
// Prepend the conversation history with a context message
messageToSend = `[Previous conversation context:]
${savedHistory}
[Continuing conversation after reconnection:]
User: ${formattedMessage}`;
// Clear the restoration flags
clearHistoryRestore();
}
}
// Reset notification state for new user message // Reset notification state for new user message
handleNewUserMessage(); handleNewUserMessage();
claudeStore.addLine("user", message); claudeStore.addLine("user", formattedMessage);
characterState.setState("thinking"); characterState.setState("thinking");
try { try {
await invoke("send_prompt", { message }); const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("send_prompt", {
conversationId,
message: messageToSend,
});
} catch (error) { } catch (error) {
console.error("Failed to send prompt:", error); console.error("Failed to send prompt:", error);
claudeStore.addLine("error", `Failed to send: ${error}`); claudeStore.addLine("error", `Failed to send: ${error}`);
@@ -38,6 +84,60 @@
} }
} }
async function handleInterrupt() {
// Save the conversation history FIRST before anything else
const history = claudeStore.getConversationHistory();
if (history) {
setSavedHistory(history);
setShouldRestoreHistory(true);
}
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted - reconnecting...");
characterState.setState("idle");
// Show connecting status while we reconnect
claudeStore.setConnectionStatus("connecting");
// Auto-reconnect after a brief delay
setTimeout(async () => {
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
// Get current working directory before reconnecting
const workingDir = await invoke<string>("get_working_directory", { conversationId });
// Set the flag to skip greeting on next connection
setSkipNextGreeting(true);
// Reconnect to Claude
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDir,
},
});
} catch (reconnectError) {
console.error("Failed to auto-reconnect:", reconnectError);
claudeStore.addLine("error", `Failed to reconnect: ${reconnectError}`);
claudeStore.addLine("system", "Please manually reconnect to continue");
}
}, 500); // Brief delay to ensure process is fully terminated
} catch (error) {
console.error("Failed to interrupt:", error);
claudeStore.addLine("error", `Failed to interrupt: ${error}`);
}
}
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
handleSubmit(event); handleSubmit(event);
@@ -45,7 +145,12 @@
} }
</script> </script>
<form onsubmit={handleSubmit} class="input-bar flex gap-3 items-end"> <form onsubmit={handleSubmit} class="input-bar">
<div class="input-controls flex gap-2 mb-2">
<MessageModeSelector />
</div>
<div class="input-row flex gap-3 items-end">
<div class="flex-1 relative"> <div class="flex-1 relative">
<textarea <textarea
bind:value={inputValue} bind:value={inputValue}
@@ -61,6 +166,18 @@
></textarea> ></textarea>
</div> </div>
{#if isProcessing}
<button
type="button"
onclick={handleInterrupt}
class="px-6 py-3 bg-red-600 hover:bg-red-700
text-white font-medium rounded-lg
transition-all duration-200 transform hover:scale-105 active:scale-95"
title="Interrupt the current response (Ctrl+C)"
>
<span class="font-bold"></span> Stop
</button>
{:else}
<button <button
type="submit" type="submit"
disabled={!isConnected || isSubmitting || !inputValue.trim()} disabled={!isConnected || isSubmitting || !inputValue.trim()}
@@ -75,4 +192,26 @@
Send Send
{/if} {/if}
</button> </button>
{/if}
</div>
</form> </form>
<style>
.input-bar {
display: flex;
flex-direction: column;
gap: 8px;
}
.input-controls {
display: flex;
align-items: center;
gap: 8px;
}
.input-row {
display: flex;
gap: 12px;
align-items: flex-end;
}
</style>
@@ -0,0 +1,157 @@
<script lang="ts">
import { MESSAGE_MODES, type MessageMode } from "$lib/types/messageMode";
import { messageMode } from "$lib/stores/messageMode";
let currentMode = $state("chat");
let isOpen = $state(false);
messageMode.subscribe((mode) => {
currentMode = mode;
});
let selectedMode = $derived(MESSAGE_MODES.find((m) => m.id === currentMode) || MESSAGE_MODES[0]);
function selectMode(mode: MessageMode) {
messageMode.set(mode.id);
isOpen = false;
}
function toggleDropdown(event: MouseEvent) {
event.stopPropagation();
isOpen = !isOpen;
}
// Close dropdown when clicking outside
function handleClickOutside() {
if (isOpen) {
isOpen = false;
}
}
</script>
<svelte:window onclick={handleClickOutside} />
<div class="mode-selector-container">
<button
class="mode-selector-button"
onclick={toggleDropdown}
title={`Current mode: ${selectedMode.name} - ${selectedMode.description}`}
>
<span class="mode-icon">{selectedMode.icon}</span>
<span class="mode-name">{selectedMode.name}</span>
<svg class="dropdown-arrow" class:open={isOpen} width="12" height="12" viewBox="0 0 12 12">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" fill="none" />
</svg>
</button>
{#if isOpen}
<div class="dropdown-menu">
{#each MESSAGE_MODES as mode (mode.id)}
<button
class="dropdown-item"
class:active={mode.id === currentMode}
onclick={() => selectMode(mode)}
>
<span class="mode-icon">{mode.icon}</span>
<div class="mode-info">
<div class="mode-name">{mode.name}</div>
<div class="mode-description">{mode.description}</div>
</div>
</button>
{/each}
</div>
{/if}
</div>
<style>
.mode-selector-container {
position: relative;
}
.mode-selector-button {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.mode-selector-button:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.mode-icon {
font-size: 16px;
}
.mode-name {
font-weight: 500;
}
.dropdown-arrow {
margin-left: 4px;
transition: transform 0.2s;
}
.dropdown-arrow.open {
transform: rotate(180deg);
}
.dropdown-menu {
position: absolute;
bottom: calc(100% + 4px);
left: 0;
min-width: 280px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
overflow: hidden;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px 16px;
background: transparent;
border: none;
color: var(--text-primary);
cursor: pointer;
transition: background 0.2s;
text-align: left;
}
.dropdown-item:hover {
background: var(--bg-hover);
}
.dropdown-item.active {
background: var(--accent-primary);
color: white;
}
.mode-info {
flex: 1;
}
.dropdown-item .mode-name {
font-weight: 500;
font-size: 14px;
margin-bottom: 2px;
}
.mode-description {
font-size: 12px;
opacity: 0.7;
}
</style>
+12 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { claudeStore, hasPermissionPending } from "$lib/stores/claude"; import { claudeStore, hasPermissionPending } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character"; import { characterState } from "$lib/stores/character";
import type { PermissionRequest } from "$lib/types/messages"; import type { PermissionRequest } from "$lib/types/messages";
@@ -45,12 +46,18 @@
// Stop current session and reconnect with new permissions // Stop current session and reconnect with new permissions
try { try {
await invoke("stop_claude"); const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("stop_claude", { conversationId });
// Small delay to ensure clean shutdown // Small delay to ensure clean shutdown
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
await invoke("start_claude", { await invoke("start_claude", {
conversationId,
options: { options: {
working_dir: workingDirectory || "/home/naomi", working_dir: workingDirectory || "/home/naomi",
allowed_tools: newGrantedTools, allowed_tools: newGrantedTools,
@@ -72,7 +79,10 @@ ${JSON.stringify(toolInput, null, 2)}
Please continue where we left off and retry that action now that you have permission.`; Please continue where we left off and retry that action now that you have permission.`;
await invoke("send_prompt", { message: contextMessage }); await invoke("send_prompt", {
conversationId,
message: contextMessage,
});
} }
} catch (error) { } catch (error) {
console.error("Failed to reconnect:", error); console.error("Failed to reconnect:", error);
+63 -1
View File
@@ -9,14 +9,18 @@
import { getVersion } from "@tauri-apps/api/app"; import { getVersion } from "@tauri-apps/api/app";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { openUrl } from "@tauri-apps/plugin-opener"; import { openUrl } from "@tauri-apps/plugin-opener";
import { get } from "svelte/store";
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { configStore, type HikariConfig } from "$lib/stores/config"; import { configStore, type HikariConfig } from "$lib/stores/config";
import type { ConnectionStatus } from "$lib/types/messages"; import type { ConnectionStatus } from "$lib/types/messages";
import { onMount } from "svelte"; import { onMount } from "svelte";
import StatsDisplay from "./StatsDisplay.svelte"; import StatsDisplay from "./StatsDisplay.svelte";
import AboutPanel from "./AboutPanel.svelte";
import HelpPanel from "./HelpPanel.svelte";
import { achievementProgress } from "$lib/stores/achievements"; import { achievementProgress } from "$lib/stores/achievements";
const DISCORD_URL = "https://chat.nhcarrigan.com"; const DISCORD_URL = "https://chat.nhcarrigan.com";
const DONATE_URL = "https://donate.nhcarrigan.com";
let connectionStatus: ConnectionStatus = $state("disconnected"); let connectionStatus: ConnectionStatus = $state("disconnected");
let workingDirectory = $state(""); let workingDirectory = $state("");
@@ -25,6 +29,8 @@
let grantedToolsList: string[] = $state([]); let grantedToolsList: string[] = $state([]);
let appVersion = $state(""); let appVersion = $state("");
let showStats = $state(false); let showStats = $state(false);
let showAbout = $state(false);
let showHelp = $state(false);
const progress = $derived($achievementProgress); const progress = $derived($achievementProgress);
let currentConfig: HikariConfig = $state({ let currentConfig: HikariConfig = $state({
model: null, model: null,
@@ -87,7 +93,12 @@
]; ];
try { try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("start_claude", { await invoke("start_claude", {
conversationId,
options: { options: {
working_dir: targetDir, working_dir: targetDir,
model: currentConfig.model || null, model: currentConfig.model || null,
@@ -105,7 +116,11 @@
async function handleDisconnect() { async function handleDisconnect() {
try { try {
await invoke("stop_claude"); const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("stop_claude", { conversationId });
} catch (error) { } catch (error) {
console.error("Failed to stop Claude:", error); console.error("Failed to stop Claude:", error);
} }
@@ -231,6 +246,45 @@
/> />
</svg> </svg>
</button> </button>
<button
onclick={() => openUrl(DONATE_URL)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
title="Support our work"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</button>
<button
onclick={() => (showAbout = true)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
title="About Hikari Desktop"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<button
onclick={() => (showHelp = true)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
title="Help"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<button <button
onclick={() => openUrl(DISCORD_URL)} onclick={() => openUrl(DISCORD_URL)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors" class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
@@ -278,3 +332,11 @@
<StatsDisplay /> <StatsDisplay />
</div> </div>
{/if} {/if}
{#if showAbout}
<AboutPanel onClose={() => (showAbout = false)} />
{/if}
{#if showHelp}
<HelpPanel onClose={() => (showHelp = false)} />
{/if}
+1 -1
View File
@@ -2,7 +2,7 @@ import { NOTIFICATION_SOUNDS, type NotificationType } from "./types";
class SoundPlayer { class SoundPlayer {
private audioCache: Map<NotificationType, HTMLAudioElement> = new Map(); private audioCache: Map<NotificationType, HTMLAudioElement> = new Map();
private enabled: boolean = true; private enabled: boolean = false; // Start disabled until config loads
private globalVolume: number = 1.0; private globalVolume: number = 1.0;
constructor() { constructor() {
+34 -1
View File
@@ -1,6 +1,14 @@
import { derived } from "svelte/store"; import { derived } from "svelte/store";
import { conversationsStore } from "./conversations"; import { conversationsStore } from "./conversations";
import type { ConnectionStatus, PermissionRequest, TerminalLine } from "$lib/types/messages"; import type { TerminalLine } from "$lib/types/messages";
import { characterState } from "$lib/stores/character";
import {
setShouldRestoreHistory,
setSavedHistory,
getShouldRestoreHistory,
getSavedHistory,
clearHistoryRestore,
} from "./historyRestore";
// Re-export TerminalLine type for backwards compatibility // Re-export TerminalLine type for backwards compatibility
export type { TerminalLine }; export type { TerminalLine };
@@ -24,10 +32,15 @@ export const claudeStore = {
// Methods // Methods
setConnectionStatus: conversationsStore.setConnectionStatus, setConnectionStatus: conversationsStore.setConnectionStatus,
setConnectionStatusForConversation: conversationsStore.setConnectionStatusForConversation,
setCharacterStateForConversation: conversationsStore.setCharacterStateForConversation,
setSessionId: conversationsStore.setSessionId, setSessionId: conversationsStore.setSessionId,
setSessionIdForConversation: conversationsStore.setSessionIdForConversation,
setWorkingDirectory: conversationsStore.setWorkingDirectory, setWorkingDirectory: conversationsStore.setWorkingDirectory,
setWorkingDirectoryForConversation: conversationsStore.setWorkingDirectoryForConversation,
setProcessing: conversationsStore.setProcessing, setProcessing: conversationsStore.setProcessing,
addLine: conversationsStore.addLine, addLine: conversationsStore.addLine,
addLineToConversation: conversationsStore.addLineToConversation,
updateLine: conversationsStore.updateLine, updateLine: conversationsStore.updateLine,
appendToLine: conversationsStore.appendToLine, appendToLine: conversationsStore.appendToLine,
clearTerminal: conversationsStore.clearTerminal, clearTerminal: conversationsStore.clearTerminal,
@@ -51,6 +64,12 @@ export const claudeStore = {
return tools; return tools;
}, },
// History restoration methods from main branch
setShouldRestoreHistory: setShouldRestoreHistory,
setSavedConversationHistory: setSavedHistory,
getShouldRestoreHistory: getShouldRestoreHistory,
getSavedConversationHistory: getSavedHistory,
reset: () => { reset: () => {
// Reset only the active conversation // Reset only the active conversation
conversationsStore.clearTerminal(); conversationsStore.clearTerminal();
@@ -58,6 +77,8 @@ export const claudeStore = {
conversationsStore.setWorkingDirectory(""); conversationsStore.setWorkingDirectory("");
conversationsStore.setProcessing(false); conversationsStore.setProcessing(false);
conversationsStore.revokeAllTools(); conversationsStore.revokeAllTools();
// Also clear history restoration
clearHistoryRestore();
}, },
}; };
@@ -65,3 +86,15 @@ export const hasPermissionPending = derived(
claudeStore.pendingPermission, claudeStore.pendingPermission,
($permission) => $permission !== null ($permission) => $permission !== null
); );
// Derived store to check if Claude is currently processing (can be interrupted)
export const isClaudeProcessing = derived(
[claudeStore.connectionStatus, characterState],
([$connectionStatus, $characterState]) => {
// Must be connected and in one of the processing states
return (
$connectionStatus === "connected" &&
["thinking", "typing", "searching", "coding", "mcp"].includes($characterState)
);
}
);
+97 -30
View File
@@ -1,5 +1,8 @@
import { writable, derived, get } from "svelte/store"; import { writable, derived, get } from "svelte/store";
import type { TerminalLine, ConnectionStatus, PermissionRequest } from "$lib/types/messages"; import type { TerminalLine, ConnectionStatus, PermissionRequest } from "$lib/types/messages";
import type { CharacterState } from "$lib/types/states";
import { cleanupConversationTracking } from "$lib/tauri";
import { characterState } from "$lib/stores/character";
export interface Conversation { export interface Conversation {
id: string; id: string;
@@ -8,6 +11,7 @@ export interface Conversation {
sessionId: string | null; sessionId: string | null;
connectionStatus: ConnectionStatus; connectionStatus: ConnectionStatus;
workingDirectory: string; workingDirectory: string;
characterState: CharacterState;
isProcessing: boolean; isProcessing: boolean;
grantedTools: Set<string>; grantedTools: Set<string>;
createdAt: Date; createdAt: Date;
@@ -40,6 +44,7 @@ function createConversationsStore() {
sessionId: null, sessionId: null,
connectionStatus: "disconnected", connectionStatus: "disconnected",
workingDirectory: "", workingDirectory: "",
characterState: "idle",
isProcessing: false, isProcessing: false,
grantedTools: new Set(), grantedTools: new Set(),
createdAt: new Date(), createdAt: new Date(),
@@ -71,14 +76,23 @@ function createConversationsStore() {
); );
// Derived stores for compatibility with existing code // Derived stores for compatibility with existing code
const connectionStatus = derived(activeConversation, ($conv) => $conv?.connectionStatus || "disconnected"); const connectionStatus = derived(
activeConversation,
($conv) => $conv?.connectionStatus || "disconnected"
);
const terminalLines = derived(activeConversation, ($conv) => { const terminalLines = derived(activeConversation, ($conv) => {
return $conv?.terminalLines || []; return $conv?.terminalLines || [];
}); });
const sessionId = derived(activeConversation, ($conv) => $conv?.sessionId || null); const sessionId = derived(activeConversation, ($conv) => $conv?.sessionId || null);
const currentWorkingDirectory = derived(activeConversation, ($conv) => $conv?.workingDirectory || ""); const currentWorkingDirectory = derived(
activeConversation,
($conv) => $conv?.workingDirectory || ""
);
const isProcessing = derived(activeConversation, ($conv) => $conv?.isProcessing || false); const isProcessing = derived(activeConversation, ($conv) => $conv?.isProcessing || false);
const grantedTools = derived(activeConversation, ($conv) => $conv?.grantedTools || new Set()); const grantedTools = derived(
activeConversation,
($conv) => $conv?.grantedTools || new Set<string>()
);
return { return {
// Expose derived stores for compatibility // Expose derived stores for compatibility
@@ -110,6 +124,30 @@ function createConversationsStore() {
return convs; return convs;
}); });
}, },
setConnectionStatusForConversation: (conversationId: string, status: ConnectionStatus) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.connectionStatus = status;
conv.lastActivityAt = new Date();
}
return convs;
});
},
setCharacterStateForConversation: (conversationId: string, state: CharacterState) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.characterState = state;
// If this is the active conversation, update the global character state
if (conversationId === get(activeConversationId)) {
characterState.setState(state);
}
}
return convs;
});
},
requestPermission: (request: PermissionRequest) => pendingPermission.set(request), requestPermission: (request: PermissionRequest) => pendingPermission.set(request),
clearPermission: () => pendingPermission.set(null), clearPermission: () => pendingPermission.set(null),
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message), setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
@@ -136,6 +174,9 @@ function createConversationsStore() {
return false; return false;
} }
// Clean up tracking for this conversation
cleanupConversationTracking(id);
conversations.update((c) => { conversations.update((c) => {
c.delete(id); c.delete(id);
return c; return c;
@@ -162,33 +203,9 @@ function createConversationsStore() {
if (currentId !== id) { if (currentId !== id) {
activeConversationId.set(id); activeConversationId.set(id);
// Auto-reconnect logic // Update the global character state to match the conversation's state
if (targetConv && targetConv.connectionStatus === "disconnected") { if (targetConv) {
// Check if another conversation is connected characterState.setState(targetConv.characterState);
let hasConnectedConv = false;
for (const [convId, conv] of convs) {
if (convId !== id && (conv.connectionStatus === "connected" || conv.connectionStatus === "connecting")) {
hasConnectedConv = true;
break;
}
}
if (hasConnectedConv) {
// Add a note about the limitation
const lineId = generateLineId();
conversations.update((c) => {
const conv = c.get(id);
if (conv) {
conv.terminalLines.push({
id: lineId,
type: "system",
content: "Another tab is connected. Disconnect it first or use that tab.",
timestamp: new Date(),
});
}
return c;
});
}
} }
} }
}, },
@@ -220,6 +237,17 @@ function createConversationsStore() {
}); });
}, },
setSessionIdForConversation: (conversationId: string, id: string | null) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.sessionId = id;
conv.lastActivityAt = new Date();
}
return convs;
});
},
setWorkingDirectory: (dir: string) => { setWorkingDirectory: (dir: string) => {
const activeId = get(activeConversationId); const activeId = get(activeConversationId);
if (!activeId) return; if (!activeId) return;
@@ -234,6 +262,17 @@ function createConversationsStore() {
}); });
}, },
setWorkingDirectoryForConversation: (conversationId: string, dir: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.workingDirectory = dir;
conv.lastActivityAt = new Date();
}
return convs;
});
},
setProcessing: (processing: boolean) => { setProcessing: (processing: boolean) => {
const activeId = get(activeConversationId); const activeId = get(activeConversationId);
if (!activeId) return; if (!activeId) return;
@@ -273,6 +312,34 @@ function createConversationsStore() {
return line.id; return line.id;
}, },
addLineToConversation: (
conversationId: string,
type: TerminalLine["type"],
content: string,
toolName?: string
) => {
ensureInitialized();
const line: TerminalLine = {
id: generateLineId(),
type,
content,
timestamp: new Date(),
toolName,
};
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.terminalLines.push(line);
conv.lastActivityAt = new Date();
}
return convs;
});
return line.id;
},
updateLine: (id: string, content: string) => { updateLine: (id: string, content: string) => {
const activeId = get(activeConversationId); const activeId = get(activeConversationId);
if (!activeId) return; if (!activeId) return;
+24
View File
@@ -0,0 +1,24 @@
// Separate module for history restoration to ensure persistence across reconnects
let shouldRestore = false;
let savedHistory: string | null = null;
export function setShouldRestoreHistory(should: boolean) {
shouldRestore = should;
}
export function setSavedHistory(history: string | null) {
savedHistory = history;
}
export function getShouldRestoreHistory(): boolean {
return shouldRestore;
}
export function getSavedHistory(): string | null {
return savedHistory;
}
export function clearHistoryRestore() {
shouldRestore = false;
savedHistory = null;
}
+19
View File
@@ -0,0 +1,19 @@
import { writable } from "svelte/store";
// Default to chat mode
const messageModeStore = writable<string>("chat");
export const messageMode = {
subscribe: messageModeStore.subscribe,
set: (mode: string) => {
messageModeStore.set(mode);
},
reset: () => messageModeStore.set("chat"),
};
// Helper to get current mode
export function getCurrentMode(): string {
let currentMode = "chat";
messageMode.subscribe((mode) => (currentMode = mode))();
return currentMode;
}
+16 -15
View File
@@ -1,22 +1,23 @@
import { derived } from "svelte/store";
import { configStore } from "./config"; import { configStore } from "./config";
import { soundPlayer } from "$lib/notifications"; import { soundPlayer } from "$lib/notifications";
// Sync notification settings with config let unsubscribe: (() => void) | null = null;
export const notificationSettings = derived(configStore.config, ($config) => {
// Initialize notification settings sync - call this once on app startup
export function initNotificationSync() {
// Prevent duplicate subscriptions
if (unsubscribe) return;
unsubscribe = configStore.config.subscribe(($config) => {
soundPlayer.setEnabled($config.notifications_enabled); soundPlayer.setEnabled($config.notifications_enabled);
soundPlayer.setGlobalVolume($config.notification_volume); soundPlayer.setGlobalVolume($config.notification_volume);
return {
enabled: $config.notifications_enabled,
volume: $config.notification_volume,
};
});
// Helper to update notification settings
export async function updateNotificationSettings(enabled: boolean, volume: number) {
await configStore.updateConfig({
notifications_enabled: enabled,
notification_volume: volume,
}); });
} }
// Cleanup function if needed
export function cleanupNotificationSync() {
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
}
+166 -28
View File
@@ -1,5 +1,6 @@
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character"; import { characterState } from "$lib/stores/character";
import { configStore } from "$lib/stores/config"; import { configStore } from "$lib/stores/config";
@@ -17,10 +18,16 @@ import {
interface StateChangePayload { interface StateChangePayload {
state: CharacterState; state: CharacterState;
tool_name: string | null; tool_name: string | null;
conversation_id?: string;
} }
let hasConnectedThisSession = false; const connectedConversations = new Set<string>();
let unlisteners: Array<() => void> = []; let unlisteners: Array<() => void> = [];
let skipNextGreeting = false;
export function setSkipNextGreeting(skip: boolean) {
skipNextGreeting = skip;
}
function getTimeOfDay(): string { function getTimeOfDay(): string {
const hour = new Date().getHours(); const hour = new Date().getHours();
@@ -41,7 +48,13 @@ function generateGreetingPrompt(): string {
return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`; return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`;
} }
async function sendGreeting() { async function sendGreeting(conversationId: string) {
// Check if we should skip this greeting
if (skipNextGreeting) {
skipNextGreeting = false; // Reset the flag
return;
}
const config = configStore.getConfig(); const config = configStore.getConfig();
if (!config.greeting_enabled) { if (!config.greeting_enabled) {
@@ -57,10 +70,13 @@ async function sendGreeting() {
handleNewUserMessage(); handleNewUserMessage();
try { try {
await invoke("send_prompt", { message: greetingPrompt }); await invoke("send_prompt", {
conversationId,
message: greetingPrompt,
});
} catch (error) { } catch (error) {
console.error("Failed to send greeting:", error); console.error("Failed to send greeting:", error);
claudeStore.addLine("error", `Failed to send greeting: ${error}`); claudeStore.addLineToConversation(conversationId, "error", `Failed to send greeting: ${error}`);
characterState.setTemporaryState("error", 3000); characterState.setTemporaryState("error", 3000);
} }
} }
@@ -69,6 +85,26 @@ interface OutputPayload {
line_type: string; line_type: string;
content: string; content: string;
tool_name: string | null; tool_name: string | null;
conversation_id?: string;
}
interface ConnectionPayload {
status: ConnectionStatus;
conversation_id?: string;
}
interface SessionPayload {
session_id: string;
conversation_id?: string;
}
interface WorkingDirectoryPayload {
directory: string;
conversation_id?: string;
}
export function cleanupConversationTracking(conversationId: string) {
connectedConversations.delete(conversationId);
} }
export async function initializeTauriListeners() { export async function initializeTauriListeners() {
@@ -84,35 +120,78 @@ export async function initializeTauriListeners() {
// Initialize achievements listener // Initialize achievements listener
await initAchievementsListener(); await initAchievementsListener();
const connectionUnlisten = await listen<string>("claude:connection", async (event) => { const connectionUnlisten = await listen<ConnectionPayload>("claude:connection", async (event) => {
const status = event.payload as ConnectionStatus; const { status, conversation_id } = event.payload;
// Update connection status for the specific conversation
if (conversation_id) {
claudeStore.setConnectionStatusForConversation(conversation_id, status);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.setConnectionStatus(status); claudeStore.setConnectionStatus(status);
}
// Handle notification for connection status // Handle notification for connection status
handleConnectionStatusChange(status); handleConnectionStatusChange(status);
if (status === "connected") { if (status === "connected") {
claudeStore.addLine("system", "Connected to Claude Code"); // Get the actual conversation ID (fallback to active if not provided)
characterState.setState("idle"); const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (!hasConnectedThisSession) {
hasConnectedThisSession = true; if (targetConversationId) {
// Add system message to the correct conversation
claudeStore.addLineToConversation(
targetConversationId,
"system",
"Connected to Claude Code"
);
// Update character state for this conversation
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
// Check if this specific conversation has connected before
if (!connectedConversations.has(targetConversationId)) {
connectedConversations.add(targetConversationId);
resetSessionStats(); // Reset session stats on new connection resetSessionStats(); // Reset session stats on new connection
await sendGreeting(); await sendGreeting(targetConversationId);
}
} }
} else if (status === "disconnected") { } else if (status === "disconnected") {
hasConnectedThisSession = false; const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
claudeStore.addLine("system", "Disconnected from Claude Code");
characterState.setState("idle"); // Only remove from connected set if we're not about to reconnect
if (!skipNextGreeting && targetConversationId) {
connectedConversations.delete(targetConversationId);
}
// Don't add system message if we're about to reconnect
if (!skipNextGreeting && targetConversationId) {
claudeStore.addLineToConversation(
targetConversationId,
"system",
"Disconnected from Claude Code"
);
}
// Update character state for this conversation
if (targetConversationId) {
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
}
} else if (status === "error") { } else if (status === "error") {
hasConnectedThisSession = false; const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
claudeStore.addLine("error", "Connection error");
if (targetConversationId) {
connectedConversations.delete(targetConversationId);
claudeStore.addLineToConversation(targetConversationId, "error", "Connection error");
}
characterState.setTemporaryState("error", 3000); characterState.setTemporaryState("error", 3000);
} }
}); });
unlisteners.push(connectionUnlisten); unlisteners.push(connectionUnlisten);
const stateUnlisten = await listen<StateChangePayload>("claude:state", (event) => { const stateUnlisten = await listen<StateChangePayload>("claude:state", (event) => {
const { state } = event.payload; const { state, conversation_id } = event.payload;
const stateMap: Record<string, CharacterState> = { const stateMap: Record<string, CharacterState> = {
idle: "idle", idle: "idle",
@@ -128,21 +207,48 @@ export async function initializeTauriListeners() {
const mappedState = stateMap[state.toLowerCase()] || "idle"; const mappedState = stateMap[state.toLowerCase()] || "idle";
// Always update the conversation's state
if (conversation_id) {
claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
} else {
// Fallback to active conversation if no conversation_id
const activeConversationId = get(claudeStore.activeConversationId);
if (activeConversationId) {
claudeStore.setCharacterStateForConversation(activeConversationId, mappedState);
}
}
// Only update global character state for active conversation
const activeConversationId = get(claudeStore.activeConversationId);
if (!conversation_id || conversation_id === activeConversationId) {
if (mappedState === "success" || mappedState === "error") { if (mappedState === "success" || mappedState === "error") {
characterState.setTemporaryState(mappedState, 3000); characterState.setTemporaryState(mappedState, 3000);
} else { } else {
characterState.setState(mappedState); characterState.setState(mappedState);
} }
}
}); });
unlisteners.push(stateUnlisten); unlisteners.push(stateUnlisten);
const outputUnlisten = await listen<OutputPayload>("claude:output", (event) => { const outputUnlisten = await listen<OutputPayload>("claude:output", (event) => {
const { line_type, content, tool_name } = event.payload; const { line_type, content, tool_name, conversation_id } = event.payload;
// Always store the output to the correct conversation
if (conversation_id) {
claudeStore.addLineToConversation(
conversation_id,
line_type as "user" | "assistant" | "system" | "tool" | "error",
content,
tool_name || undefined
);
} else {
// Fallback to active conversation if no conversation_id provided
claudeStore.addLine( claudeStore.addLine(
line_type as "user" | "assistant" | "system" | "tool" | "error", line_type as "user" | "assistant" | "system" | "tool" | "error",
content, content,
tool_name || undefined tool_name || undefined
); );
}
}); });
unlisteners.push(outputUnlisten); unlisteners.push(outputUnlisten);
@@ -151,30 +257,64 @@ export async function initializeTauriListeners() {
}); });
unlisteners.push(streamUnlisten); unlisteners.push(streamUnlisten);
const sessionUnlisten = await listen<string>("claude:session", (event) => { const sessionUnlisten = await listen<SessionPayload>("claude:session", (event) => {
claudeStore.setSessionId(event.payload); const { session_id, conversation_id } = event.payload;
claudeStore.addLine("system", `Session: ${event.payload.substring(0, 8)}...`);
// Store session ID for the correct conversation
if (conversation_id) {
claudeStore.setSessionIdForConversation(conversation_id, session_id);
claudeStore.addLineToConversation(
conversation_id,
"system",
`Session: ${session_id.substring(0, 8)}...`
);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.setSessionId(session_id);
claudeStore.addLine("system", `Session: ${session_id.substring(0, 8)}...`);
}
}); });
unlisteners.push(sessionUnlisten); unlisteners.push(sessionUnlisten);
const cwdUnlisten = await listen<string>("claude:cwd", (event) => { const cwdUnlisten = await listen<WorkingDirectoryPayload>("claude:cwd", (event) => {
claudeStore.setWorkingDirectory(event.payload); const { directory, conversation_id } = event.payload;
// Store working directory for the correct conversation
if (conversation_id) {
claudeStore.setWorkingDirectoryForConversation(conversation_id, directory);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.setWorkingDirectory(directory);
}
}); });
unlisteners.push(cwdUnlisten); unlisteners.push(cwdUnlisten);
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => { const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
const { id, tool_name, tool_input, description } = event.payload; const { id, tool_name, tool_input, description, conversation_id } = event.payload;
// Only process permission requests for the active conversation
const activeConversationId = get(claudeStore.activeConversationId);
if (conversation_id === activeConversationId) {
claudeStore.requestPermission({ claudeStore.requestPermission({
id, id,
tool: tool_name, tool: tool_name,
description, description,
input: tool_input, input: tool_input,
}); });
}
// Always store the permission message to the correct conversation
if (conversation_id) {
claudeStore.addLineToConversation(
conversation_id,
"system",
`Permission requested for: ${tool_name}`
);
} else if (conversation_id === activeConversationId) {
claudeStore.addLine("system", `Permission requested for: ${tool_name}`); claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
}
}); });
unlisteners.push(permissionUnlisten); unlisteners.push(permissionUnlisten);
console.log("Tauri event listeners initialized");
} }
export function cleanupTauriListeners() { export function cleanupTauriListeners() {
@@ -184,6 +324,4 @@ export function cleanupTauriListeners() {
// Cleanup notification rules // Cleanup notification rules
cleanupNotificationRules(); cleanupNotificationRules();
console.log("Tauri event listeners cleaned up");
} }
+69
View File
@@ -0,0 +1,69 @@
export interface MessageMode {
id: string;
name: string;
description: string;
prefix?: string;
icon: string;
}
export const MESSAGE_MODES: MessageMode[] = [
{
id: "chat",
name: "Chat",
description: "Normal conversation mode",
icon: "💬",
},
{
id: "architect",
name: "Architect",
description: "High-level design and architecture planning",
prefix: "[Architect Mode] ",
icon: "🏗️",
},
{
id: "code",
name: "Code",
description: "Focused on writing and editing code",
prefix: "[Code Mode] ",
icon: "💻",
},
{
id: "debug",
name: "Debug",
description: "Help with debugging and troubleshooting",
prefix: "[Debug Mode] ",
icon: "🐛",
},
{
id: "ask",
name: "Ask",
description: "Technical questions and explanations",
prefix: "[Ask Mode] ",
icon: "❓",
},
{
id: "review",
name: "Review",
description: "Code review and feedback",
prefix: "[Review Mode] ",
icon: "👀",
},
];
export function getMessageMode(id: string): MessageMode | undefined {
return MESSAGE_MODES.find((mode) => mode.id === id);
}
export function formatMessageWithMode(message: string, modeId: string): string {
const mode = getMessageMode(modeId);
if (!mode || !mode.prefix) {
return message;
}
// Don't double-prefix if the message already starts with the prefix
if (message.startsWith(mode.prefix)) {
return message;
}
return mode.prefix + message;
}
+1
View File
@@ -123,6 +123,7 @@ export interface PermissionPromptEvent {
tool_name: string; tool_name: string;
tool_input: Record<string, unknown>; tool_input: Record<string, unknown>;
description: string; description: string;
conversation_id?: string;
} }
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"; export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
+10
View File
@@ -2,6 +2,8 @@
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri"; import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
import { configStore, applyTheme } from "$lib/stores/config"; import { configStore, applyTheme } from "$lib/stores/config";
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
import { conversationsStore } from "$lib/stores/conversations";
import "$lib/notifications/testNotifications"; import "$lib/notifications/testNotifications";
import Terminal from "$lib/components/Terminal.svelte"; import Terminal from "$lib/components/Terminal.svelte";
import InputBar from "$lib/components/InputBar.svelte"; import InputBar from "$lib/components/InputBar.svelte";
@@ -18,18 +20,26 @@
onMount(async () => { onMount(async () => {
if (!initialized) { if (!initialized) {
initialized = true; initialized = true;
// Initialize conversations store first to ensure activeConversationId is set
conversationsStore.initialize();
await initializeTauriListeners(); await initializeTauriListeners();
await configStore.loadConfig(); await configStore.loadConfig();
// Apply saved theme on startup // Apply saved theme on startup
const config = configStore.getConfig(); const config = configStore.getConfig();
applyTheme(config.theme); applyTheme(config.theme);
// Initialize notification settings sync
initNotificationSync();
} }
}); });
onDestroy(() => { onDestroy(() => {
if (initialized) { if (initialized) {
cleanupTauriListeners(); cleanupTauriListeners();
cleanupNotificationSync();
initialized = false; initialized = false;
} }
}); });
Binary file not shown.

Before

Width:  |  Height:  |  Size: 680 KiB

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 KiB

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 778 KiB

After

Width:  |  Height:  |  Size: 777 KiB