Compare commits
5 Commits
v0.2.0
..
27f69cb308
| Author | SHA1 | Date | |
|---|---|---|---|
|
27f69cb308
|
|||
|
da566f408e
|
|||
|
d72ca7a975
|
|||
|
b691a91c53
|
|||
|
1ce43dcff8
|
@@ -1,51 +0,0 @@
|
|||||||
#!/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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hikari-desktop",
|
"name": "hikari-desktop",
|
||||||
"version": "0.2.0",
|
"version": "0.1.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1411,7 +1411,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hikari-desktop"
|
name = "hikari-desktop"
|
||||||
version = "0.2.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "hikari-desktop"
|
name = "hikari-desktop"
|
||||||
version = "0.2.0"
|
version = "0.1.0"
|
||||||
description = "Hikari - Claude Code Visual Assistant"
|
description = "Hikari - Claude Code Visual Assistant"
|
||||||
authors = ["Naomi Carrigan"]
|
authors = ["Naomi Carrigan"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -9,10 +9,6 @@
|
|||||||
"opener:default",
|
"opener:default",
|
||||||
"shell:allow-spawn",
|
"shell:allow-spawn",
|
||||||
"shell:allow-stdin-write",
|
"shell:allow-stdin-write",
|
||||||
"shell:allow-kill",
|
"shell:allow-kill"
|
||||||
"notification:default",
|
|
||||||
"notification:allow-is-permission-granted",
|
|
||||||
"notification:allow-request-permission",
|
|
||||||
"notification:allow-notify"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,12 +114,10 @@ impl AchievementProgress {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn take_newly_unlocked(&mut self) -> Vec<AchievementId> {
|
pub fn take_newly_unlocked(&mut self) -> Vec<AchievementId> {
|
||||||
std::mem::take(&mut self.newly_unlocked)
|
std::mem::take(&mut self.newly_unlocked)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn is_unlocked(&self, achievement: &AchievementId) -> bool {
|
pub fn is_unlocked(&self, achievement: &AchievementId) -> bool {
|
||||||
self.unlocked.contains(achievement)
|
self.unlocked.contains(achievement)
|
||||||
}
|
}
|
||||||
@@ -649,7 +647,7 @@ pub fn check_achievements(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Special first-time achievements
|
// Special first-time achievements
|
||||||
if !stats.tools_usage.is_empty() && progress.unlock(AchievementId::FirstTool) {
|
if stats.tools_usage.len() >= 1 && progress.unlock(AchievementId::FirstTool) {
|
||||||
newly_unlocked.push(AchievementId::FirstTool);
|
newly_unlocked.push(AchievementId::FirstTool);
|
||||||
}
|
}
|
||||||
if stats.code_blocks_generated >= 1 && progress.unlock(AchievementId::FirstCodeBlock) {
|
if stats.code_blocks_generated >= 1 && progress.unlock(AchievementId::FirstCodeBlock) {
|
||||||
@@ -699,7 +697,7 @@ pub fn check_achievements(
|
|||||||
let weekday = session_start.weekday();
|
let weekday = session_start.weekday();
|
||||||
|
|
||||||
// Early bird - 5 AM to 7 AM
|
// Early bird - 5 AM to 7 AM
|
||||||
if (5..=7).contains(&hour) && progress.unlock(AchievementId::EarlyBird) {
|
if hour >= 5 && hour <= 7 && progress.unlock(AchievementId::EarlyBird) {
|
||||||
newly_unlocked.push(AchievementId::EarlyBird);
|
newly_unlocked.push(AchievementId::EarlyBird);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,7 +708,7 @@ pub fn check_achievements(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// All nighter - 2 AM to 5 AM
|
// All nighter - 2 AM to 5 AM
|
||||||
if (2..=5).contains(¤t_hour) && progress.unlock(AchievementId::AllNighter) {
|
if current_hour >= 2 && current_hour <= 5 && progress.unlock(AchievementId::AllNighter) {
|
||||||
newly_unlocked.push(AchievementId::AllNighter);
|
newly_unlocked.push(AchievementId::AllNighter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
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()))
|
|
||||||
}
|
|
||||||
@@ -3,65 +3,44 @@ 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::bridge_manager::SharedBridgeManager;
|
use crate::wsl_bridge::SharedBridge;
|
||||||
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(
|
||||||
bridge_manager: State<'_, SharedBridgeManager>,
|
app: AppHandle,
|
||||||
conversation_id: String,
|
bridge: State<'_, SharedBridge>,
|
||||||
options: ClaudeStartOptions,
|
options: ClaudeStartOptions,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut manager = bridge_manager.lock();
|
let mut bridge = bridge.lock();
|
||||||
manager.start_claude(&conversation_id, options)
|
bridge.start(app, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn stop_claude(
|
pub async fn stop_claude(app: AppHandle, bridge: State<'_, SharedBridge>) -> Result<(), String> {
|
||||||
bridge_manager: State<'_, SharedBridgeManager>,
|
let mut bridge = bridge.lock();
|
||||||
conversation_id: String,
|
bridge.stop(&app);
|
||||||
) -> Result<(), String> {
|
Ok(())
|
||||||
let mut manager = bridge_manager.lock();
|
|
||||||
manager.stop_claude(&conversation_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn interrupt_claude(
|
pub async fn send_prompt(bridge: State<'_, SharedBridge>, message: String) -> Result<(), String> {
|
||||||
bridge_manager: State<'_, SharedBridgeManager>,
|
let mut bridge = bridge.lock();
|
||||||
conversation_id: String,
|
bridge.send_message(&message)
|
||||||
) -> Result<(), String> {
|
|
||||||
let mut manager = bridge_manager.lock();
|
|
||||||
manager.interrupt_claude(&conversation_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn send_prompt(
|
pub async fn is_claude_running(bridge: State<'_, SharedBridge>) -> Result<bool, String> {
|
||||||
bridge_manager: State<'_, SharedBridgeManager>,
|
let bridge = bridge.lock();
|
||||||
conversation_id: String,
|
Ok(bridge.is_running())
|
||||||
message: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let mut manager = bridge_manager.lock();
|
|
||||||
manager.send_prompt(&conversation_id, message)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn is_claude_running(
|
pub async fn get_working_directory(bridge: State<'_, SharedBridge>) -> Result<String, String> {
|
||||||
bridge_manager: State<'_, SharedBridgeManager>,
|
let bridge = bridge.lock();
|
||||||
conversation_id: String,
|
Ok(bridge.get_working_directory().to_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]
|
||||||
@@ -97,12 +76,9 @@ pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), Str
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_usage_stats(
|
pub async fn get_usage_stats(bridge: State<'_, SharedBridge>) -> Result<UsageStats, String> {
|
||||||
bridge_manager: State<'_, SharedBridgeManager>,
|
let bridge = bridge.lock();
|
||||||
conversation_id: String,
|
Ok(bridge.get_stats())
|
||||||
) -> Result<UsageStats, String> {
|
|
||||||
let manager = bridge_manager.lock();
|
|
||||||
manager.get_usage_stats(&conversation_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ 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)]
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
mod achievements;
|
mod achievements;
|
||||||
mod bridge_manager;
|
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
@@ -12,7 +11,7 @@ mod windows_toast;
|
|||||||
|
|
||||||
use commands::*;
|
use commands::*;
|
||||||
use notifications::*;
|
use notifications::*;
|
||||||
use bridge_manager::create_shared_bridge_manager;
|
use wsl_bridge::create_shared_bridge;
|
||||||
use commands::load_saved_achievements;
|
use commands::load_saved_achievements;
|
||||||
use wsl_notifications::*;
|
use wsl_notifications::*;
|
||||||
use vbs_notification::*;
|
use vbs_notification::*;
|
||||||
@@ -20,7 +19,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_manager = create_shared_bridge_manager();
|
let bridge = create_shared_bridge();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
@@ -29,16 +28,10 @@ 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_manager.clone())
|
.manage(bridge)
|
||||||
.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,
|
||||||
|
|||||||
@@ -172,8 +172,6 @@ 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)]
|
||||||
@@ -181,8 +179,6 @@ 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)]
|
||||||
@@ -191,29 +187,6 @@ 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)]
|
||||||
@@ -321,7 +294,6 @@ 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();
|
||||||
@@ -335,7 +307,6 @@ 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();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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;
|
||||||
@@ -11,7 +12,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, ConnectionEvent, SessionEvent, WorkingDirectoryEvent};
|
use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent};
|
||||||
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"];
|
||||||
@@ -75,7 +76,6 @@ 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,22 +87,19 @@ 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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
working_directory: String::new(),
|
|
||||||
session_id: None,
|
|
||||||
mcp_config_file: None,
|
|
||||||
stats: Arc::new(RwLock::new(UsageStats::new())),
|
|
||||||
conversation_id: Some(conversation_id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Load saved achievements into the stats
|
||||||
|
let achievements = crate::achievements::load_achievements(app).await;
|
||||||
|
println!("Loaded achievements into bridge: {} unlocked", achievements.unlocked.len());
|
||||||
|
bridge.stats.write().achievements = achievements;
|
||||||
|
|
||||||
|
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() {
|
||||||
@@ -122,7 +119,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, self.conversation_id.clone());
|
emit_connection_status(&app, ConnectionStatus::Connecting);
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -292,21 +289,19 @@ 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, conv_id);
|
handle_stdout(stdout, app_clone, stats_clone);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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, conv_id);
|
handle_stderr(stderr, app_clone);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_connection_status(&app, ConnectionStatus::Connected, self.conversation_id.clone());
|
emit_connection_status(&app, ConnectionStatus::Connected);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -336,30 +331,6 @@ 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();
|
||||||
@@ -368,7 +339,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, self.conversation_id.clone());
|
emit_connection_status(app, ConnectionStatus::Disconnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_running(&self) -> bool {
|
pub fn is_running(&self) -> bool {
|
||||||
@@ -383,6 +354,13 @@ impl WslBridge {
|
|||||||
self.stats.read().clone()
|
self.stats.read().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_stats(&mut self, input_tokens: u64, output_tokens: u64, model: &str) {
|
||||||
|
self.stats.write().add_usage(input_tokens, output_tokens, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_session_stats(&mut self) {
|
||||||
|
self.stats.write().reset_session();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for WslBridge {
|
impl Default for WslBridge {
|
||||||
@@ -391,13 +369,13 @@ impl Default for WslBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<RwLock<UsageStats>>, conversation_id: Option<String>) {
|
fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<RwLock<UsageStats>>) {
|
||||||
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, &conversation_id) {
|
if let Err(e) = process_json_line(&line, &app, &stats) {
|
||||||
eprintln!("Error processing line: {}", e);
|
eprintln!("Error processing line: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -409,10 +387,10 @@ fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<R
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
|
emit_connection_status(&app, ConnectionStatus::Disconnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle, conversation_id: Option<String>) {
|
fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) {
|
||||||
let reader = BufReader::new(stderr);
|
let reader = BufReader::new(stderr);
|
||||||
|
|
||||||
for line in reader.lines() {
|
for line in reader.lines() {
|
||||||
@@ -422,7 +400,6 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle, conversation
|
|||||||
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,
|
||||||
@@ -431,7 +408,7 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle, conversation
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>>, conversation_id: &Option<String>) -> Result<(), String> {
|
fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>>) -> 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))?;
|
||||||
|
|
||||||
@@ -439,18 +416,12 @@ 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", SessionEvent {
|
let _ = app.emit("claude:session", id.clone());
|
||||||
session_id: id.clone(),
|
|
||||||
conversation_id: conversation_id.clone(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if let Some(dir) = cwd {
|
if let Some(dir) = cwd {
|
||||||
let _ = app.emit("claude:cwd", WorkingDirectoryEvent {
|
let _ = app.emit("claude:cwd", dir.clone());
|
||||||
directory: dir.clone(),
|
|
||||||
conversation_id: conversation_id.clone(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
emit_state_change(app, CharacterState::Idle, None, conversation_id.clone());
|
emit_state_change(app, CharacterState::Idle, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,7 +475,6 @@ 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 } => {
|
||||||
@@ -518,7 +488,6 @@ 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 } => {
|
||||||
@@ -527,14 +496,13 @@ 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, conversation_id.clone());
|
emit_state_change(app, state, tool_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
ClaudeMessage::StreamEvent { event } => {
|
ClaudeMessage::StreamEvent { event } => {
|
||||||
@@ -552,7 +520,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(), conversation_id.clone());
|
emit_state_change(app, state, block.name.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 {
|
||||||
@@ -619,7 +587,6 @@ 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(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -633,18 +600,17 @@ 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, conversation_id.clone());
|
emit_state_change(app, CharacterState::Permission, None);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_state_change(app, state, None, conversation_id.clone());
|
emit_state_change(app, state, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
ClaudeMessage::User { message } => {
|
ClaudeMessage::User { message } => {
|
||||||
@@ -701,7 +667,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_state_change(app, CharacterState::Thinking, None, conversation_id.clone());
|
emit_state_change(app, CharacterState::Thinking, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,14 +734,19 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option<String>, conversation_id: Option<String>) {
|
fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option<String>) {
|
||||||
let _ = app.emit("claude:state", StateChangeEvent { state, tool_name, conversation_id });
|
let _ = app.emit("claude:state", StateChangeEvent { state, tool_name });
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_connection_status(app: &AppHandle, status: ConnectionStatus, conversation_id: Option<String>) {
|
fn emit_connection_status(app: &AppHandle, status: ConnectionStatus) {
|
||||||
let _ = app.emit("claude:connection", ConnectionEvent { status, conversation_id });
|
let _ = app.emit("claude:connection", status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -894,10 +865,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_create_shared_bridge_manager() {
|
fn test_create_shared_bridge() {
|
||||||
use crate::bridge_manager::create_shared_bridge_manager;
|
let shared = create_shared_bridge();
|
||||||
let shared = create_shared_bridge_manager();
|
let bridge = shared.lock();
|
||||||
let manager = shared.lock();
|
assert!(!bridge.is_running());
|
||||||
assert!(manager.get_active_conversations().is_empty());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.2.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.naomi.hikari-desktop",
|
"identifier": "com.naomi.hikari-desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { claudeStore } from "$lib/stores/claude";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import type { Conversation } from "$lib/stores/conversations";
|
|
||||||
import { SvelteMap } from "svelte/reactivity";
|
|
||||||
|
|
||||||
let conversations: Map<string, Conversation> = new Map();
|
|
||||||
let activeConversationId: string | null = null;
|
|
||||||
let editingTabId: string | null = null;
|
|
||||||
let editingName = "";
|
|
||||||
|
|
||||||
// Track which conversation actually has the Claude connection
|
|
||||||
let connectedConversationId: string | null = null;
|
|
||||||
|
|
||||||
// Track last seen message count for each conversation
|
|
||||||
let lastSeenMessageCount = new SvelteMap<string, number>();
|
|
||||||
|
|
||||||
claudeStore.conversations.subscribe((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) => {
|
|
||||||
activeConversationId = id;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find the connected conversation
|
|
||||||
$: {
|
|
||||||
let foundConnected = false;
|
|
||||||
for (const [id, conv] of conversations) {
|
|
||||||
if (conv.connectionStatus === "connected" || conv.connectionStatus === "connecting") {
|
|
||||||
connectedConversationId = id;
|
|
||||||
foundConnected = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!foundConnected) {
|
|
||||||
connectedConversationId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createNewTab() {
|
|
||||||
claudeStore.createConversation();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function switchTab(id: string) {
|
|
||||||
if (editingTabId) {
|
|
||||||
saveTabName();
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (conversations.size > 1) {
|
|
||||||
claudeStore.deleteConversation(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startEditing(id: string, name: string, event: MouseEvent) {
|
|
||||||
event.stopPropagation();
|
|
||||||
editingTabId = id;
|
|
||||||
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() {
|
|
||||||
if (editingTabId && editingName.trim()) {
|
|
||||||
claudeStore.renameConversation(editingTabId, editingName.trim());
|
|
||||||
}
|
|
||||||
editingTabId = null;
|
|
||||||
editingName = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getConnectionStatusColor(status: Conversation["connectionStatus"]): string {
|
|
||||||
switch (status) {
|
|
||||||
case "connected":
|
|
||||||
return "bg-green-500";
|
|
||||||
case "connecting":
|
|
||||||
return "bg-yellow-500";
|
|
||||||
case "disconnected":
|
|
||||||
return "bg-red-500";
|
|
||||||
default:
|
|
||||||
return "bg-gray-500";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
saveTabName();
|
|
||||||
} else if (event.key === "Escape") {
|
|
||||||
editingTabId = null;
|
|
||||||
editingName = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTabKeydown(id: string, event: KeyboardEvent) {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
switchTab(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
|
||||||
onMount(() => {
|
|
||||||
function handleGlobalKeydown(event: KeyboardEvent) {
|
|
||||||
// Ctrl/Cmd + T: New tab
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === "t") {
|
|
||||||
event.preventDefault();
|
|
||||||
createNewTab();
|
|
||||||
}
|
|
||||||
// Ctrl/Cmd + W: Close current tab
|
|
||||||
else if ((event.ctrlKey || event.metaKey) && event.key === "w") {
|
|
||||||
event.preventDefault();
|
|
||||||
if (activeConversationId && conversations.size > 1) {
|
|
||||||
claudeStore.deleteConversation(activeConversationId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Ctrl/Cmd + Tab: Next tab
|
|
||||||
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
const tabs = Array.from(conversations.keys());
|
|
||||||
const currentIndex = tabs.findIndex((id) => id === activeConversationId);
|
|
||||||
if (currentIndex !== -1) {
|
|
||||||
const nextIndex = (currentIndex + 1) % tabs.length;
|
|
||||||
claudeStore.switchConversation(tabs[nextIndex]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Ctrl/Cmd + Shift + Tab: Previous tab
|
|
||||||
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
const tabs = Array.from(conversations.keys());
|
|
||||||
const currentIndex = tabs.findIndex((id) => id === activeConversationId);
|
|
||||||
if (currentIndex !== -1) {
|
|
||||||
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
|
||||||
claudeStore.switchConversation(tabs[prevIndex]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleGlobalKeydown);
|
|
||||||
return () => window.removeEventListener("keydown", handleGlobalKeydown);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
|
|
||||||
>
|
|
||||||
{#each Array.from(conversations.entries()) as [id, conversation] (id)}
|
|
||||||
<div
|
|
||||||
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all
|
|
||||||
{id === activeConversationId
|
|
||||||
? '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'}"
|
|
||||||
onclick={() => switchTab(id)}
|
|
||||||
onkeydown={(e) => handleTabKeydown(id, e)}
|
|
||||||
role="tab"
|
|
||||||
tabindex={0}
|
|
||||||
aria-selected={id === activeConversationId}
|
|
||||||
>
|
|
||||||
{#if editingTabId === id}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={editingName}
|
|
||||||
onblur={saveTabName}
|
|
||||||
onkeydown={handleKeydown}
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
class="w-2 h-2 rounded-full {getConnectionStatusColor(conversation.connectionStatus)}"
|
|
||||||
title="Connection: {conversation.connectionStatus}{id !== connectedConversationId &&
|
|
||||||
connectedConversationId
|
|
||||||
? ' (Another tab is connected)'
|
|
||||||
: ''}"
|
|
||||||
></div>
|
|
||||||
<span
|
|
||||||
class="text-sm pr-6 max-w-[150px] truncate"
|
|
||||||
ondblclick={(e) => startEditing(id, conversation.name, e)}
|
|
||||||
role="button"
|
|
||||||
tabindex={-1}
|
|
||||||
>
|
|
||||||
{conversation.name}
|
|
||||||
</span>
|
|
||||||
{#if id !== activeConversationId && id === connectedConversationId}
|
|
||||||
<span
|
|
||||||
class="text-xs text-[var(--text-tertiary)]"
|
|
||||||
title="This tab has the Claude connection"
|
|
||||||
>
|
|
||||||
(connected)
|
|
||||||
</span>
|
|
||||||
{/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>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if conversations.size > 1}
|
|
||||||
<button
|
|
||||||
onclick={(e) => deleteTab(id, e)}
|
|
||||||
class="absolute right-1 top-1/2 -translate-y-1/2 w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)] opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
title="Close tab"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-3 h-3"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<button
|
|
||||||
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"
|
|
||||||
title="New conversation (Ctrl+T)"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.terminal-tabs {
|
|
||||||
min-height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-item {
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
console.log("ConversationTabs component loading...");
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="terminal-tabs" style="background: red; height: 36px; color: white;">
|
|
||||||
Debug: Tabs Component Loaded
|
|
||||||
</div>
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,34 +1,17 @@
|
|||||||
<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 } from "$lib/stores/claude";
|
||||||
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();
|
||||||
|
|
||||||
@@ -38,43 +21,14 @@
|
|||||||
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", formattedMessage);
|
claudeStore.addLine("user", message);
|
||||||
characterState.setState("thinking");
|
characterState.setState("thinking");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const conversationId = get(claudeStore.activeConversationId);
|
await invoke("send_prompt", { message });
|
||||||
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}`);
|
||||||
@@ -84,60 +38,6 @@ User: ${formattedMessage}`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -145,73 +45,34 @@ User: ${formattedMessage}`;
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form onsubmit={handleSubmit} class="input-bar">
|
<form onsubmit={handleSubmit} class="input-bar flex gap-3 items-end">
|
||||||
<div class="input-controls flex gap-2 mb-2">
|
<div class="flex-1 relative">
|
||||||
<MessageModeSelector />
|
<textarea
|
||||||
</div>
|
bind:value={inputValue}
|
||||||
|
onkeydown={handleKeyDown}
|
||||||
<div class="input-row flex gap-3 items-end">
|
placeholder={isConnected ? "Ask Hikari anything..." : "Connect to Claude first..."}
|
||||||
<div class="flex-1 relative">
|
disabled={!isConnected || isSubmitting}
|
||||||
<textarea
|
rows={1}
|
||||||
bind:value={inputValue}
|
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
|
||||||
onkeydown={handleKeyDown}
|
|
||||||
placeholder={isConnected ? "Ask Hikari anything..." : "Connect to Claude first..."}
|
|
||||||
disabled={!isConnected || isSubmitting}
|
|
||||||
rows={1}
|
|
||||||
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
|
|
||||||
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
|
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
|
||||||
focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]
|
focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
transition-all duration-200"
|
transition-all duration-200"
|
||||||
></textarea>
|
></textarea>
|
||||||
</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
|
|
||||||
type="submit"
|
|
||||||
disabled={!isConnected || isSubmitting || !inputValue.trim()}
|
|
||||||
class="px-6 py-3 bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)]
|
|
||||||
text-white font-medium rounded-lg
|
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
|
||||||
transition-all duration-200 transform hover:scale-105 active:scale-95"
|
|
||||||
>
|
|
||||||
{#if isSubmitting}
|
|
||||||
<span class="inline-block animate-spin">⏳</span>
|
|
||||||
{:else}
|
|
||||||
Send
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isConnected || isSubmitting || !inputValue.trim()}
|
||||||
|
class="px-6 py-3 bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)]
|
||||||
|
text-white font-medium rounded-lg
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
transition-all duration-200 transform hover:scale-105 active:scale-95"
|
||||||
|
>
|
||||||
|
{#if isSubmitting}
|
||||||
|
<span class="inline-block animate-spin">⏳</span>
|
||||||
|
{:else}
|
||||||
|
Send
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
</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>
|
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<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";
|
||||||
@@ -46,18 +45,12 @@
|
|||||||
|
|
||||||
// Stop current session and reconnect with new permissions
|
// Stop current session and reconnect with new permissions
|
||||||
try {
|
try {
|
||||||
const conversationId = get(claudeStore.activeConversationId);
|
await invoke("stop_claude");
|
||||||
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,
|
||||||
@@ -79,10 +72,7 @@ ${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", {
|
await invoke("send_prompt", { message: contextMessage });
|
||||||
conversationId,
|
|
||||||
message: contextMessage,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to reconnect:", error);
|
console.error("Failed to reconnect:", error);
|
||||||
|
|||||||
@@ -9,18 +9,14 @@
|
|||||||
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("");
|
||||||
@@ -29,8 +25,6 @@
|
|||||||
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,
|
||||||
@@ -93,12 +87,7 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
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,
|
||||||
@@ -116,11 +105,7 @@
|
|||||||
|
|
||||||
async function handleDisconnect() {
|
async function handleDisconnect() {
|
||||||
try {
|
try {
|
||||||
const conversationId = get(claudeStore.activeConversationId);
|
await invoke("stop_claude");
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -246,45 +231,6 @@
|
|||||||
/>
|
/>
|
||||||
</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"
|
||||||
@@ -332,11 +278,3 @@
|
|||||||
<StatsDisplay />
|
<StatsDisplay />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showAbout}
|
|
||||||
<AboutPanel onClose={() => (showAbout = false)} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showHelp}
|
|
||||||
<HelpPanel onClose={() => (showHelp = false)} />
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
||||||
import { afterUpdate } from "svelte";
|
import { afterUpdate } from "svelte";
|
||||||
import ConversationTabs from "./ConversationTabs.svelte";
|
|
||||||
|
|
||||||
let terminalElement: HTMLDivElement;
|
let terminalElement: HTMLDivElement;
|
||||||
let shouldAutoScroll = true;
|
let shouldAutoScroll = true;
|
||||||
@@ -79,12 +78,10 @@
|
|||||||
<span class="text-sm terminal-header-text ml-2">Terminal</span>
|
<span class="text-sm terminal-header-text ml-2">Terminal</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConversationTabs />
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={terminalElement}
|
bind:this={terminalElement}
|
||||||
onscroll={handleScroll}
|
onscroll={handleScroll}
|
||||||
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono text-sm"
|
class="terminal-content h-[calc(100%-40px)] overflow-y-auto p-4 font-mono text-sm"
|
||||||
>
|
>
|
||||||
{#if lines.length === 0}
|
{#if lines.length === 0}
|
||||||
<div class="terminal-waiting italic">
|
<div class="terminal-waiting italic">
|
||||||
|
|||||||
@@ -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 = false; // Start disabled until config loads
|
private enabled: boolean = true;
|
||||||
private globalVolume: number = 1.0;
|
private globalVolume: number = 1.0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -1,100 +1,127 @@
|
|||||||
import { derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import { conversationsStore } from "./conversations";
|
import type { ConnectionStatus, PermissionRequest } 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
|
export interface TerminalLine {
|
||||||
export type { TerminalLine };
|
id: string;
|
||||||
|
type: "user" | "assistant" | "system" | "tool" | "error";
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
toolName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Re-export from conversations store for backwards compatibility
|
function createClaudeStore() {
|
||||||
export const claudeStore = {
|
const connectionStatus = writable<ConnectionStatus>("disconnected");
|
||||||
// Existing subscriptions
|
const sessionId = writable<string | null>(null);
|
||||||
connectionStatus: conversationsStore.connectionStatus,
|
const currentWorkingDirectory = writable<string>("");
|
||||||
sessionId: conversationsStore.sessionId,
|
const terminalLines = writable<TerminalLine[]>([]);
|
||||||
currentWorkingDirectory: conversationsStore.currentWorkingDirectory,
|
const pendingPermission = writable<PermissionRequest | null>(null);
|
||||||
terminalLines: conversationsStore.terminalLines,
|
const isProcessing = writable<boolean>(false);
|
||||||
pendingPermission: conversationsStore.pendingPermission,
|
const grantedTools = writable<Set<string>>(new Set());
|
||||||
isProcessing: conversationsStore.isProcessing,
|
const pendingRetryMessage = writable<string | null>(null);
|
||||||
grantedTools: conversationsStore.grantedTools,
|
|
||||||
pendingRetryMessage: conversationsStore.pendingRetryMessage,
|
|
||||||
|
|
||||||
// New conversation-aware subscriptions
|
let lineIdCounter = 0;
|
||||||
conversations: conversationsStore.conversations,
|
|
||||||
activeConversationId: conversationsStore.activeConversationId,
|
|
||||||
activeConversation: conversationsStore.activeConversation,
|
|
||||||
|
|
||||||
// Methods
|
function generateLineId(): string {
|
||||||
setConnectionStatus: conversationsStore.setConnectionStatus,
|
return `line-${Date.now()}-${lineIdCounter++}`;
|
||||||
setConnectionStatusForConversation: conversationsStore.setConnectionStatusForConversation,
|
}
|
||||||
setCharacterStateForConversation: conversationsStore.setCharacterStateForConversation,
|
|
||||||
setSessionId: conversationsStore.setSessionId,
|
|
||||||
setSessionIdForConversation: conversationsStore.setSessionIdForConversation,
|
|
||||||
setWorkingDirectory: conversationsStore.setWorkingDirectory,
|
|
||||||
setWorkingDirectoryForConversation: conversationsStore.setWorkingDirectoryForConversation,
|
|
||||||
setProcessing: conversationsStore.setProcessing,
|
|
||||||
addLine: conversationsStore.addLine,
|
|
||||||
addLineToConversation: conversationsStore.addLineToConversation,
|
|
||||||
updateLine: conversationsStore.updateLine,
|
|
||||||
appendToLine: conversationsStore.appendToLine,
|
|
||||||
clearTerminal: conversationsStore.clearTerminal,
|
|
||||||
getConversationHistory: conversationsStore.getConversationHistory,
|
|
||||||
requestPermission: conversationsStore.requestPermission,
|
|
||||||
clearPermission: conversationsStore.clearPermission,
|
|
||||||
grantTool: conversationsStore.grantTool,
|
|
||||||
revokeAllTools: conversationsStore.revokeAllTools,
|
|
||||||
isToolGranted: conversationsStore.isToolGranted,
|
|
||||||
setPendingRetryMessage: conversationsStore.setPendingRetryMessage,
|
|
||||||
|
|
||||||
// Conversation management
|
return {
|
||||||
createConversation: conversationsStore.createConversation,
|
connectionStatus: { subscribe: connectionStatus.subscribe },
|
||||||
deleteConversation: conversationsStore.deleteConversation,
|
sessionId: { subscribe: sessionId.subscribe },
|
||||||
switchConversation: conversationsStore.switchConversation,
|
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
|
||||||
renameConversation: conversationsStore.renameConversation,
|
terminalLines: { subscribe: terminalLines.subscribe },
|
||||||
|
pendingPermission: { subscribe: pendingPermission.subscribe },
|
||||||
|
isProcessing: { subscribe: isProcessing.subscribe },
|
||||||
|
grantedTools: { subscribe: grantedTools.subscribe },
|
||||||
|
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
|
||||||
|
|
||||||
getGrantedTools: (): string[] => {
|
setConnectionStatus: (status: ConnectionStatus) => connectionStatus.set(status),
|
||||||
let tools: string[] = [];
|
setSessionId: (id: string | null) => sessionId.set(id),
|
||||||
conversationsStore.grantedTools.subscribe((t) => (tools = Array.from(t)))();
|
setWorkingDirectory: (dir: string) => currentWorkingDirectory.set(dir),
|
||||||
return tools;
|
setProcessing: (processing: boolean) => isProcessing.set(processing),
|
||||||
},
|
|
||||||
|
|
||||||
// History restoration methods from main branch
|
addLine: (type: TerminalLine["type"], content: string, toolName?: string) => {
|
||||||
setShouldRestoreHistory: setShouldRestoreHistory,
|
const line: TerminalLine = {
|
||||||
setSavedConversationHistory: setSavedHistory,
|
id: generateLineId(),
|
||||||
getShouldRestoreHistory: getShouldRestoreHistory,
|
type,
|
||||||
getSavedConversationHistory: getSavedHistory,
|
content,
|
||||||
|
timestamp: new Date(),
|
||||||
|
toolName,
|
||||||
|
};
|
||||||
|
terminalLines.update((lines) => [...lines, line]);
|
||||||
|
return line.id;
|
||||||
|
},
|
||||||
|
|
||||||
reset: () => {
|
updateLine: (id: string, content: string) => {
|
||||||
// Reset only the active conversation
|
terminalLines.update((lines) =>
|
||||||
conversationsStore.clearTerminal();
|
lines.map((line) => (line.id === id ? { ...line, content } : line))
|
||||||
conversationsStore.setSessionId(null);
|
);
|
||||||
conversationsStore.setWorkingDirectory("");
|
},
|
||||||
conversationsStore.setProcessing(false);
|
|
||||||
conversationsStore.revokeAllTools();
|
appendToLine: (id: string, additionalContent: string) => {
|
||||||
// Also clear history restoration
|
terminalLines.update((lines) =>
|
||||||
clearHistoryRestore();
|
lines.map((line) =>
|
||||||
},
|
line.id === id ? { ...line, content: line.content + additionalContent } : line
|
||||||
};
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearTerminal: () => terminalLines.set([]),
|
||||||
|
|
||||||
|
getConversationHistory: (): string => {
|
||||||
|
let lines: TerminalLine[] = [];
|
||||||
|
terminalLines.subscribe((l) => (lines = l))();
|
||||||
|
|
||||||
|
// Filter to just user and assistant messages, skip system/tool noise
|
||||||
|
const relevantLines = lines.filter(
|
||||||
|
(line) => line.type === "user" || line.type === "assistant"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (relevantLines.length === 0) return "";
|
||||||
|
|
||||||
|
return relevantLines
|
||||||
|
.map((line) => {
|
||||||
|
const role = line.type === "user" ? "User" : "Assistant";
|
||||||
|
return `${role}: ${line.content}`;
|
||||||
|
})
|
||||||
|
.join("\n\n");
|
||||||
|
},
|
||||||
|
|
||||||
|
requestPermission: (request: PermissionRequest) => pendingPermission.set(request),
|
||||||
|
clearPermission: () => pendingPermission.set(null),
|
||||||
|
|
||||||
|
grantTool: (toolName: string) => {
|
||||||
|
grantedTools.update((tools) => {
|
||||||
|
const newTools = new Set(tools);
|
||||||
|
newTools.add(toolName);
|
||||||
|
return newTools;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getGrantedTools: (): string[] => {
|
||||||
|
let tools: string[] = [];
|
||||||
|
grantedTools.subscribe((t) => (tools = Array.from(t)))();
|
||||||
|
return tools;
|
||||||
|
},
|
||||||
|
|
||||||
|
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
|
||||||
|
|
||||||
|
reset: () => {
|
||||||
|
connectionStatus.set("disconnected");
|
||||||
|
sessionId.set(null);
|
||||||
|
currentWorkingDirectory.set("");
|
||||||
|
terminalLines.set([]);
|
||||||
|
pendingPermission.set(null);
|
||||||
|
isProcessing.set(false);
|
||||||
|
grantedTools.set(new Set());
|
||||||
|
pendingRetryMessage.set(null);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const claudeStore = createClaudeStore();
|
||||||
|
|
||||||
export const hasPermissionPending = derived(
|
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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,459 +0,0 @@
|
|||||||
import { writable, derived, get } from "svelte/store";
|
|
||||||
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 {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
terminalLines: TerminalLine[];
|
|
||||||
sessionId: string | null;
|
|
||||||
connectionStatus: ConnectionStatus;
|
|
||||||
workingDirectory: string;
|
|
||||||
characterState: CharacterState;
|
|
||||||
isProcessing: boolean;
|
|
||||||
grantedTools: Set<string>;
|
|
||||||
createdAt: Date;
|
|
||||||
lastActivityAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createConversationsStore() {
|
|
||||||
const conversations = writable<Map<string, Conversation>>(new Map());
|
|
||||||
const activeConversationId = writable<string | null>(null);
|
|
||||||
const pendingPermission = writable<PermissionRequest | null>(null);
|
|
||||||
const pendingRetryMessage = writable<string | null>(null);
|
|
||||||
|
|
||||||
let conversationCounter = 0;
|
|
||||||
let lineIdCounter = 0;
|
|
||||||
|
|
||||||
function generateConversationId(): string {
|
|
||||||
return `conv-${Date.now()}-${conversationCounter++}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateLineId(): string {
|
|
||||||
return `line-${Date.now()}-${lineIdCounter++}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createNewConversation(name?: string): Conversation {
|
|
||||||
const id = generateConversationId();
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: name || `Conversation ${conversationCounter}`,
|
|
||||||
terminalLines: [],
|
|
||||||
sessionId: null,
|
|
||||||
connectionStatus: "disconnected",
|
|
||||||
workingDirectory: "",
|
|
||||||
characterState: "idle",
|
|
||||||
isProcessing: false,
|
|
||||||
grantedTools: new Set(),
|
|
||||||
createdAt: new Date(),
|
|
||||||
lastActivityAt: new Date(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize with first conversation lazily
|
|
||||||
let initialized = false;
|
|
||||||
function ensureInitialized() {
|
|
||||||
if (!initialized) {
|
|
||||||
initialized = true;
|
|
||||||
const initialConversation = createNewConversation("Main");
|
|
||||||
conversations.update((convs) => {
|
|
||||||
convs.set(initialConversation.id, initialConversation);
|
|
||||||
return convs;
|
|
||||||
});
|
|
||||||
activeConversationId.set(initialConversation.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derived store for current conversation
|
|
||||||
const activeConversation = derived(
|
|
||||||
[conversations, activeConversationId],
|
|
||||||
([$conversations, $activeId]) => {
|
|
||||||
if (!$activeId) return null;
|
|
||||||
return $conversations.get($activeId) || null;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Derived stores for compatibility with existing code
|
|
||||||
const connectionStatus = derived(
|
|
||||||
activeConversation,
|
|
||||||
($conv) => $conv?.connectionStatus || "disconnected"
|
|
||||||
);
|
|
||||||
const terminalLines = derived(activeConversation, ($conv) => {
|
|
||||||
return $conv?.terminalLines || [];
|
|
||||||
});
|
|
||||||
const sessionId = derived(activeConversation, ($conv) => $conv?.sessionId || null);
|
|
||||||
const currentWorkingDirectory = derived(
|
|
||||||
activeConversation,
|
|
||||||
($conv) => $conv?.workingDirectory || ""
|
|
||||||
);
|
|
||||||
const isProcessing = derived(activeConversation, ($conv) => $conv?.isProcessing || false);
|
|
||||||
const grantedTools = derived(
|
|
||||||
activeConversation,
|
|
||||||
($conv) => $conv?.grantedTools || new Set<string>()
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Expose derived stores for compatibility
|
|
||||||
connectionStatus: { subscribe: connectionStatus.subscribe },
|
|
||||||
sessionId: { subscribe: sessionId.subscribe },
|
|
||||||
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
|
|
||||||
terminalLines: { subscribe: terminalLines.subscribe },
|
|
||||||
pendingPermission: { subscribe: pendingPermission.subscribe },
|
|
||||||
isProcessing: { subscribe: isProcessing.subscribe },
|
|
||||||
grantedTools: { subscribe: grantedTools.subscribe },
|
|
||||||
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
|
|
||||||
|
|
||||||
// New conversation-specific stores
|
|
||||||
conversations: { subscribe: conversations.subscribe },
|
|
||||||
activeConversationId: { subscribe: activeConversationId.subscribe },
|
|
||||||
activeConversation: { subscribe: activeConversation.subscribe },
|
|
||||||
|
|
||||||
// Connection management
|
|
||||||
setConnectionStatus: (status: ConnectionStatus) => {
|
|
||||||
const activeId = get(activeConversationId);
|
|
||||||
if (!activeId) return;
|
|
||||||
|
|
||||||
conversations.update((convs) => {
|
|
||||||
const conv = convs.get(activeId);
|
|
||||||
if (conv) {
|
|
||||||
conv.connectionStatus = status;
|
|
||||||
conv.lastActivityAt = new Date();
|
|
||||||
}
|
|
||||||
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),
|
|
||||||
clearPermission: () => pendingPermission.set(null),
|
|
||||||
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
|
|
||||||
|
|
||||||
// Conversation management
|
|
||||||
createConversation: (name?: string) => {
|
|
||||||
ensureInitialized();
|
|
||||||
const newConv = createNewConversation(name);
|
|
||||||
conversations.update((convs) => {
|
|
||||||
convs.set(newConv.id, newConv);
|
|
||||||
return convs;
|
|
||||||
});
|
|
||||||
activeConversationId.set(newConv.id);
|
|
||||||
return newConv.id;
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteConversation: (id: string) => {
|
|
||||||
ensureInitialized();
|
|
||||||
const convs = get(conversations);
|
|
||||||
const activeId = get(activeConversationId);
|
|
||||||
|
|
||||||
if (convs.size <= 1) {
|
|
||||||
// Don't delete the last conversation
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up tracking for this conversation
|
|
||||||
cleanupConversationTracking(id);
|
|
||||||
|
|
||||||
conversations.update((c) => {
|
|
||||||
c.delete(id);
|
|
||||||
return c;
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we deleted the active conversation, switch to another
|
|
||||||
if (activeId === id) {
|
|
||||||
const remaining = Array.from(get(conversations).keys());
|
|
||||||
if (remaining.length > 0) {
|
|
||||||
activeConversationId.set(remaining[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
|
|
||||||
switchConversation: async (id: string) => {
|
|
||||||
const convs = get(conversations);
|
|
||||||
if (!convs.has(id)) return;
|
|
||||||
|
|
||||||
const currentId = get(activeConversationId);
|
|
||||||
const targetConv = convs.get(id);
|
|
||||||
|
|
||||||
// If switching to a different conversation
|
|
||||||
if (currentId !== id) {
|
|
||||||
activeConversationId.set(id);
|
|
||||||
|
|
||||||
// Update the global character state to match the conversation's state
|
|
||||||
if (targetConv) {
|
|
||||||
characterState.setState(targetConv.characterState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
renameConversation: (id: string, newName: string) => {
|
|
||||||
conversations.update((convs) => {
|
|
||||||
const conv = convs.get(id);
|
|
||||||
if (conv) {
|
|
||||||
conv.name = newName;
|
|
||||||
conv.lastActivityAt = new Date();
|
|
||||||
}
|
|
||||||
return convs;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Methods that operate on the active conversation
|
|
||||||
setSessionId: (id: string | null) => {
|
|
||||||
ensureInitialized();
|
|
||||||
const activeId = get(activeConversationId);
|
|
||||||
if (!activeId) return;
|
|
||||||
|
|
||||||
conversations.update((convs) => {
|
|
||||||
const conv = convs.get(activeId);
|
|
||||||
if (conv) {
|
|
||||||
conv.sessionId = id;
|
|
||||||
conv.lastActivityAt = new Date();
|
|
||||||
}
|
|
||||||
return convs;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
const activeId = get(activeConversationId);
|
|
||||||
if (!activeId) return;
|
|
||||||
|
|
||||||
conversations.update((convs) => {
|
|
||||||
const conv = convs.get(activeId);
|
|
||||||
if (conv) {
|
|
||||||
conv.workingDirectory = dir;
|
|
||||||
conv.lastActivityAt = new Date();
|
|
||||||
}
|
|
||||||
return convs;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
const activeId = get(activeConversationId);
|
|
||||||
if (!activeId) return;
|
|
||||||
|
|
||||||
conversations.update((convs) => {
|
|
||||||
const conv = convs.get(activeId);
|
|
||||||
if (conv) {
|
|
||||||
conv.isProcessing = processing;
|
|
||||||
conv.lastActivityAt = new Date();
|
|
||||||
}
|
|
||||||
return convs;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
addLine: (type: TerminalLine["type"], content: string, toolName?: string) => {
|
|
||||||
ensureInitialized();
|
|
||||||
const activeId = get(activeConversationId);
|
|
||||||
if (!activeId) return "";
|
|
||||||
|
|
||||||
const line: TerminalLine = {
|
|
||||||
id: generateLineId(),
|
|
||||||
type,
|
|
||||||
content,
|
|
||||||
timestamp: new Date(),
|
|
||||||
toolName,
|
|
||||||
};
|
|
||||||
|
|
||||||
conversations.update((convs) => {
|
|
||||||
const conv = convs.get(activeId);
|
|
||||||
if (conv) {
|
|
||||||
conv.terminalLines.push(line);
|
|
||||||
conv.lastActivityAt = new Date();
|
|
||||||
}
|
|
||||||
return convs;
|
|
||||||
});
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
const activeId = get(activeConversationId);
|
|
||||||
if (!activeId) return;
|
|
||||||
|
|
||||||
conversations.update((convs) => {
|
|
||||||
const conv = convs.get(activeId);
|
|
||||||
if (conv) {
|
|
||||||
const line = conv.terminalLines.find((l) => l.id === id);
|
|
||||||
if (line) {
|
|
||||||
line.content = content;
|
|
||||||
conv.lastActivityAt = new Date();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return convs;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
appendToLine: (id: string, additionalContent: string) => {
|
|
||||||
const activeId = get(activeConversationId);
|
|
||||||
if (!activeId) return;
|
|
||||||
|
|
||||||
conversations.update((convs) => {
|
|
||||||
const conv = convs.get(activeId);
|
|
||||||
if (conv) {
|
|
||||||
const line = conv.terminalLines.find((l) => l.id === id);
|
|
||||||
if (line) {
|
|
||||||
line.content += additionalContent;
|
|
||||||
conv.lastActivityAt = new Date();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return convs;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
clearTerminal: () => {
|
|
||||||
const activeId = get(activeConversationId);
|
|
||||||
if (!activeId) return;
|
|
||||||
|
|
||||||
conversations.update((convs) => {
|
|
||||||
const conv = convs.get(activeId);
|
|
||||||
if (conv) {
|
|
||||||
conv.terminalLines = [];
|
|
||||||
conv.lastActivityAt = new Date();
|
|
||||||
}
|
|
||||||
return convs;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
getConversationHistory: (): string => {
|
|
||||||
const activeId = get(activeConversationId);
|
|
||||||
if (!activeId) return "";
|
|
||||||
|
|
||||||
const convs = get(conversations);
|
|
||||||
const conv = convs.get(activeId);
|
|
||||||
if (!conv) return "";
|
|
||||||
|
|
||||||
const relevantLines = conv.terminalLines.filter(
|
|
||||||
(line) => line.type === "user" || line.type === "assistant"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (relevantLines.length === 0) return "";
|
|
||||||
|
|
||||||
return relevantLines
|
|
||||||
.map((line) => {
|
|
||||||
const role = line.type === "user" ? "User" : "Assistant";
|
|
||||||
return `${role}: ${line.content}`;
|
|
||||||
})
|
|
||||||
.join("\n\n");
|
|
||||||
},
|
|
||||||
|
|
||||||
grantTool: (toolName: string) => {
|
|
||||||
const activeId = get(activeConversationId);
|
|
||||||
if (!activeId) return;
|
|
||||||
|
|
||||||
conversations.update((convs) => {
|
|
||||||
const conv = convs.get(activeId);
|
|
||||||
if (conv) {
|
|
||||||
conv.grantedTools.add(toolName);
|
|
||||||
conv.lastActivityAt = new Date();
|
|
||||||
}
|
|
||||||
return convs;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
revokeAllTools: () => {
|
|
||||||
const activeId = get(activeConversationId);
|
|
||||||
if (!activeId) return;
|
|
||||||
|
|
||||||
conversations.update((convs) => {
|
|
||||||
const conv = convs.get(activeId);
|
|
||||||
if (conv) {
|
|
||||||
conv.grantedTools.clear();
|
|
||||||
conv.lastActivityAt = new Date();
|
|
||||||
}
|
|
||||||
return convs;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
isToolGranted: (toolName: string): boolean => {
|
|
||||||
const activeId = get(activeConversationId);
|
|
||||||
if (!activeId) return false;
|
|
||||||
|
|
||||||
const convs = get(conversations);
|
|
||||||
const conv = convs.get(activeId);
|
|
||||||
return conv?.grantedTools.has(toolName) || false;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Add initialization helper
|
|
||||||
initialize: () => {
|
|
||||||
ensureInitialized();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const conversationsStore = createConversationsStore();
|
|
||||||
// Initialize immediately
|
|
||||||
conversationsStore.initialize();
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,22 @@
|
|||||||
|
import { derived } from "svelte/store";
|
||||||
import { configStore } from "./config";
|
import { configStore } from "./config";
|
||||||
import { soundPlayer } from "$lib/notifications";
|
import { soundPlayer } from "$lib/notifications";
|
||||||
|
|
||||||
let unsubscribe: (() => void) | null = null;
|
// Sync notification settings with config
|
||||||
|
export const notificationSettings = derived(configStore.config, ($config) => {
|
||||||
|
soundPlayer.setEnabled($config.notifications_enabled);
|
||||||
|
soundPlayer.setGlobalVolume($config.notification_volume);
|
||||||
|
|
||||||
// Initialize notification settings sync - call this once on app startup
|
return {
|
||||||
export function initNotificationSync() {
|
enabled: $config.notifications_enabled,
|
||||||
// Prevent duplicate subscriptions
|
volume: $config.notification_volume,
|
||||||
if (unsubscribe) return;
|
};
|
||||||
|
});
|
||||||
|
|
||||||
unsubscribe = configStore.config.subscribe(($config) => {
|
// Helper to update notification settings
|
||||||
soundPlayer.setEnabled($config.notifications_enabled);
|
export async function updateNotificationSettings(enabled: boolean, volume: number) {
|
||||||
soundPlayer.setGlobalVolume($config.notification_volume);
|
await configStore.updateConfig({
|
||||||
|
notifications_enabled: enabled,
|
||||||
|
notification_volume: volume,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup function if needed
|
|
||||||
export function cleanupNotificationSync() {
|
|
||||||
if (unsubscribe) {
|
|
||||||
unsubscribe();
|
|
||||||
unsubscribe = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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";
|
||||||
@@ -18,16 +17,10 @@ import {
|
|||||||
interface StateChangePayload {
|
interface StateChangePayload {
|
||||||
state: CharacterState;
|
state: CharacterState;
|
||||||
tool_name: string | null;
|
tool_name: string | null;
|
||||||
conversation_id?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectedConversations = new Set<string>();
|
let hasConnectedThisSession = false;
|
||||||
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();
|
||||||
@@ -48,13 +41,7 @@ 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(conversationId: string) {
|
async function sendGreeting() {
|
||||||
// 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) {
|
||||||
@@ -70,13 +57,10 @@ async function sendGreeting(conversationId: string) {
|
|||||||
handleNewUserMessage();
|
handleNewUserMessage();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invoke("send_prompt", {
|
await invoke("send_prompt", { message: greetingPrompt });
|
||||||
conversationId,
|
|
||||||
message: greetingPrompt,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send greeting:", error);
|
console.error("Failed to send greeting:", error);
|
||||||
claudeStore.addLineToConversation(conversationId, "error", `Failed to send greeting: ${error}`);
|
claudeStore.addLine("error", `Failed to send greeting: ${error}`);
|
||||||
characterState.setTemporaryState("error", 3000);
|
characterState.setTemporaryState("error", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,26 +69,6 @@ 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() {
|
||||||
@@ -120,78 +84,35 @@ export async function initializeTauriListeners() {
|
|||||||
// Initialize achievements listener
|
// Initialize achievements listener
|
||||||
await initAchievementsListener();
|
await initAchievementsListener();
|
||||||
|
|
||||||
const connectionUnlisten = await listen<ConnectionPayload>("claude:connection", async (event) => {
|
const connectionUnlisten = await listen<string>("claude:connection", async (event) => {
|
||||||
const { status, conversation_id } = event.payload;
|
const status = event.payload as ConnectionStatus;
|
||||||
|
claudeStore.setConnectionStatus(status);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle notification for connection status
|
// Handle notification for connection status
|
||||||
handleConnectionStatusChange(status);
|
handleConnectionStatusChange(status);
|
||||||
|
|
||||||
if (status === "connected") {
|
if (status === "connected") {
|
||||||
// Get the actual conversation ID (fallback to active if not provided)
|
claudeStore.addLine("system", "Connected to Claude Code");
|
||||||
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
characterState.setState("idle");
|
||||||
|
if (!hasConnectedThisSession) {
|
||||||
if (targetConversationId) {
|
hasConnectedThisSession = true;
|
||||||
// Add system message to the correct conversation
|
resetSessionStats(); // Reset session stats on new connection
|
||||||
claudeStore.addLineToConversation(
|
await sendGreeting();
|
||||||
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
|
|
||||||
await sendGreeting(targetConversationId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (status === "disconnected") {
|
} else if (status === "disconnected") {
|
||||||
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
hasConnectedThisSession = false;
|
||||||
|
claudeStore.addLine("system", "Disconnected from Claude Code");
|
||||||
// Only remove from connected set if we're not about to reconnect
|
characterState.setState("idle");
|
||||||
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") {
|
||||||
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
hasConnectedThisSession = false;
|
||||||
|
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, conversation_id } = event.payload;
|
const { state } = event.payload;
|
||||||
|
|
||||||
const stateMap: Record<string, CharacterState> = {
|
const stateMap: Record<string, CharacterState> = {
|
||||||
idle: "idle",
|
idle: "idle",
|
||||||
@@ -207,48 +128,21 @@ export async function initializeTauriListeners() {
|
|||||||
|
|
||||||
const mappedState = stateMap[state.toLowerCase()] || "idle";
|
const mappedState = stateMap[state.toLowerCase()] || "idle";
|
||||||
|
|
||||||
// Always update the conversation's state
|
if (mappedState === "success" || mappedState === "error") {
|
||||||
if (conversation_id) {
|
characterState.setTemporaryState(mappedState, 3000);
|
||||||
claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback to active conversation if no conversation_id
|
characterState.setState(mappedState);
|
||||||
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") {
|
|
||||||
characterState.setTemporaryState(mappedState, 3000);
|
|
||||||
} else {
|
|
||||||
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, conversation_id } = event.payload;
|
const { line_type, content, tool_name } = event.payload;
|
||||||
|
claudeStore.addLine(
|
||||||
// Always store the output to the correct conversation
|
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
||||||
if (conversation_id) {
|
content,
|
||||||
claudeStore.addLineToConversation(
|
tool_name || undefined
|
||||||
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(
|
|
||||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
|
||||||
content,
|
|
||||||
tool_name || undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
unlisteners.push(outputUnlisten);
|
unlisteners.push(outputUnlisten);
|
||||||
|
|
||||||
@@ -257,64 +151,30 @@ export async function initializeTauriListeners() {
|
|||||||
});
|
});
|
||||||
unlisteners.push(streamUnlisten);
|
unlisteners.push(streamUnlisten);
|
||||||
|
|
||||||
const sessionUnlisten = await listen<SessionPayload>("claude:session", (event) => {
|
const sessionUnlisten = await listen<string>("claude:session", (event) => {
|
||||||
const { session_id, conversation_id } = event.payload;
|
claudeStore.setSessionId(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<WorkingDirectoryPayload>("claude:cwd", (event) => {
|
const cwdUnlisten = await listen<string>("claude:cwd", (event) => {
|
||||||
const { directory, conversation_id } = event.payload;
|
claudeStore.setWorkingDirectory(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, conversation_id } = event.payload;
|
const { id, tool_name, tool_input, description } = event.payload;
|
||||||
|
claudeStore.requestPermission({
|
||||||
// Only process permission requests for the active conversation
|
id,
|
||||||
const activeConversationId = get(claudeStore.activeConversationId);
|
tool: tool_name,
|
||||||
if (conversation_id === activeConversationId) {
|
description,
|
||||||
claudeStore.requestPermission({
|
input: tool_input,
|
||||||
id,
|
});
|
||||||
tool: tool_name,
|
claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
|
||||||
description,
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
unlisteners.push(permissionUnlisten);
|
unlisteners.push(permissionUnlisten);
|
||||||
|
|
||||||
|
console.log("Tauri event listeners initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanupTauriListeners() {
|
export function cleanupTauriListeners() {
|
||||||
@@ -324,4 +184,6 @@ export function cleanupTauriListeners() {
|
|||||||
|
|
||||||
// Cleanup notification rules
|
// Cleanup notification rules
|
||||||
cleanupNotificationRules();
|
cleanupNotificationRules();
|
||||||
|
|
||||||
|
console.log("Tauri event listeners cleaned up");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
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,11 +1,3 @@
|
|||||||
export interface TerminalLine {
|
|
||||||
id: string;
|
|
||||||
type: "user" | "assistant" | "system" | "tool" | "error";
|
|
||||||
content: string;
|
|
||||||
timestamp: Date;
|
|
||||||
toolName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SystemInitMessage {
|
export interface SystemInitMessage {
|
||||||
type: "system";
|
type: "system";
|
||||||
subtype: "init";
|
subtype: "init";
|
||||||
@@ -123,7 +115,6 @@ 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";
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
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";
|
||||||
@@ -20,26 +18,18 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 680 KiB After Width: | Height: | Size: 680 KiB |
|
Before Width: | Height: | Size: 442 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 413 KiB After Width: | Height: | Size: 414 KiB |
|
Before Width: | Height: | Size: 777 KiB After Width: | Height: | Size: 778 KiB |