generated from nhcarrigan/template
feat: add ability to run multiple agents via tabbed views (#47)
### Explanation _No response_ ### Issue Closes #30 Closes #41 ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Reviewed-on: #47 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #47.
This commit is contained in:
+56
-53
@@ -1,4 +1,3 @@
|
||||
use parking_lot::Mutex;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
@@ -12,7 +11,7 @@ use std::os::windows::process::CommandExt;
|
||||
use crate::config::ClaudeStartOptions;
|
||||
use crate::stats::{UsageStats, StatsUpdateEvent};
|
||||
use parking_lot::RwLock;
|
||||
use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent};
|
||||
use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent, ConnectionEvent, SessionEvent, WorkingDirectoryEvent};
|
||||
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
|
||||
|
||||
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
|
||||
@@ -76,6 +75,7 @@ pub struct WslBridge {
|
||||
session_id: Option<String>,
|
||||
mcp_config_file: Option<NamedTempFile>,
|
||||
stats: Arc<RwLock<UsageStats>>,
|
||||
conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
impl WslBridge {
|
||||
@@ -87,21 +87,23 @@ impl WslBridge {
|
||||
session_id: None,
|
||||
mcp_config_file: None,
|
||||
stats: Arc::new(RwLock::new(UsageStats::new())),
|
||||
conversation_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn new_with_loaded_achievements(app: &tauri::AppHandle) -> Self {
|
||||
let bridge = Self::new();
|
||||
|
||||
// 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 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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
|
||||
if self.process.is_some() {
|
||||
return Err("Process already running".to_string());
|
||||
@@ -120,7 +122,7 @@ impl WslBridge {
|
||||
let working_dir = &options.working_dir;
|
||||
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
|
||||
let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json {
|
||||
@@ -290,19 +292,21 @@ impl WslBridge {
|
||||
if let Some(stdout) = stdout {
|
||||
let app_clone = app.clone();
|
||||
let stats_clone = self.stats.clone();
|
||||
let conv_id = self.conversation_id.clone();
|
||||
thread::spawn(move || {
|
||||
handle_stdout(stdout, app_clone, stats_clone);
|
||||
handle_stdout(stdout, app_clone, stats_clone, conv_id);
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(stderr) = stderr {
|
||||
let app_clone = app.clone();
|
||||
let conv_id = self.conversation_id.clone();
|
||||
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(())
|
||||
}
|
||||
@@ -348,7 +352,7 @@ impl WslBridge {
|
||||
// The user will see what session was interrupted
|
||||
|
||||
// Emit disconnected status
|
||||
emit_connection_status(app, ConnectionStatus::Disconnected);
|
||||
emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone());
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -364,7 +368,7 @@ impl WslBridge {
|
||||
self.stdin = None;
|
||||
self.session_id = None;
|
||||
self.mcp_config_file = None; // Temp file is automatically deleted when dropped
|
||||
emit_connection_status(app, ConnectionStatus::Disconnected);
|
||||
emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone());
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
@@ -379,15 +383,6 @@ impl WslBridge {
|
||||
self.stats.read().clone()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn update_stats(&mut self, input_tokens: u64, output_tokens: u64, model: &str) {
|
||||
self.stats.write().add_usage(input_tokens, output_tokens, model);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn reset_session_stats(&mut self) {
|
||||
self.stats.write().reset_session();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WslBridge {
|
||||
@@ -396,13 +391,13 @@ impl Default for WslBridge {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<RwLock<UsageStats>>) {
|
||||
fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<RwLock<UsageStats>>, conversation_id: Option<String>) {
|
||||
let reader = BufReader::new(stdout);
|
||||
|
||||
for line in reader.lines() {
|
||||
match line {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -414,10 +409,10 @@ fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<R
|
||||
}
|
||||
}
|
||||
|
||||
emit_connection_status(&app, ConnectionStatus::Disconnected);
|
||||
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
|
||||
}
|
||||
|
||||
fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) {
|
||||
fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle, conversation_id: Option<String>) {
|
||||
let reader = BufReader::new(stderr);
|
||||
|
||||
for line in reader.lines() {
|
||||
@@ -427,6 +422,7 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) {
|
||||
line_type: "error".to_string(),
|
||||
content: line,
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
});
|
||||
}
|
||||
Err(_) => break,
|
||||
@@ -435,7 +431,7 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>>) -> Result<(), String> {
|
||||
fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>>, conversation_id: &Option<String>) -> Result<(), String> {
|
||||
let message: ClaudeMessage = serde_json::from_str(line)
|
||||
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
|
||||
|
||||
@@ -443,12 +439,18 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
||||
ClaudeMessage::System { subtype, session_id, cwd, .. } => {
|
||||
if subtype == "init" {
|
||||
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 {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,6 +504,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
||||
line_type: "tool".to_string(),
|
||||
content: desc,
|
||||
tool_name: Some(name.clone()),
|
||||
conversation_id: conversation_id.clone(),
|
||||
});
|
||||
}
|
||||
ContentBlock::Text { text } => {
|
||||
@@ -515,6 +518,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
||||
line_type: "assistant".to_string(),
|
||||
content: text.clone(),
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
});
|
||||
}
|
||||
ContentBlock::Thinking { thinking } => {
|
||||
@@ -523,13 +527,14 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
||||
line_type: "system".to_string(),
|
||||
content: format!("[Thinking] {}", thinking),
|
||||
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 } => {
|
||||
@@ -547,7 +552,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
||||
}
|
||||
_ => 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" {
|
||||
if let Some(delta) = &event.delta {
|
||||
@@ -614,6 +619,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
||||
line_type: "error".to_string(),
|
||||
content: text.clone(),
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -627,17 +633,18 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
||||
tool_name: denial.tool_name.clone(),
|
||||
tool_input: denial.tool_input.clone(),
|
||||
description,
|
||||
conversation_id: conversation_id.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Show permission state if there were denials
|
||||
if !denials.is_empty() {
|
||||
emit_state_change(app, CharacterState::Permission, None);
|
||||
emit_state_change(app, CharacterState::Permission, None, conversation_id.clone());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
emit_state_change(app, state, None);
|
||||
emit_state_change(app, state, None, conversation_id.clone());
|
||||
}
|
||||
|
||||
ClaudeMessage::User { message } => {
|
||||
@@ -694,7 +701,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
|
||||
});
|
||||
}
|
||||
|
||||
emit_state_change(app, CharacterState::Thinking, None);
|
||||
emit_state_change(app, CharacterState::Thinking, None, conversation_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -761,19 +768,14 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option<String>) {
|
||||
let _ = app.emit("claude:state", StateChangeEvent { state, tool_name });
|
||||
fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option<String>, conversation_id: Option<String>) {
|
||||
let _ = app.emit("claude:state", StateChangeEvent { state, tool_name, conversation_id });
|
||||
}
|
||||
|
||||
fn emit_connection_status(app: &AppHandle, status: ConnectionStatus) {
|
||||
let _ = app.emit("claude:connection", status);
|
||||
fn emit_connection_status(app: &AppHandle, status: ConnectionStatus, conversation_id: Option<String>) {
|
||||
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)]
|
||||
mod tests {
|
||||
@@ -892,9 +894,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_shared_bridge() {
|
||||
let shared = create_shared_bridge();
|
||||
let bridge = shared.lock();
|
||||
assert!(!bridge.is_running());
|
||||
fn test_create_shared_bridge_manager() {
|
||||
use crate::bridge_manager::create_shared_bridge_manager;
|
||||
let shared = create_shared_bridge_manager();
|
||||
let manager = shared.lock();
|
||||
assert!(manager.get_active_conversations().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user