feat: add ability to run multiple agents via tabbed views (#47)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 54s
CI / Lint & Test (push) Successful in 14m18s
CI / Build Linux (push) Successful in 16m46s
CI / Build Windows (cross-compile) (push) Successful in 26m39s

### Explanation

_No response_

### Issue

Closes #30 Closes #41

### Attestations

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

### Dependencies

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

### Style

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

### Tests

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

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #47
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #47.
This commit is contained in:
2026-01-20 13:57:48 -08:00
committed by Naomi Carrigan
parent 2d3adcab1c
commit d83697e5cf
20 changed files with 1375 additions and 287 deletions
+5 -1
View File
@@ -9,6 +9,10 @@
"opener:default",
"shell:allow-spawn",
"shell:allow-stdin-write",
"shell:allow-kill"
"shell:allow-kill",
"notification:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify"
]
}
+135
View File
@@ -0,0 +1,135 @@
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::Arc;
use tauri::AppHandle;
use crate::config::ClaudeStartOptions;
use crate::stats::UsageStats;
use crate::wsl_bridge::WslBridge;
pub struct BridgeManager {
bridges: HashMap<String, WslBridge>,
app_handle: Option<AppHandle>,
}
impl BridgeManager {
pub fn new() -> Self {
BridgeManager {
bridges: HashMap::new(),
app_handle: None,
}
}
pub fn set_app_handle(&mut self, app: AppHandle) {
self.app_handle = Some(app);
}
pub fn start_claude(
&mut self,
conversation_id: &str,
options: ClaudeStartOptions,
) -> Result<(), String> {
// Check if a bridge already exists for this conversation
if self.bridges.get(conversation_id).map(|b| b.is_running()).unwrap_or(false) {
return Err("Claude is already running for this conversation".to_string());
}
let app = self.app_handle.as_ref()
.ok_or_else(|| "App handle not set".to_string())?
.clone();
// Create a new bridge for this conversation
let mut bridge = WslBridge::new_with_conversation_id(conversation_id.to_string());
// Start the Claude process
bridge.start(app, options)?;
// Store the bridge
self.bridges.insert(conversation_id.to_string(), bridge);
Ok(())
}
pub fn stop_claude(&mut self, conversation_id: &str) -> Result<(), String> {
if let Some(bridge) = self.bridges.get_mut(conversation_id) {
let app = self.app_handle.as_ref()
.ok_or_else(|| "App handle not set".to_string())?;
bridge.stop(app);
Ok(())
} else {
Err("No Claude instance found for this conversation".to_string())
}
}
pub fn interrupt_claude(&mut self, conversation_id: &str) -> Result<(), String> {
if let Some(bridge) = self.bridges.get_mut(conversation_id) {
let app = self.app_handle.as_ref()
.ok_or_else(|| "App handle not set".to_string())?;
bridge.interrupt(app)
} else {
Err("No Claude instance found for this conversation".to_string())
}
}
pub fn send_prompt(&mut self, conversation_id: &str, message: String) -> Result<(), String> {
if let Some(bridge) = self.bridges.get_mut(conversation_id) {
bridge.send_message(&message)
} else {
Err("No Claude instance found for this conversation".to_string())
}
}
pub fn is_claude_running(&self, conversation_id: &str) -> bool {
self.bridges.get(conversation_id)
.map(|b| b.is_running())
.unwrap_or(false)
}
pub fn get_working_directory(&self, conversation_id: &str) -> Result<String, String> {
self.bridges.get(conversation_id)
.map(|b| b.get_working_directory().to_string())
.ok_or_else(|| "No Claude instance found for this conversation".to_string())
}
pub fn get_usage_stats(&self, conversation_id: &str) -> Result<UsageStats, String> {
self.bridges.get(conversation_id)
.map(|b| b.get_stats())
.ok_or_else(|| "No Claude instance found for this conversation".to_string())
}
#[allow(dead_code)]
pub fn cleanup_stopped_bridges(&mut self) {
// Remove bridges that are no longer running
self.bridges.retain(|_, bridge| bridge.is_running());
}
#[allow(dead_code)]
pub fn stop_all(&mut self) {
if let Some(app) = &self.app_handle {
for (_, bridge) in self.bridges.iter_mut() {
bridge.stop(app);
}
}
self.bridges.clear();
}
#[allow(dead_code)]
pub fn get_active_conversations(&self) -> Vec<String> {
self.bridges.keys()
.filter(|id| self.bridges.get(*id).map(|b| b.is_running()).unwrap_or(false))
.cloned()
.collect()
}
}
impl Default for BridgeManager {
fn default() -> Self {
Self::new()
}
}
pub type SharedBridgeManager = Arc<Mutex<BridgeManager>>;
pub fn create_shared_bridge_manager() -> SharedBridgeManager {
Arc::new(Mutex::new(BridgeManager::new()))
}
+42 -24
View File
@@ -3,50 +3,65 @@ use tauri_plugin_store::StoreExt;
use crate::config::{ClaudeStartOptions, HikariConfig};
use crate::stats::UsageStats;
use crate::wsl_bridge::SharedBridge;
use crate::bridge_manager::SharedBridgeManager;
use crate::achievements::{load_achievements, get_achievement_info, AchievementUnlockedEvent};
const CONFIG_STORE_KEY: &str = "config";
#[tauri::command]
pub async fn start_claude(
app: AppHandle,
bridge: State<'_, SharedBridge>,
bridge_manager: State<'_, SharedBridgeManager>,
conversation_id: String,
options: ClaudeStartOptions,
) -> Result<(), String> {
let mut bridge = bridge.lock();
bridge.start(app, options)
let mut manager = bridge_manager.lock();
manager.start_claude(&conversation_id, options)
}
#[tauri::command]
pub async fn stop_claude(app: AppHandle, bridge: State<'_, SharedBridge>) -> Result<(), String> {
let mut bridge = bridge.lock();
bridge.stop(&app);
Ok(())
pub async fn stop_claude(
bridge_manager: State<'_, SharedBridgeManager>,
conversation_id: String,
) -> Result<(), String> {
let mut manager = bridge_manager.lock();
manager.stop_claude(&conversation_id)
}
#[tauri::command]
pub async fn interrupt_claude(app: AppHandle, bridge: State<'_, SharedBridge>) -> Result<(), String> {
let mut bridge = bridge.lock();
bridge.interrupt(&app)
pub async fn interrupt_claude(
bridge_manager: State<'_, SharedBridgeManager>,
conversation_id: String,
) -> Result<(), String> {
let mut manager = bridge_manager.lock();
manager.interrupt_claude(&conversation_id)
}
#[tauri::command]
pub async fn send_prompt(bridge: State<'_, SharedBridge>, message: String) -> Result<(), String> {
let mut bridge = bridge.lock();
bridge.send_message(&message)
pub async fn send_prompt(
bridge_manager: State<'_, SharedBridgeManager>,
conversation_id: String,
message: String,
) -> Result<(), String> {
let mut manager = bridge_manager.lock();
manager.send_prompt(&conversation_id, message)
}
#[tauri::command]
pub async fn is_claude_running(bridge: State<'_, SharedBridge>) -> Result<bool, String> {
let bridge = bridge.lock();
Ok(bridge.is_running())
pub async fn is_claude_running(
bridge_manager: State<'_, SharedBridgeManager>,
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: State<'_, SharedBridge>) -> Result<String, String> {
let bridge = bridge.lock();
Ok(bridge.get_working_directory().to_string())
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]
@@ -82,9 +97,12 @@ pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), Str
}
#[tauri::command]
pub async fn get_usage_stats(bridge: State<'_, SharedBridge>) -> Result<UsageStats, String> {
let bridge = bridge.lock();
Ok(bridge.get_stats())
pub async fn get_usage_stats(
bridge_manager: State<'_, SharedBridgeManager>,
conversation_id: String,
) -> Result<UsageStats, String> {
let manager = bridge_manager.lock();
manager.get_usage_stats(&conversation_id)
}
#[tauri::command]
+9 -3
View File
@@ -1,4 +1,5 @@
mod achievements;
mod bridge_manager;
mod commands;
mod config;
mod notifications;
@@ -11,7 +12,7 @@ mod windows_toast;
use commands::*;
use notifications::*;
use wsl_bridge::create_shared_bridge;
use bridge_manager::create_shared_bridge_manager;
use commands::load_saved_achievements;
use wsl_notifications::*;
use vbs_notification::*;
@@ -19,7 +20,7 @@ use windows_toast::*;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let bridge = create_shared_bridge();
let bridge_manager = create_shared_bridge_manager();
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
@@ -28,7 +29,12 @@ pub fn run() {
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_notification::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![
start_claude,
stop_claude,
+29
View File
@@ -172,6 +172,8 @@ pub struct DeltaContent {
pub struct StateChangeEvent {
pub state: CharacterState,
pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -179,6 +181,8 @@ pub struct OutputEvent {
pub line_type: String,
pub content: String,
pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -187,6 +191,29 @@ pub struct PermissionPromptEvent {
pub tool_name: String,
pub tool_input: serde_json::Value,
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)]
@@ -294,6 +321,7 @@ mod tests {
let event = StateChangeEvent {
state: CharacterState::Coding,
tool_name: Some("Edit".to_string()),
conversation_id: None,
};
let serialized = serde_json::to_string(&event).unwrap();
@@ -307,6 +335,7 @@ mod tests {
line_type: "assistant".to_string(),
content: "Test output".to_string(),
tool_name: None,
conversation_id: None,
};
let serialized = serde_json::to_string(&event).unwrap();
+56 -53
View File
@@ -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());
}
}