generated from nhcarrigan/template
Compare commits
6 Commits
2f4bfd4183
...
e4d2d47e61
| Author | SHA1 | Date | |
|---|---|---|---|
|
e4d2d47e61
|
|||
|
511caa3a8e
|
|||
|
51ecf47508
|
|||
|
861b44797e
|
|||
|
6a811d2016
|
|||
| 2d3adcab1c |
@@ -0,0 +1,132 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cleanup_stopped_bridges(&mut self) {
|
||||||
|
// Remove bridges that are no longer running
|
||||||
|
self.bridges.retain(|_, bridge| bridge.is_running());
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_active_conversations(&self) -> Vec<String> {
|
||||||
|
self.bridges.keys()
|
||||||
|
.filter(|id| self.bridges.get(*id).map(|b| b.is_running()).unwrap_or(false))
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BridgeManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SharedBridgeManager = Arc<Mutex<BridgeManager>>;
|
||||||
|
|
||||||
|
pub fn create_shared_bridge_manager() -> SharedBridgeManager {
|
||||||
|
Arc::new(Mutex::new(BridgeManager::new()))
|
||||||
|
}
|
||||||
+45
-21
@@ -3,44 +3,65 @@ use tauri_plugin_store::StoreExt;
|
|||||||
|
|
||||||
use crate::config::{ClaudeStartOptions, HikariConfig};
|
use crate::config::{ClaudeStartOptions, HikariConfig};
|
||||||
use crate::stats::UsageStats;
|
use crate::stats::UsageStats;
|
||||||
use crate::wsl_bridge::SharedBridge;
|
use crate::bridge_manager::SharedBridgeManager;
|
||||||
use crate::achievements::{load_achievements, get_achievement_info, AchievementUnlockedEvent};
|
use crate::achievements::{load_achievements, get_achievement_info, AchievementUnlockedEvent};
|
||||||
|
|
||||||
const CONFIG_STORE_KEY: &str = "config";
|
const CONFIG_STORE_KEY: &str = "config";
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn start_claude(
|
pub async fn start_claude(
|
||||||
app: AppHandle,
|
bridge_manager: State<'_, SharedBridgeManager>,
|
||||||
bridge: State<'_, SharedBridge>,
|
conversation_id: String,
|
||||||
options: ClaudeStartOptions,
|
options: ClaudeStartOptions,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut bridge = bridge.lock();
|
let mut manager = bridge_manager.lock();
|
||||||
bridge.start(app, options)
|
manager.start_claude(&conversation_id, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn stop_claude(app: AppHandle, bridge: State<'_, SharedBridge>) -> Result<(), String> {
|
pub async fn stop_claude(
|
||||||
let mut bridge = bridge.lock();
|
bridge_manager: State<'_, SharedBridgeManager>,
|
||||||
bridge.stop(&app);
|
conversation_id: String,
|
||||||
Ok(())
|
) -> Result<(), String> {
|
||||||
|
let mut manager = bridge_manager.lock();
|
||||||
|
manager.stop_claude(&conversation_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn send_prompt(bridge: State<'_, SharedBridge>, message: String) -> Result<(), String> {
|
pub async fn interrupt_claude(
|
||||||
let mut bridge = bridge.lock();
|
bridge_manager: State<'_, SharedBridgeManager>,
|
||||||
bridge.send_message(&message)
|
conversation_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut manager = bridge_manager.lock();
|
||||||
|
manager.interrupt_claude(&conversation_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn is_claude_running(bridge: State<'_, SharedBridge>) -> Result<bool, String> {
|
pub async fn send_prompt(
|
||||||
let bridge = bridge.lock();
|
bridge_manager: State<'_, SharedBridgeManager>,
|
||||||
Ok(bridge.is_running())
|
conversation_id: String,
|
||||||
|
message: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut manager = bridge_manager.lock();
|
||||||
|
manager.send_prompt(&conversation_id, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_working_directory(bridge: State<'_, SharedBridge>) -> Result<String, String> {
|
pub async fn is_claude_running(
|
||||||
let bridge = bridge.lock();
|
bridge_manager: State<'_, SharedBridgeManager>,
|
||||||
Ok(bridge.get_working_directory().to_string())
|
conversation_id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let manager = bridge_manager.lock();
|
||||||
|
Ok(manager.is_claude_running(&conversation_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_working_directory(
|
||||||
|
bridge_manager: State<'_, SharedBridgeManager>,
|
||||||
|
conversation_id: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let manager = bridge_manager.lock();
|
||||||
|
manager.get_working_directory(&conversation_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -76,9 +97,12 @@ pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), Str
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_usage_stats(bridge: State<'_, SharedBridge>) -> Result<UsageStats, String> {
|
pub async fn get_usage_stats(
|
||||||
let bridge = bridge.lock();
|
bridge_manager: State<'_, SharedBridgeManager>,
|
||||||
Ok(bridge.get_stats())
|
conversation_id: String,
|
||||||
|
) -> Result<UsageStats, String> {
|
||||||
|
let manager = bridge_manager.lock();
|
||||||
|
manager.get_usage_stats(&conversation_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ pub struct ClaudeStartOptions {
|
|||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub allowed_tools: Vec<String>,
|
pub allowed_tools: Vec<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub skip_greeting: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
+10
-3
@@ -1,4 +1,5 @@
|
|||||||
mod achievements;
|
mod achievements;
|
||||||
|
mod bridge_manager;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
@@ -11,7 +12,7 @@ mod windows_toast;
|
|||||||
|
|
||||||
use commands::*;
|
use commands::*;
|
||||||
use notifications::*;
|
use notifications::*;
|
||||||
use wsl_bridge::create_shared_bridge;
|
use bridge_manager::create_shared_bridge_manager;
|
||||||
use commands::load_saved_achievements;
|
use commands::load_saved_achievements;
|
||||||
use wsl_notifications::*;
|
use wsl_notifications::*;
|
||||||
use vbs_notification::*;
|
use vbs_notification::*;
|
||||||
@@ -19,7 +20,7 @@ use windows_toast::*;
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let bridge = create_shared_bridge();
|
let bridge_manager = create_shared_bridge_manager();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
@@ -28,10 +29,16 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(tauri_plugin_os::init())
|
.plugin(tauri_plugin_os::init())
|
||||||
.manage(bridge)
|
.manage(bridge_manager.clone())
|
||||||
|
.setup(move |app| {
|
||||||
|
// Initialize the app handle in the bridge manager
|
||||||
|
bridge_manager.lock().set_app_handle(app.handle().clone());
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
start_claude,
|
start_claude,
|
||||||
stop_claude,
|
stop_claude,
|
||||||
|
interrupt_claude,
|
||||||
send_prompt,
|
send_prompt,
|
||||||
is_claude_running,
|
is_claude_running,
|
||||||
get_working_directory,
|
get_working_directory,
|
||||||
|
|||||||
@@ -172,6 +172,8 @@ pub struct DeltaContent {
|
|||||||
pub struct StateChangeEvent {
|
pub struct StateChangeEvent {
|
||||||
pub state: CharacterState,
|
pub state: CharacterState,
|
||||||
pub tool_name: Option<String>,
|
pub tool_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -179,6 +181,8 @@ pub struct OutputEvent {
|
|||||||
pub line_type: String,
|
pub line_type: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub tool_name: Option<String>,
|
pub tool_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -187,6 +191,29 @@ pub struct PermissionPromptEvent {
|
|||||||
pub tool_name: String,
|
pub tool_name: String,
|
||||||
pub tool_input: serde_json::Value,
|
pub tool_input: serde_json::Value,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConnectionEvent {
|
||||||
|
pub status: ConnectionStatus,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionEvent {
|
||||||
|
pub session_id: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WorkingDirectoryEvent {
|
||||||
|
pub directory: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+75
-29
@@ -1,4 +1,3 @@
|
|||||||
use parking_lot::Mutex;
|
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -12,7 +11,7 @@ use std::os::windows::process::CommandExt;
|
|||||||
use crate::config::ClaudeStartOptions;
|
use crate::config::ClaudeStartOptions;
|
||||||
use crate::stats::{UsageStats, StatsUpdateEvent};
|
use crate::stats::{UsageStats, StatsUpdateEvent};
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent};
|
use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent, ConnectionEvent, SessionEvent, WorkingDirectoryEvent};
|
||||||
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
|
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
|
||||||
|
|
||||||
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
|
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
|
||||||
@@ -76,6 +75,7 @@ pub struct WslBridge {
|
|||||||
session_id: Option<String>,
|
session_id: Option<String>,
|
||||||
mcp_config_file: Option<NamedTempFile>,
|
mcp_config_file: Option<NamedTempFile>,
|
||||||
stats: Arc<RwLock<UsageStats>>,
|
stats: Arc<RwLock<UsageStats>>,
|
||||||
|
conversation_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WslBridge {
|
impl WslBridge {
|
||||||
@@ -87,6 +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 {
|
||||||
|
WslBridge {
|
||||||
|
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +133,7 @@ impl WslBridge {
|
|||||||
let working_dir = &options.working_dir;
|
let working_dir = &options.working_dir;
|
||||||
self.working_directory = working_dir.clone();
|
self.working_directory = working_dir.clone();
|
||||||
|
|
||||||
emit_connection_status(&app, ConnectionStatus::Connecting);
|
emit_connection_status(&app, ConnectionStatus::Connecting, self.conversation_id.clone());
|
||||||
|
|
||||||
// Create temp file for MCP config if provided
|
// Create temp file for MCP config if provided
|
||||||
let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json {
|
let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json {
|
||||||
@@ -290,19 +303,21 @@ impl WslBridge {
|
|||||||
if let Some(stdout) = stdout {
|
if let Some(stdout) = stdout {
|
||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
let stats_clone = self.stats.clone();
|
let stats_clone = self.stats.clone();
|
||||||
|
let conv_id = self.conversation_id.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
handle_stdout(stdout, app_clone, stats_clone);
|
handle_stdout(stdout, app_clone, stats_clone, conv_id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(stderr) = stderr {
|
if let Some(stderr) = stderr {
|
||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
|
let conv_id = self.conversation_id.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
handle_stderr(stderr, app_clone);
|
handle_stderr(stderr, app_clone, conv_id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_connection_status(&app, ConnectionStatus::Connected);
|
emit_connection_status(&app, ConnectionStatus::Connected, self.conversation_id.clone());
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -332,6 +347,30 @@ impl WslBridge {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn interrupt(&mut self, app: &AppHandle) -> Result<(), String> {
|
||||||
|
// Due to persistent bug in Claude Code where ESC/Ctrl+C doesn't work,
|
||||||
|
// we have to kill the process. This is the only reliable way to stop it.
|
||||||
|
// See: https://github.com/anthropics/claude-code/issues/3455
|
||||||
|
if let Some(mut process) = self.process.take() {
|
||||||
|
// Kill the process immediately
|
||||||
|
let _ = process.kill();
|
||||||
|
let _ = process.wait();
|
||||||
|
|
||||||
|
// Clear stdin
|
||||||
|
self.stdin = None;
|
||||||
|
|
||||||
|
// Keep session_id and working directory for user reference
|
||||||
|
// The user will see what session was interrupted
|
||||||
|
|
||||||
|
// Emit disconnected status
|
||||||
|
emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("No active process to interrupt".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn stop(&mut self, app: &AppHandle) {
|
pub fn stop(&mut self, app: &AppHandle) {
|
||||||
if let Some(mut process) = self.process.take() {
|
if let Some(mut process) = self.process.take() {
|
||||||
let _ = process.kill();
|
let _ = process.kill();
|
||||||
@@ -340,7 +379,7 @@ impl WslBridge {
|
|||||||
self.stdin = None;
|
self.stdin = None;
|
||||||
self.session_id = None;
|
self.session_id = None;
|
||||||
self.mcp_config_file = None; // Temp file is automatically deleted when dropped
|
self.mcp_config_file = None; // Temp file is automatically deleted when dropped
|
||||||
emit_connection_status(app, ConnectionStatus::Disconnected);
|
emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_running(&self) -> bool {
|
pub fn is_running(&self) -> bool {
|
||||||
@@ -372,13 +411,13 @@ impl Default for WslBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<RwLock<UsageStats>>) {
|
fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<RwLock<UsageStats>>, conversation_id: Option<String>) {
|
||||||
let reader = BufReader::new(stdout);
|
let reader = BufReader::new(stdout);
|
||||||
|
|
||||||
for line in reader.lines() {
|
for line in reader.lines() {
|
||||||
match line {
|
match line {
|
||||||
Ok(line) if !line.is_empty() => {
|
Ok(line) if !line.is_empty() => {
|
||||||
if let Err(e) = process_json_line(&line, &app, &stats) {
|
if let Err(e) = process_json_line(&line, &app, &stats, &conversation_id) {
|
||||||
eprintln!("Error processing line: {}", e);
|
eprintln!("Error processing line: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -390,10 +429,10 @@ fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<R
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_connection_status(&app, ConnectionStatus::Disconnected);
|
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) {
|
fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle, conversation_id: Option<String>) {
|
||||||
let reader = BufReader::new(stderr);
|
let reader = BufReader::new(stderr);
|
||||||
|
|
||||||
for line in reader.lines() {
|
for line in reader.lines() {
|
||||||
@@ -403,6 +442,7 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) {
|
|||||||
line_type: "error".to_string(),
|
line_type: "error".to_string(),
|
||||||
content: line,
|
content: line,
|
||||||
tool_name: None,
|
tool_name: None,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
@@ -411,7 +451,7 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>>) -> Result<(), String> {
|
fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>>, conversation_id: &Option<String>) -> Result<(), String> {
|
||||||
let message: ClaudeMessage = serde_json::from_str(line)
|
let message: ClaudeMessage = serde_json::from_str(line)
|
||||||
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
|
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
|
||||||
|
|
||||||
@@ -419,12 +459,18 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
|||||||
ClaudeMessage::System { subtype, session_id, cwd, .. } => {
|
ClaudeMessage::System { subtype, session_id, cwd, .. } => {
|
||||||
if subtype == "init" {
|
if subtype == "init" {
|
||||||
if let Some(id) = session_id {
|
if let Some(id) = session_id {
|
||||||
let _ = app.emit("claude:session", id.clone());
|
let _ = app.emit("claude:session", SessionEvent {
|
||||||
|
session_id: id.clone(),
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if let Some(dir) = cwd {
|
if let Some(dir) = cwd {
|
||||||
let _ = app.emit("claude:cwd", dir.clone());
|
let _ = app.emit("claude:cwd", WorkingDirectoryEvent {
|
||||||
|
directory: dir.clone(),
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
emit_state_change(app, CharacterState::Idle, None);
|
emit_state_change(app, CharacterState::Idle, None, conversation_id.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,6 +524,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
|||||||
line_type: "tool".to_string(),
|
line_type: "tool".to_string(),
|
||||||
content: desc,
|
content: desc,
|
||||||
tool_name: Some(name.clone()),
|
tool_name: Some(name.clone()),
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
ContentBlock::Text { text } => {
|
ContentBlock::Text { text } => {
|
||||||
@@ -491,6 +538,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
|||||||
line_type: "assistant".to_string(),
|
line_type: "assistant".to_string(),
|
||||||
content: text.clone(),
|
content: text.clone(),
|
||||||
tool_name: None,
|
tool_name: None,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
ContentBlock::Thinking { thinking } => {
|
ContentBlock::Thinking { thinking } => {
|
||||||
@@ -499,13 +547,14 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
|||||||
line_type: "system".to_string(),
|
line_type: "system".to_string(),
|
||||||
content: format!("[Thinking] {}", thinking),
|
content: format!("[Thinking] {}", thinking),
|
||||||
tool_name: None,
|
tool_name: None,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_state_change(app, state, tool_name);
|
emit_state_change(app, state, tool_name, conversation_id.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
ClaudeMessage::StreamEvent { event } => {
|
ClaudeMessage::StreamEvent { event } => {
|
||||||
@@ -523,7 +572,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
|||||||
}
|
}
|
||||||
_ => CharacterState::Typing,
|
_ => CharacterState::Typing,
|
||||||
};
|
};
|
||||||
emit_state_change(app, state, block.name.clone());
|
emit_state_change(app, state, block.name.clone(), conversation_id.clone());
|
||||||
}
|
}
|
||||||
} else if event.event_type == "content_block_delta" {
|
} else if event.event_type == "content_block_delta" {
|
||||||
if let Some(delta) = &event.delta {
|
if let Some(delta) = &event.delta {
|
||||||
@@ -590,6 +639,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
|||||||
line_type: "error".to_string(),
|
line_type: "error".to_string(),
|
||||||
content: text.clone(),
|
content: text.clone(),
|
||||||
tool_name: None,
|
tool_name: None,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -603,17 +653,18 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
|||||||
tool_name: denial.tool_name.clone(),
|
tool_name: denial.tool_name.clone(),
|
||||||
tool_input: denial.tool_input.clone(),
|
tool_input: denial.tool_input.clone(),
|
||||||
description,
|
description,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show permission state if there were denials
|
// Show permission state if there were denials
|
||||||
if !denials.is_empty() {
|
if !denials.is_empty() {
|
||||||
emit_state_change(app, CharacterState::Permission, None);
|
emit_state_change(app, CharacterState::Permission, None, conversation_id.clone());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_state_change(app, state, None);
|
emit_state_change(app, state, None, conversation_id.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
ClaudeMessage::User { message } => {
|
ClaudeMessage::User { message } => {
|
||||||
@@ -670,7 +721,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_state_change(app, CharacterState::Thinking, None);
|
emit_state_change(app, CharacterState::Thinking, None, conversation_id.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,19 +788,14 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option<String>) {
|
fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option<String>, conversation_id: Option<String>) {
|
||||||
let _ = app.emit("claude:state", StateChangeEvent { state, tool_name });
|
let _ = app.emit("claude:state", StateChangeEvent { state, tool_name, conversation_id });
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_connection_status(app: &AppHandle, status: ConnectionStatus) {
|
fn emit_connection_status(app: &AppHandle, status: ConnectionStatus, conversation_id: Option<String>) {
|
||||||
let _ = app.emit("claude:connection", status);
|
let _ = app.emit("claude:connection", ConnectionEvent { status, conversation_id });
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type SharedBridge = Arc<Mutex<WslBridge>>;
|
|
||||||
|
|
||||||
pub fn create_shared_bridge() -> SharedBridge {
|
|
||||||
Arc::new(Mutex::new(WslBridge::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { claudeStore } from "$lib/stores/claude";
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import type { Conversation } from "$lib/stores/conversations";
|
import type { Conversation } from "$lib/stores/conversations";
|
||||||
|
import { SvelteMap } from "svelte/reactivity";
|
||||||
|
|
||||||
let conversations: Map<string, Conversation> = new Map();
|
let conversations: Map<string, Conversation> = new Map();
|
||||||
let activeConversationId: string | null = null;
|
let activeConversationId: string | null = null;
|
||||||
@@ -11,8 +12,19 @@
|
|||||||
// Track which conversation actually has the Claude connection
|
// Track which conversation actually has the Claude connection
|
||||||
let connectedConversationId: string | null = null;
|
let connectedConversationId: string | null = null;
|
||||||
|
|
||||||
|
// Track last seen message count for each conversation
|
||||||
|
let lastSeenMessageCount = new SvelteMap<string, number>();
|
||||||
|
|
||||||
claudeStore.conversations.subscribe((convs) => {
|
claudeStore.conversations.subscribe((convs) => {
|
||||||
conversations = convs;
|
conversations = convs;
|
||||||
|
|
||||||
|
// Update the last seen count for the active conversation
|
||||||
|
if (activeConversationId) {
|
||||||
|
const activeConv = convs.get(activeConversationId);
|
||||||
|
if (activeConv) {
|
||||||
|
lastSeenMessageCount.set(activeConversationId, activeConv.terminalLines.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
claudeStore.activeConversationId.subscribe((id) => {
|
claudeStore.activeConversationId.subscribe((id) => {
|
||||||
@@ -43,6 +55,14 @@
|
|||||||
saveTabName();
|
saveTabName();
|
||||||
}
|
}
|
||||||
await claudeStore.switchConversation(id);
|
await claudeStore.switchConversation(id);
|
||||||
|
|
||||||
|
// Mark messages as seen when switching to this tab
|
||||||
|
const conv = conversations.get(id);
|
||||||
|
if (conv) {
|
||||||
|
lastSeenMessageCount.set(id, conv.terminalLines.length);
|
||||||
|
// Trigger reactivity
|
||||||
|
lastSeenMessageCount = lastSeenMessageCount;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteTab(id: string, event: MouseEvent) {
|
function deleteTab(id: string, event: MouseEvent) {
|
||||||
@@ -56,6 +76,11 @@
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
editingTabId = id;
|
editingTabId = id;
|
||||||
editingName = name;
|
editingName = name;
|
||||||
|
// Focus input after DOM update
|
||||||
|
setTimeout(() => {
|
||||||
|
const input = document.querySelector('.tab-item input[type="text"]') as HTMLInputElement;
|
||||||
|
if (input) input.focus();
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveTabName() {
|
function saveTabName() {
|
||||||
@@ -79,6 +104,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasUnreadMessages(id: string, conversation: Conversation): boolean {
|
||||||
|
if (id === activeConversationId) return false; // Active tab never has unread
|
||||||
|
const lastSeen = lastSeenMessageCount.get(id) || 0;
|
||||||
|
return conversation.terminalLines.length > lastSeen;
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
saveTabName();
|
saveTabName();
|
||||||
@@ -88,6 +119,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTabKeydown(id: string, event: KeyboardEvent) {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
switchTab(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
function handleGlobalKeydown(event: KeyboardEvent) {
|
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||||
@@ -140,6 +178,7 @@
|
|||||||
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
|
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
|
||||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
|
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
|
||||||
onclick={() => switchTab(id)}
|
onclick={() => switchTab(id)}
|
||||||
|
onkeydown={(e) => handleTabKeydown(id, e)}
|
||||||
role="tab"
|
role="tab"
|
||||||
tabindex={0}
|
tabindex={0}
|
||||||
aria-selected={id === activeConversationId}
|
aria-selected={id === activeConversationId}
|
||||||
@@ -152,25 +191,38 @@
|
|||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32"
|
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32"
|
||||||
autofocus
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
class="w-2 h-2 rounded-full {getConnectionStatusColor(conversation.connectionStatus)}"
|
class="w-2 h-2 rounded-full {getConnectionStatusColor(conversation.connectionStatus)}"
|
||||||
title="Connection: {conversation.connectionStatus}{id !== connectedConversationId && connectedConversationId ? ' (Another tab is connected)' : ''}"
|
title="Connection: {conversation.connectionStatus}{id !== connectedConversationId &&
|
||||||
/>
|
connectedConversationId
|
||||||
|
? ' (Another tab is connected)'
|
||||||
|
: ''}"
|
||||||
|
></div>
|
||||||
<span
|
<span
|
||||||
class="text-sm pr-6 max-w-[150px] truncate"
|
class="text-sm pr-6 max-w-[150px] truncate"
|
||||||
ondblclick={(e) => startEditing(id, conversation.name, e)}
|
ondblclick={(e) => startEditing(id, conversation.name, e)}
|
||||||
|
role="button"
|
||||||
|
tabindex={-1}
|
||||||
>
|
>
|
||||||
{conversation.name}
|
{conversation.name}
|
||||||
</span>
|
</span>
|
||||||
{#if id !== activeConversationId && id === connectedConversationId}
|
{#if id !== activeConversationId && id === connectedConversationId}
|
||||||
<span class="text-xs text-[var(--text-tertiary)]" title="This tab has the active Claude connection">
|
<span
|
||||||
(active)
|
class="text-xs text-[var(--text-tertiary)]"
|
||||||
|
title="This tab has the Claude connection"
|
||||||
|
>
|
||||||
|
(connected)
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if hasUnreadMessages(id, conversation)}
|
||||||
|
<div
|
||||||
|
class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse"
|
||||||
|
title="New messages"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -202,7 +254,7 @@
|
|||||||
<button
|
<button
|
||||||
onclick={createNewTab}
|
onclick={createNewTab}
|
||||||
class="new-tab-btn flex items-center justify-center w-7 h-7 rounded hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)] transition-colors"
|
class="new-tab-btn flex items-center justify-center w-7 h-7 rounded hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)] transition-colors"
|
||||||
title="New conversation (Ctrl+T) Note: Only one tab can be connected at a time"
|
title="New conversation (Ctrl+T)"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="w-4 h-4"
|
class="w-4 h-4"
|
||||||
@@ -211,12 +263,7 @@
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,34 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { claudeStore } from "$lib/stores/claude";
|
import { get } from "svelte/store";
|
||||||
|
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
import { handleNewUserMessage } from "$lib/notifications/rules";
|
import { handleNewUserMessage } from "$lib/notifications/rules";
|
||||||
|
import { setSkipNextGreeting } from "$lib/tauri";
|
||||||
|
import {
|
||||||
|
setShouldRestoreHistory,
|
||||||
|
setSavedHistory,
|
||||||
|
getShouldRestoreHistory,
|
||||||
|
getSavedHistory,
|
||||||
|
clearHistoryRestore,
|
||||||
|
} from "$lib/stores/historyRestore";
|
||||||
|
import MessageModeSelector from "$lib/components/MessageModeSelector.svelte";
|
||||||
|
import { getCurrentMode } from "$lib/stores/messageMode";
|
||||||
|
import { formatMessageWithMode } from "$lib/types/messageMode";
|
||||||
|
|
||||||
let inputValue = $state("");
|
let inputValue = $state("");
|
||||||
let isSubmitting = $state(false);
|
let isSubmitting = $state(false);
|
||||||
let isConnected = $state(false);
|
let isConnected = $state(false);
|
||||||
|
let isProcessing = $state(false);
|
||||||
|
|
||||||
claudeStore.connectionStatus.subscribe((status) => {
|
claudeStore.connectionStatus.subscribe((status) => {
|
||||||
isConnected = status === "connected";
|
isConnected = status === "connected";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
isClaudeProcessing.subscribe((processing) => {
|
||||||
|
isProcessing = processing;
|
||||||
|
});
|
||||||
|
|
||||||
async function handleSubmit(event: Event) {
|
async function handleSubmit(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
@@ -21,14 +38,43 @@
|
|||||||
isSubmitting = true;
|
isSubmitting = true;
|
||||||
inputValue = "";
|
inputValue = "";
|
||||||
|
|
||||||
|
// Apply mode prefix if needed
|
||||||
|
const currentMode = getCurrentMode();
|
||||||
|
const formattedMessage = formatMessageWithMode(message, currentMode);
|
||||||
|
|
||||||
|
// Check if we need to restore conversation history
|
||||||
|
let messageToSend = formattedMessage;
|
||||||
|
if (getShouldRestoreHistory()) {
|
||||||
|
const savedHistory = getSavedHistory();
|
||||||
|
|
||||||
|
if (savedHistory) {
|
||||||
|
// Prepend the conversation history with a context message
|
||||||
|
messageToSend = `[Previous conversation context:]
|
||||||
|
${savedHistory}
|
||||||
|
|
||||||
|
[Continuing conversation after reconnection:]
|
||||||
|
User: ${formattedMessage}`;
|
||||||
|
|
||||||
|
// Clear the restoration flags
|
||||||
|
clearHistoryRestore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reset notification state for new user message
|
// Reset notification state for new user message
|
||||||
handleNewUserMessage();
|
handleNewUserMessage();
|
||||||
|
|
||||||
claudeStore.addLine("user", message);
|
claudeStore.addLine("user", formattedMessage);
|
||||||
characterState.setState("thinking");
|
characterState.setState("thinking");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invoke("send_prompt", { message });
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) {
|
||||||
|
throw new Error("No active conversation");
|
||||||
|
}
|
||||||
|
await invoke("send_prompt", {
|
||||||
|
conversationId,
|
||||||
|
message: messageToSend,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send prompt:", error);
|
console.error("Failed to send prompt:", error);
|
||||||
claudeStore.addLine("error", `Failed to send: ${error}`);
|
claudeStore.addLine("error", `Failed to send: ${error}`);
|
||||||
@@ -38,6 +84,60 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleInterrupt() {
|
||||||
|
// Save the conversation history FIRST before anything else
|
||||||
|
const history = claudeStore.getConversationHistory();
|
||||||
|
|
||||||
|
if (history) {
|
||||||
|
setSavedHistory(history);
|
||||||
|
setShouldRestoreHistory(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) {
|
||||||
|
throw new Error("No active conversation");
|
||||||
|
}
|
||||||
|
await invoke("interrupt_claude", { conversationId });
|
||||||
|
claudeStore.addLine("system", "Process interrupted - reconnecting...");
|
||||||
|
characterState.setState("idle");
|
||||||
|
|
||||||
|
// Show connecting status while we reconnect
|
||||||
|
claudeStore.setConnectionStatus("connecting");
|
||||||
|
|
||||||
|
// Auto-reconnect after a brief delay
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) {
|
||||||
|
throw new Error("No active conversation");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current working directory before reconnecting
|
||||||
|
const workingDir = await invoke<string>("get_working_directory", { conversationId });
|
||||||
|
|
||||||
|
// Set the flag to skip greeting on next connection
|
||||||
|
setSkipNextGreeting(true);
|
||||||
|
|
||||||
|
// Reconnect to Claude
|
||||||
|
await invoke("start_claude", {
|
||||||
|
conversationId,
|
||||||
|
options: {
|
||||||
|
working_dir: workingDir,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (reconnectError) {
|
||||||
|
console.error("Failed to auto-reconnect:", reconnectError);
|
||||||
|
claudeStore.addLine("error", `Failed to reconnect: ${reconnectError}`);
|
||||||
|
claudeStore.addLine("system", "Please manually reconnect to continue");
|
||||||
|
}
|
||||||
|
}, 500); // Brief delay to ensure process is fully terminated
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to interrupt:", error);
|
||||||
|
claudeStore.addLine("error", `Failed to interrupt: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
handleSubmit(event);
|
handleSubmit(event);
|
||||||
@@ -45,34 +145,73 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form onsubmit={handleSubmit} class="input-bar flex gap-3 items-end">
|
<form onsubmit={handleSubmit} class="input-bar">
|
||||||
<div class="flex-1 relative">
|
<div class="input-controls flex gap-2 mb-2">
|
||||||
<textarea
|
<MessageModeSelector />
|
||||||
bind:value={inputValue}
|
</div>
|
||||||
onkeydown={handleKeyDown}
|
|
||||||
placeholder={isConnected ? "Ask Hikari anything..." : "Connect to Claude first..."}
|
<div class="input-row flex gap-3 items-end">
|
||||||
disabled={!isConnected || isSubmitting}
|
<div class="flex-1 relative">
|
||||||
rows={1}
|
<textarea
|
||||||
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
|
bind:value={inputValue}
|
||||||
|
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>
|
</div>
|
||||||
|
|
||||||
<button
|
{#if isProcessing}
|
||||||
type="submit"
|
<button
|
||||||
disabled={!isConnected || isSubmitting || !inputValue.trim()}
|
type="button"
|
||||||
class="px-6 py-3 bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)]
|
onclick={handleInterrupt}
|
||||||
text-white font-medium rounded-lg
|
class="px-6 py-3 bg-red-600 hover:bg-red-700
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
text-white font-medium rounded-lg
|
||||||
transition-all duration-200 transform hover:scale-105 active:scale-95"
|
transition-all duration-200 transform hover:scale-105 active:scale-95"
|
||||||
>
|
title="Interrupt the current response (Ctrl+C)"
|
||||||
{#if isSubmitting}
|
>
|
||||||
<span class="inline-block animate-spin">⏳</span>
|
<span class="font-bold">■</span> Stop
|
||||||
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
Send
|
<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}
|
{/if}
|
||||||
</button>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.input-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { MESSAGE_MODES, type MessageMode } from "$lib/types/messageMode";
|
||||||
|
import { messageMode } from "$lib/stores/messageMode";
|
||||||
|
|
||||||
|
let currentMode = $state("chat");
|
||||||
|
let isOpen = $state(false);
|
||||||
|
|
||||||
|
messageMode.subscribe((mode) => {
|
||||||
|
currentMode = mode;
|
||||||
|
});
|
||||||
|
|
||||||
|
let selectedMode = $derived(MESSAGE_MODES.find((m) => m.id === currentMode) || MESSAGE_MODES[0]);
|
||||||
|
|
||||||
|
function selectMode(mode: MessageMode) {
|
||||||
|
messageMode.set(mode.id);
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDropdown(event: MouseEvent) {
|
||||||
|
event.stopPropagation();
|
||||||
|
isOpen = !isOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
function handleClickOutside() {
|
||||||
|
if (isOpen) {
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onclick={handleClickOutside} />
|
||||||
|
|
||||||
|
<div class="mode-selector-container">
|
||||||
|
<button
|
||||||
|
class="mode-selector-button"
|
||||||
|
onclick={toggleDropdown}
|
||||||
|
title={`Current mode: ${selectedMode.name} - ${selectedMode.description}`}
|
||||||
|
>
|
||||||
|
<span class="mode-icon">{selectedMode.icon}</span>
|
||||||
|
<span class="mode-name">{selectedMode.name}</span>
|
||||||
|
<svg class="dropdown-arrow" class:open={isOpen} width="12" height="12" viewBox="0 0 12 12">
|
||||||
|
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" fill="none" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
{#each MESSAGE_MODES as mode (mode.id)}
|
||||||
|
<button
|
||||||
|
class="dropdown-item"
|
||||||
|
class:active={mode.id === currentMode}
|
||||||
|
onclick={() => selectMode(mode)}
|
||||||
|
>
|
||||||
|
<span class="mode-icon">{mode.icon}</span>
|
||||||
|
<div class="mode-info">
|
||||||
|
<div class="mode-name">{mode.name}</div>
|
||||||
|
<div class="mode-description">{mode.description}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.mode-selector-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-selector-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-selector-button:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-arrow {
|
||||||
|
margin-left: 4px;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-arrow.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
min-width: 280px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.active {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item .mode-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-description {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { get } from "svelte/store";
|
||||||
import { claudeStore, hasPermissionPending } from "$lib/stores/claude";
|
import { claudeStore, hasPermissionPending } from "$lib/stores/claude";
|
||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
import type { PermissionRequest } from "$lib/types/messages";
|
import type { PermissionRequest } from "$lib/types/messages";
|
||||||
@@ -45,12 +46,18 @@
|
|||||||
|
|
||||||
// Stop current session and reconnect with new permissions
|
// Stop current session and reconnect with new permissions
|
||||||
try {
|
try {
|
||||||
await invoke("stop_claude");
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) {
|
||||||
|
throw new Error("No active conversation");
|
||||||
|
}
|
||||||
|
|
||||||
|
await invoke("stop_claude", { conversationId });
|
||||||
|
|
||||||
// Small delay to ensure clean shutdown
|
// Small delay to ensure clean shutdown
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
await invoke("start_claude", {
|
await invoke("start_claude", {
|
||||||
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
working_dir: workingDirectory || "/home/naomi",
|
working_dir: workingDirectory || "/home/naomi",
|
||||||
allowed_tools: newGrantedTools,
|
allowed_tools: newGrantedTools,
|
||||||
@@ -72,7 +79,10 @@ ${JSON.stringify(toolInput, null, 2)}
|
|||||||
|
|
||||||
Please continue where we left off and retry that action now that you have permission.`;
|
Please continue where we left off and retry that action now that you have permission.`;
|
||||||
|
|
||||||
await invoke("send_prompt", { message: contextMessage });
|
await invoke("send_prompt", {
|
||||||
|
conversationId,
|
||||||
|
message: contextMessage,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to reconnect:", error);
|
console.error("Failed to reconnect:", error);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
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";
|
||||||
@@ -87,7 +88,12 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) {
|
||||||
|
throw new Error("No active conversation");
|
||||||
|
}
|
||||||
await invoke("start_claude", {
|
await invoke("start_claude", {
|
||||||
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
working_dir: targetDir,
|
working_dir: targetDir,
|
||||||
model: currentConfig.model || null,
|
model: currentConfig.model || null,
|
||||||
@@ -105,7 +111,11 @@
|
|||||||
|
|
||||||
async function handleDisconnect() {
|
async function handleDisconnect() {
|
||||||
try {
|
try {
|
||||||
await invoke("stop_claude");
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) {
|
||||||
|
throw new Error("No active conversation");
|
||||||
|
}
|
||||||
|
await invoke("stop_claude", { conversationId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to stop Claude:", error);
|
console.error("Failed to stop Claude:", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { derived } from "svelte/store";
|
import { derived } from "svelte/store";
|
||||||
import { conversationsStore } from "./conversations";
|
import { conversationsStore } from "./conversations";
|
||||||
import type { ConnectionStatus, PermissionRequest, TerminalLine } from "$lib/types/messages";
|
import type { TerminalLine } from "$lib/types/messages";
|
||||||
|
import { characterState } from "$lib/stores/character";
|
||||||
|
import {
|
||||||
|
setShouldRestoreHistory,
|
||||||
|
setSavedHistory,
|
||||||
|
getShouldRestoreHistory,
|
||||||
|
getSavedHistory,
|
||||||
|
clearHistoryRestore,
|
||||||
|
} from "./historyRestore";
|
||||||
|
|
||||||
// Re-export TerminalLine type for backwards compatibility
|
// Re-export TerminalLine type for backwards compatibility
|
||||||
export type { TerminalLine };
|
export type { TerminalLine };
|
||||||
@@ -24,10 +32,15 @@ export const claudeStore = {
|
|||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
setConnectionStatus: conversationsStore.setConnectionStatus,
|
setConnectionStatus: conversationsStore.setConnectionStatus,
|
||||||
|
setConnectionStatusForConversation: conversationsStore.setConnectionStatusForConversation,
|
||||||
|
setCharacterStateForConversation: conversationsStore.setCharacterStateForConversation,
|
||||||
setSessionId: conversationsStore.setSessionId,
|
setSessionId: conversationsStore.setSessionId,
|
||||||
|
setSessionIdForConversation: conversationsStore.setSessionIdForConversation,
|
||||||
setWorkingDirectory: conversationsStore.setWorkingDirectory,
|
setWorkingDirectory: conversationsStore.setWorkingDirectory,
|
||||||
|
setWorkingDirectoryForConversation: conversationsStore.setWorkingDirectoryForConversation,
|
||||||
setProcessing: conversationsStore.setProcessing,
|
setProcessing: conversationsStore.setProcessing,
|
||||||
addLine: conversationsStore.addLine,
|
addLine: conversationsStore.addLine,
|
||||||
|
addLineToConversation: conversationsStore.addLineToConversation,
|
||||||
updateLine: conversationsStore.updateLine,
|
updateLine: conversationsStore.updateLine,
|
||||||
appendToLine: conversationsStore.appendToLine,
|
appendToLine: conversationsStore.appendToLine,
|
||||||
clearTerminal: conversationsStore.clearTerminal,
|
clearTerminal: conversationsStore.clearTerminal,
|
||||||
@@ -51,6 +64,12 @@ export const claudeStore = {
|
|||||||
return tools;
|
return tools;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// History restoration methods from main branch
|
||||||
|
setShouldRestoreHistory: setShouldRestoreHistory,
|
||||||
|
setSavedConversationHistory: setSavedHistory,
|
||||||
|
getShouldRestoreHistory: getShouldRestoreHistory,
|
||||||
|
getSavedConversationHistory: getSavedHistory,
|
||||||
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
// Reset only the active conversation
|
// Reset only the active conversation
|
||||||
conversationsStore.clearTerminal();
|
conversationsStore.clearTerminal();
|
||||||
@@ -58,6 +77,8 @@ export const claudeStore = {
|
|||||||
conversationsStore.setWorkingDirectory("");
|
conversationsStore.setWorkingDirectory("");
|
||||||
conversationsStore.setProcessing(false);
|
conversationsStore.setProcessing(false);
|
||||||
conversationsStore.revokeAllTools();
|
conversationsStore.revokeAllTools();
|
||||||
|
// Also clear history restoration
|
||||||
|
clearHistoryRestore();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,3 +86,15 @@ export const hasPermissionPending = derived(
|
|||||||
claudeStore.pendingPermission,
|
claudeStore.pendingPermission,
|
||||||
($permission) => $permission !== null
|
($permission) => $permission !== null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Derived store to check if Claude is currently processing (can be interrupted)
|
||||||
|
export const isClaudeProcessing = derived(
|
||||||
|
[claudeStore.connectionStatus, characterState],
|
||||||
|
([$connectionStatus, $characterState]) => {
|
||||||
|
// Must be connected and in one of the processing states
|
||||||
|
return (
|
||||||
|
$connectionStatus === "connected" &&
|
||||||
|
["thinking", "typing", "searching", "coding", "mcp"].includes($characterState)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { writable, derived, get } from "svelte/store";
|
import { writable, derived, get } from "svelte/store";
|
||||||
import type { TerminalLine, ConnectionStatus, PermissionRequest } from "$lib/types/messages";
|
import type { TerminalLine, ConnectionStatus, PermissionRequest } from "$lib/types/messages";
|
||||||
|
import type { CharacterState } from "$lib/types/states";
|
||||||
|
import { cleanupConversationTracking } from "$lib/tauri";
|
||||||
|
import { characterState } from "$lib/stores/character";
|
||||||
|
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -8,6 +11,7 @@ export interface Conversation {
|
|||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
connectionStatus: ConnectionStatus;
|
connectionStatus: ConnectionStatus;
|
||||||
workingDirectory: string;
|
workingDirectory: string;
|
||||||
|
characterState: CharacterState;
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
grantedTools: Set<string>;
|
grantedTools: Set<string>;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -40,6 +44,7 @@ function createConversationsStore() {
|
|||||||
sessionId: null,
|
sessionId: null,
|
||||||
connectionStatus: "disconnected",
|
connectionStatus: "disconnected",
|
||||||
workingDirectory: "",
|
workingDirectory: "",
|
||||||
|
characterState: "idle",
|
||||||
isProcessing: false,
|
isProcessing: false,
|
||||||
grantedTools: new Set(),
|
grantedTools: new Set(),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -71,14 +76,23 @@ function createConversationsStore() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Derived stores for compatibility with existing code
|
// Derived stores for compatibility with existing code
|
||||||
const connectionStatus = derived(activeConversation, ($conv) => $conv?.connectionStatus || "disconnected");
|
const connectionStatus = derived(
|
||||||
|
activeConversation,
|
||||||
|
($conv) => $conv?.connectionStatus || "disconnected"
|
||||||
|
);
|
||||||
const terminalLines = derived(activeConversation, ($conv) => {
|
const terminalLines = derived(activeConversation, ($conv) => {
|
||||||
return $conv?.terminalLines || [];
|
return $conv?.terminalLines || [];
|
||||||
});
|
});
|
||||||
const sessionId = derived(activeConversation, ($conv) => $conv?.sessionId || null);
|
const sessionId = derived(activeConversation, ($conv) => $conv?.sessionId || null);
|
||||||
const currentWorkingDirectory = derived(activeConversation, ($conv) => $conv?.workingDirectory || "");
|
const currentWorkingDirectory = derived(
|
||||||
|
activeConversation,
|
||||||
|
($conv) => $conv?.workingDirectory || ""
|
||||||
|
);
|
||||||
const isProcessing = derived(activeConversation, ($conv) => $conv?.isProcessing || false);
|
const isProcessing = derived(activeConversation, ($conv) => $conv?.isProcessing || false);
|
||||||
const grantedTools = derived(activeConversation, ($conv) => $conv?.grantedTools || new Set());
|
const grantedTools = derived(
|
||||||
|
activeConversation,
|
||||||
|
($conv) => $conv?.grantedTools || new Set<string>()
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Expose derived stores for compatibility
|
// Expose derived stores for compatibility
|
||||||
@@ -110,6 +124,30 @@ function createConversationsStore() {
|
|||||||
return convs;
|
return convs;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setConnectionStatusForConversation: (conversationId: string, status: ConnectionStatus) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.connectionStatus = status;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setCharacterStateForConversation: (conversationId: string, state: CharacterState) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.characterState = state;
|
||||||
|
// If this is the active conversation, update the global character state
|
||||||
|
if (conversationId === get(activeConversationId)) {
|
||||||
|
characterState.setState(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
requestPermission: (request: PermissionRequest) => pendingPermission.set(request),
|
requestPermission: (request: PermissionRequest) => pendingPermission.set(request),
|
||||||
clearPermission: () => pendingPermission.set(null),
|
clearPermission: () => pendingPermission.set(null),
|
||||||
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
|
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
|
||||||
@@ -136,6 +174,9 @@ function createConversationsStore() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up tracking for this conversation
|
||||||
|
cleanupConversationTracking(id);
|
||||||
|
|
||||||
conversations.update((c) => {
|
conversations.update((c) => {
|
||||||
c.delete(id);
|
c.delete(id);
|
||||||
return c;
|
return c;
|
||||||
@@ -162,33 +203,9 @@ function createConversationsStore() {
|
|||||||
if (currentId !== id) {
|
if (currentId !== id) {
|
||||||
activeConversationId.set(id);
|
activeConversationId.set(id);
|
||||||
|
|
||||||
// Auto-reconnect logic
|
// Update the global character state to match the conversation's state
|
||||||
if (targetConv && targetConv.connectionStatus === "disconnected") {
|
if (targetConv) {
|
||||||
// Check if another conversation is connected
|
characterState.setState(targetConv.characterState);
|
||||||
let hasConnectedConv = false;
|
|
||||||
for (const [convId, conv] of convs) {
|
|
||||||
if (convId !== id && (conv.connectionStatus === "connected" || conv.connectionStatus === "connecting")) {
|
|
||||||
hasConnectedConv = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasConnectedConv) {
|
|
||||||
// Add a note about the limitation
|
|
||||||
const lineId = generateLineId();
|
|
||||||
conversations.update((c) => {
|
|
||||||
const conv = c.get(id);
|
|
||||||
if (conv) {
|
|
||||||
conv.terminalLines.push({
|
|
||||||
id: lineId,
|
|
||||||
type: "system",
|
|
||||||
content: "Another tab is connected. Disconnect it first or use that tab.",
|
|
||||||
timestamp: new Date(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return c;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -220,6 +237,17 @@ function createConversationsStore() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setSessionIdForConversation: (conversationId: string, id: string | null) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.sessionId = id;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
setWorkingDirectory: (dir: string) => {
|
setWorkingDirectory: (dir: string) => {
|
||||||
const activeId = get(activeConversationId);
|
const activeId = get(activeConversationId);
|
||||||
if (!activeId) return;
|
if (!activeId) return;
|
||||||
@@ -234,6 +262,17 @@ function createConversationsStore() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setWorkingDirectoryForConversation: (conversationId: string, dir: string) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.workingDirectory = dir;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
setProcessing: (processing: boolean) => {
|
setProcessing: (processing: boolean) => {
|
||||||
const activeId = get(activeConversationId);
|
const activeId = get(activeConversationId);
|
||||||
if (!activeId) return;
|
if (!activeId) return;
|
||||||
@@ -273,6 +312,34 @@ function createConversationsStore() {
|
|||||||
return line.id;
|
return line.id;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addLineToConversation: (
|
||||||
|
conversationId: string,
|
||||||
|
type: TerminalLine["type"],
|
||||||
|
content: string,
|
||||||
|
toolName?: string
|
||||||
|
) => {
|
||||||
|
ensureInitialized();
|
||||||
|
|
||||||
|
const line: TerminalLine = {
|
||||||
|
id: generateLineId(),
|
||||||
|
type,
|
||||||
|
content,
|
||||||
|
timestamp: new Date(),
|
||||||
|
toolName,
|
||||||
|
};
|
||||||
|
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.terminalLines.push(line);
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
|
||||||
|
return line.id;
|
||||||
|
},
|
||||||
|
|
||||||
updateLine: (id: string, content: string) => {
|
updateLine: (id: string, content: string) => {
|
||||||
const activeId = get(activeConversationId);
|
const activeId = get(activeConversationId);
|
||||||
if (!activeId) return;
|
if (!activeId) return;
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Separate module for history restoration to ensure persistence across reconnects
|
||||||
|
let shouldRestore = false;
|
||||||
|
let savedHistory: string | null = null;
|
||||||
|
|
||||||
|
export function setShouldRestoreHistory(should: boolean) {
|
||||||
|
shouldRestore = should;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSavedHistory(history: string | null) {
|
||||||
|
savedHistory = history;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShouldRestoreHistory(): boolean {
|
||||||
|
return shouldRestore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSavedHistory(): string | null {
|
||||||
|
return savedHistory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHistoryRestore() {
|
||||||
|
shouldRestore = false;
|
||||||
|
savedHistory = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
// Default to chat mode
|
||||||
|
const messageModeStore = writable<string>("chat");
|
||||||
|
|
||||||
|
export const messageMode = {
|
||||||
|
subscribe: messageModeStore.subscribe,
|
||||||
|
set: (mode: string) => {
|
||||||
|
messageModeStore.set(mode);
|
||||||
|
},
|
||||||
|
reset: () => messageModeStore.set("chat"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get current mode
|
||||||
|
export function getCurrentMode(): string {
|
||||||
|
let currentMode = "chat";
|
||||||
|
messageMode.subscribe((mode) => (currentMode = mode))();
|
||||||
|
return currentMode;
|
||||||
|
}
|
||||||
+183
-45
@@ -1,5 +1,6 @@
|
|||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { get } from "svelte/store";
|
||||||
import { claudeStore } from "$lib/stores/claude";
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
import { configStore } from "$lib/stores/config";
|
import { configStore } from "$lib/stores/config";
|
||||||
@@ -17,10 +18,16 @@ import {
|
|||||||
interface StateChangePayload {
|
interface StateChangePayload {
|
||||||
state: CharacterState;
|
state: CharacterState;
|
||||||
tool_name: string | null;
|
tool_name: string | null;
|
||||||
|
conversation_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasConnectedThisSession = false;
|
const connectedConversations = new Set<string>();
|
||||||
let unlisteners: Array<() => void> = [];
|
let unlisteners: Array<() => void> = [];
|
||||||
|
let skipNextGreeting = false;
|
||||||
|
|
||||||
|
export function setSkipNextGreeting(skip: boolean) {
|
||||||
|
skipNextGreeting = skip;
|
||||||
|
}
|
||||||
|
|
||||||
function getTimeOfDay(): string {
|
function getTimeOfDay(): string {
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
@@ -41,7 +48,13 @@ function generateGreetingPrompt(): string {
|
|||||||
return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`;
|
return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendGreeting() {
|
async function sendGreeting(conversationId: string) {
|
||||||
|
// Check if we should skip this greeting
|
||||||
|
if (skipNextGreeting) {
|
||||||
|
skipNextGreeting = false; // Reset the flag
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const config = configStore.getConfig();
|
const config = configStore.getConfig();
|
||||||
|
|
||||||
if (!config.greeting_enabled) {
|
if (!config.greeting_enabled) {
|
||||||
@@ -57,10 +70,13 @@ async function sendGreeting() {
|
|||||||
handleNewUserMessage();
|
handleNewUserMessage();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invoke("send_prompt", { message: greetingPrompt });
|
await invoke("send_prompt", {
|
||||||
|
conversationId,
|
||||||
|
message: greetingPrompt,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send greeting:", error);
|
console.error("Failed to send greeting:", error);
|
||||||
claudeStore.addLine("error", `Failed to send greeting: ${error}`);
|
claudeStore.addLineToConversation(conversationId, "error", `Failed to send greeting: ${error}`);
|
||||||
characterState.setTemporaryState("error", 3000);
|
characterState.setTemporaryState("error", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,6 +85,26 @@ interface OutputPayload {
|
|||||||
line_type: string;
|
line_type: string;
|
||||||
content: string;
|
content: string;
|
||||||
tool_name: string | null;
|
tool_name: string | null;
|
||||||
|
conversation_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectionPayload {
|
||||||
|
status: ConnectionStatus;
|
||||||
|
conversation_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionPayload {
|
||||||
|
session_id: string;
|
||||||
|
conversation_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkingDirectoryPayload {
|
||||||
|
directory: string;
|
||||||
|
conversation_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupConversationTracking(conversationId: string) {
|
||||||
|
connectedConversations.delete(conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initializeTauriListeners() {
|
export async function initializeTauriListeners() {
|
||||||
@@ -84,35 +120,78 @@ export async function initializeTauriListeners() {
|
|||||||
// Initialize achievements listener
|
// Initialize achievements listener
|
||||||
await initAchievementsListener();
|
await initAchievementsListener();
|
||||||
|
|
||||||
const connectionUnlisten = await listen<string>("claude:connection", async (event) => {
|
const connectionUnlisten = await listen<ConnectionPayload>("claude:connection", async (event) => {
|
||||||
const status = event.payload as ConnectionStatus;
|
const { status, conversation_id } = event.payload;
|
||||||
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") {
|
||||||
claudeStore.addLine("system", "Connected to Claude Code");
|
// Get the actual conversation ID (fallback to active if not provided)
|
||||||
characterState.setState("idle");
|
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||||
if (!hasConnectedThisSession) {
|
|
||||||
hasConnectedThisSession = true;
|
if (targetConversationId) {
|
||||||
resetSessionStats(); // Reset session stats on new connection
|
// Add system message to the correct conversation
|
||||||
await sendGreeting();
|
claudeStore.addLineToConversation(
|
||||||
|
targetConversationId,
|
||||||
|
"system",
|
||||||
|
"Connected to Claude Code"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update character state for this conversation
|
||||||
|
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
|
||||||
|
|
||||||
|
// Check if this specific conversation has connected before
|
||||||
|
if (!connectedConversations.has(targetConversationId)) {
|
||||||
|
connectedConversations.add(targetConversationId);
|
||||||
|
resetSessionStats(); // Reset session stats on new connection
|
||||||
|
await sendGreeting(targetConversationId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (status === "disconnected") {
|
} else if (status === "disconnected") {
|
||||||
hasConnectedThisSession = false;
|
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||||
claudeStore.addLine("system", "Disconnected from Claude Code");
|
|
||||||
characterState.setState("idle");
|
// Only remove from connected set if we're not about to reconnect
|
||||||
|
if (!skipNextGreeting && targetConversationId) {
|
||||||
|
connectedConversations.delete(targetConversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't add system message if we're about to reconnect
|
||||||
|
if (!skipNextGreeting && targetConversationId) {
|
||||||
|
claudeStore.addLineToConversation(
|
||||||
|
targetConversationId,
|
||||||
|
"system",
|
||||||
|
"Disconnected from Claude Code"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update character state for this conversation
|
||||||
|
if (targetConversationId) {
|
||||||
|
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
|
||||||
|
}
|
||||||
} else if (status === "error") {
|
} else if (status === "error") {
|
||||||
hasConnectedThisSession = false;
|
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||||
claudeStore.addLine("error", "Connection error");
|
|
||||||
|
if (targetConversationId) {
|
||||||
|
connectedConversations.delete(targetConversationId);
|
||||||
|
claudeStore.addLineToConversation(targetConversationId, "error", "Connection error");
|
||||||
|
}
|
||||||
|
|
||||||
characterState.setTemporaryState("error", 3000);
|
characterState.setTemporaryState("error", 3000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
unlisteners.push(connectionUnlisten);
|
unlisteners.push(connectionUnlisten);
|
||||||
|
|
||||||
const stateUnlisten = await listen<StateChangePayload>("claude:state", (event) => {
|
const stateUnlisten = await listen<StateChangePayload>("claude:state", (event) => {
|
||||||
const { state } = event.payload;
|
const { state, conversation_id } = event.payload;
|
||||||
|
|
||||||
const stateMap: Record<string, CharacterState> = {
|
const stateMap: Record<string, CharacterState> = {
|
||||||
idle: "idle",
|
idle: "idle",
|
||||||
@@ -128,21 +207,48 @@ export async function initializeTauriListeners() {
|
|||||||
|
|
||||||
const mappedState = stateMap[state.toLowerCase()] || "idle";
|
const mappedState = stateMap[state.toLowerCase()] || "idle";
|
||||||
|
|
||||||
if (mappedState === "success" || mappedState === "error") {
|
// Always update the conversation's state
|
||||||
characterState.setTemporaryState(mappedState, 3000);
|
if (conversation_id) {
|
||||||
|
claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
|
||||||
} else {
|
} else {
|
||||||
characterState.setState(mappedState);
|
// Fallback to active conversation if no conversation_id
|
||||||
|
const activeConversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (activeConversationId) {
|
||||||
|
claudeStore.setCharacterStateForConversation(activeConversationId, mappedState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update global character state for active conversation
|
||||||
|
const activeConversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversation_id || conversation_id === activeConversationId) {
|
||||||
|
if (mappedState === "success" || mappedState === "error") {
|
||||||
|
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 } = event.payload;
|
const { line_type, content, tool_name, conversation_id } = event.payload;
|
||||||
claudeStore.addLine(
|
|
||||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
// Always store the output to the correct conversation
|
||||||
content,
|
if (conversation_id) {
|
||||||
tool_name || undefined
|
claudeStore.addLineToConversation(
|
||||||
);
|
conversation_id,
|
||||||
|
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
||||||
|
content,
|
||||||
|
tool_name || undefined
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Fallback to active conversation if no conversation_id provided
|
||||||
|
claudeStore.addLine(
|
||||||
|
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
||||||
|
content,
|
||||||
|
tool_name || undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
unlisteners.push(outputUnlisten);
|
unlisteners.push(outputUnlisten);
|
||||||
|
|
||||||
@@ -151,30 +257,64 @@ export async function initializeTauriListeners() {
|
|||||||
});
|
});
|
||||||
unlisteners.push(streamUnlisten);
|
unlisteners.push(streamUnlisten);
|
||||||
|
|
||||||
const sessionUnlisten = await listen<string>("claude:session", (event) => {
|
const sessionUnlisten = await listen<SessionPayload>("claude:session", (event) => {
|
||||||
claudeStore.setSessionId(event.payload);
|
const { session_id, conversation_id } = event.payload;
|
||||||
claudeStore.addLine("system", `Session: ${event.payload.substring(0, 8)}...`);
|
|
||||||
|
// Store session ID for the correct conversation
|
||||||
|
if (conversation_id) {
|
||||||
|
claudeStore.setSessionIdForConversation(conversation_id, session_id);
|
||||||
|
claudeStore.addLineToConversation(
|
||||||
|
conversation_id,
|
||||||
|
"system",
|
||||||
|
`Session: ${session_id.substring(0, 8)}...`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Fallback to active conversation if no conversation_id
|
||||||
|
claudeStore.setSessionId(session_id);
|
||||||
|
claudeStore.addLine("system", `Session: ${session_id.substring(0, 8)}...`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
unlisteners.push(sessionUnlisten);
|
unlisteners.push(sessionUnlisten);
|
||||||
|
|
||||||
const cwdUnlisten = await listen<string>("claude:cwd", (event) => {
|
const cwdUnlisten = await listen<WorkingDirectoryPayload>("claude:cwd", (event) => {
|
||||||
claudeStore.setWorkingDirectory(event.payload);
|
const { directory, conversation_id } = event.payload;
|
||||||
|
|
||||||
|
// Store working directory for the correct conversation
|
||||||
|
if (conversation_id) {
|
||||||
|
claudeStore.setWorkingDirectoryForConversation(conversation_id, directory);
|
||||||
|
} else {
|
||||||
|
// Fallback to active conversation if no conversation_id
|
||||||
|
claudeStore.setWorkingDirectory(directory);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
unlisteners.push(cwdUnlisten);
|
unlisteners.push(cwdUnlisten);
|
||||||
|
|
||||||
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
|
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
|
||||||
const { id, tool_name, tool_input, description } = event.payload;
|
const { id, tool_name, tool_input, description, conversation_id } = event.payload;
|
||||||
claudeStore.requestPermission({
|
|
||||||
id,
|
// Only process permission requests for the active conversation
|
||||||
tool: tool_name,
|
const activeConversationId = get(claudeStore.activeConversationId);
|
||||||
description,
|
if (conversation_id === activeConversationId) {
|
||||||
input: tool_input,
|
claudeStore.requestPermission({
|
||||||
});
|
id,
|
||||||
claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
|
tool: 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() {
|
||||||
@@ -184,6 +324,4 @@ export function cleanupTauriListeners() {
|
|||||||
|
|
||||||
// Cleanup notification rules
|
// Cleanup notification rules
|
||||||
cleanupNotificationRules();
|
cleanupNotificationRules();
|
||||||
|
|
||||||
console.log("Tauri event listeners cleaned up");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
export interface MessageMode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
prefix?: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MESSAGE_MODES: MessageMode[] = [
|
||||||
|
{
|
||||||
|
id: "chat",
|
||||||
|
name: "Chat",
|
||||||
|
description: "Normal conversation mode",
|
||||||
|
icon: "💬",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "architect",
|
||||||
|
name: "Architect",
|
||||||
|
description: "High-level design and architecture planning",
|
||||||
|
prefix: "[Architect Mode] ",
|
||||||
|
icon: "🏗️",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "code",
|
||||||
|
name: "Code",
|
||||||
|
description: "Focused on writing and editing code",
|
||||||
|
prefix: "[Code Mode] ",
|
||||||
|
icon: "💻",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "debug",
|
||||||
|
name: "Debug",
|
||||||
|
description: "Help with debugging and troubleshooting",
|
||||||
|
prefix: "[Debug Mode] ",
|
||||||
|
icon: "🐛",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ask",
|
||||||
|
name: "Ask",
|
||||||
|
description: "Technical questions and explanations",
|
||||||
|
prefix: "[Ask Mode] ",
|
||||||
|
icon: "❓",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "review",
|
||||||
|
name: "Review",
|
||||||
|
description: "Code review and feedback",
|
||||||
|
prefix: "[Review Mode] ",
|
||||||
|
icon: "👀",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getMessageMode(id: string): MessageMode | undefined {
|
||||||
|
return MESSAGE_MODES.find((mode) => mode.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMessageWithMode(message: string, modeId: string): string {
|
||||||
|
const mode = getMessageMode(modeId);
|
||||||
|
if (!mode || !mode.prefix) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't double-prefix if the message already starts with the prefix
|
||||||
|
if (message.startsWith(mode.prefix)) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mode.prefix + message;
|
||||||
|
}
|
||||||
@@ -123,6 +123,7 @@ export interface PermissionPromptEvent {
|
|||||||
tool_name: string;
|
tool_name: string;
|
||||||
tool_input: Record<string, unknown>;
|
tool_input: Record<string, unknown>;
|
||||||
description: string;
|
description: string;
|
||||||
|
conversation_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
|
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
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 { conversationsStore } from "$lib/stores/conversations";
|
||||||
import "$lib/notifications/testNotifications";
|
import "$lib/notifications/testNotifications";
|
||||||
import Terminal from "$lib/components/Terminal.svelte";
|
import Terminal from "$lib/components/Terminal.svelte";
|
||||||
import InputBar from "$lib/components/InputBar.svelte";
|
import InputBar from "$lib/components/InputBar.svelte";
|
||||||
@@ -18,6 +19,10 @@
|
|||||||
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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user