notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit eacaa41b34c2eb19b2a617b53882c2860c1b69a9
parent 3ec5b203e903f6be11a06c26cb0677528750bc15
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 26 Jan 2026 12:16:13 -0800

dave: fix RTS scene bugs and improve multi-agent status updates

- Process events for all sessions, not just active one, so agent status
  updates in real-time without needing to switch to each agent
- Remove grid from scene view (was rendering incorrectly)
- Add compact mode to DaveUi for better sidebar layout with reduced margins
- Guard keyboard shortcuts with wants_keyboard_input() to prevent
  N/backspace from spawning/deleting agents while typing in chat

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 195+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mcrates/notedeck_dave/src/ui/dave.rs | 25++++++++++++++++++-------
Mcrates/notedeck_dave/src/ui/scene.rs | 79+++++++++++--------------------------------------------------------------------
3 files changed, 141 insertions(+), 158 deletions(-)

diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -155,111 +155,139 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr self.settings = settings; } - /// Process incoming tokens from the ai backend + /// Process incoming tokens from the ai backend for ALL sessions fn process_events(&mut self, app_ctx: &AppContext) -> bool { // Should we continue sending requests? Set this to true if - // we have tool responses to send back to the ai + // we have tool responses to send back to the ai (only for active session) let mut should_send = false; + let active_id = self.session_manager.active_id(); + + // Get all session IDs to process + let session_ids = self.session_manager.session_ids(); + + for session_id in session_ids { + // Take the receiver out to avoid borrow conflicts + let recvr = { + let Some(session) = self.session_manager.get_mut(session_id) else { + continue; + }; + session.incoming_tokens.take() + }; - // Take the receiver out to avoid borrow conflicts - let recvr = { - let Some(session) = self.session_manager.get_active_mut() else { - return should_send; + let Some(recvr) = recvr else { + continue; }; - session.incoming_tokens.take() - }; - let Some(recvr) = recvr else { - return should_send; - }; + while let Ok(res) = recvr.try_recv() { + // Nudge avatar only for active session + if active_id == Some(session_id) { + if let Some(avatar) = &mut self.avatar { + avatar.random_nudge(); + } + } - while let Ok(res) = recvr.try_recv() { - if let Some(avatar) = &mut self.avatar { - avatar.random_nudge(); - } + let Some(session) = self.session_manager.get_mut(session_id) else { + break; + }; + + match res { + DaveApiResponse::Failed(err) => session.chat.push(Message::Error(err)), + + DaveApiResponse::Token(token) => match session.chat.last_mut() { + Some(Message::Assistant(msg)) => *msg = msg.clone() + &token, + Some(_) => session.chat.push(Message::Assistant(token)), + None => {} + }, + + DaveApiResponse::ToolCalls(toolcalls) => { + tracing::info!("got tool calls: {:?}", toolcalls); + session.chat.push(Message::ToolCalls(toolcalls.clone())); + + let txn = Transaction::new(app_ctx.ndb).unwrap(); + for call in &toolcalls { + // execute toolcall + match call.calls() { + ToolCalls::PresentNotes(present) => { + session.chat.push(Message::ToolResponse(ToolResponse::new( + call.id().to_owned(), + ToolResponses::PresentNotes(present.note_ids.len() as i32), + ))); + + // Only send for active session + if active_id == Some(session_id) { + should_send = true; + } + } - let Some(session) = self.session_manager.get_active_mut() else { - break; - }; + ToolCalls::Invalid(invalid) => { + if active_id == Some(session_id) { + should_send = true; + } - match res { - DaveApiResponse::Failed(err) => session.chat.push(Message::Error(err)), - - DaveApiResponse::Token(token) => match session.chat.last_mut() { - Some(Message::Assistant(msg)) => *msg = msg.clone() + &token, - Some(_) => session.chat.push(Message::Assistant(token)), - None => {} - }, - - DaveApiResponse::ToolCalls(toolcalls) => { - tracing::info!("got tool calls: {:?}", toolcalls); - session.chat.push(Message::ToolCalls(toolcalls.clone())); - - let txn = Transaction::new(app_ctx.ndb).unwrap(); - for call in &toolcalls { - // execute toolcall - match call.calls() { - ToolCalls::PresentNotes(present) => { - session.chat.push(Message::ToolResponse(ToolResponse::new( - call.id().to_owned(), - ToolResponses::PresentNotes(present.note_ids.len() as i32), - ))); - - should_send = true; - } + session.chat.push(Message::tool_error( + call.id().to_string(), + invalid.error.clone(), + )); + } - ToolCalls::Invalid(invalid) => { - should_send = true; + ToolCalls::Query(search_call) => { + if active_id == Some(session_id) { + should_send = true; + } - session.chat.push(Message::tool_error( - call.id().to_string(), - invalid.error.clone(), - )); + let resp = search_call.execute(&txn, app_ctx.ndb); + session.chat.push(Message::ToolResponse(ToolResponse::new( + call.id().to_owned(), + ToolResponses::Query(resp), + ))) + } } + } + } - ToolCalls::Query(search_call) => { - should_send = true; + DaveApiResponse::PermissionRequest(pending) => { + tracing::info!( + "Permission request for tool '{}': {:?}", + pending.request.tool_name, + pending.request.tool_input + ); + + // Store the response sender for later + session + .pending_permissions + .insert(pending.request.id, pending.response_tx); + + // Add the request to chat for UI display + session + .chat + .push(Message::PermissionRequest(pending.request)); + } - let resp = search_call.execute(&txn, app_ctx.ndb); - session.chat.push(Message::ToolResponse(ToolResponse::new( - call.id().to_owned(), - ToolResponses::Query(resp), - ))) - } - } + DaveApiResponse::ToolResult(result) => { + tracing::debug!("Tool result: {} - {}", result.tool_name, result.summary); + session.chat.push(Message::ToolResult(result)); } } + } - DaveApiResponse::PermissionRequest(pending) => { - tracing::info!( - "Permission request for tool '{}': {:?}", - pending.request.tool_name, - pending.request.tool_input - ); - - // Store the response sender for later - session - .pending_permissions - .insert(pending.request.id, pending.response_tx); - - // Add the request to chat for UI display - session - .chat - .push(Message::PermissionRequest(pending.request)); + // Check if channel is disconnected (stream ended) + match recvr.try_recv() { + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + // Stream ended, clear task state + if let Some(session) = self.session_manager.get_mut(session_id) { + session.task_handle = None; + // Don't restore incoming_tokens - leave it None + } } - - DaveApiResponse::ToolResult(result) => { - tracing::debug!("Tool result: {} - {}", result.tool_name, result.summary); - session.chat.push(Message::ToolResult(result)); + _ => { + // Channel still open, put receiver back + if let Some(session) = self.session_manager.get_mut(session_id) { + session.incoming_tokens = Some(recvr); + } } } } - // Put the receiver back - if let Some(session) = self.session_manager.get_active_mut() { - session.incoming_tokens = Some(recvr); - } - should_send } @@ -364,6 +392,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr &session.chat, &mut session.input, ) + .compact(true) .ui(app_ctx, ui); if response.action.is_some() { diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -18,6 +18,7 @@ pub struct DaveUi<'a> { chat: &'a [Message], trial: bool, input: &'a mut String, + compact: bool, } /// The response the app generates. The response contains an optional @@ -79,19 +80,29 @@ pub enum DaveAction { impl<'a> DaveUi<'a> { pub fn new(trial: bool, chat: &'a [Message], input: &'a mut String) -> Self { - DaveUi { trial, chat, input } + DaveUi { + trial, + chat, + input, + compact: false, + } + } + + pub fn compact(mut self, compact: bool) -> Self { + self.compact = compact; + self } - fn chat_margin(ctx: &egui::Context) -> i8 { - if notedeck::ui::is_narrow(ctx) { + fn chat_margin(&self, ctx: &egui::Context) -> i8 { + if self.compact || notedeck::ui::is_narrow(ctx) { 20 } else { 100 } } - fn chat_frame(ctx: &egui::Context) -> egui::Frame { - let margin = Self::chat_margin(ctx); + fn chat_frame(&self, ctx: &egui::Context) -> egui::Frame { + let margin = self.chat_margin(ctx); egui::Frame::new().inner_margin(egui::Margin { left: margin, right: margin, @@ -107,7 +118,7 @@ impl<'a> DaveUi<'a> { egui::Frame::NONE .show(ui, |ui| { ui.with_layout(Layout::bottom_up(Align::Min), |ui| { - let margin = Self::chat_margin(ui.ctx()); + let margin = self.chat_margin(ui.ctx()); let r = egui::Frame::new() .outer_margin(egui::Margin { @@ -126,7 +137,7 @@ impl<'a> DaveUi<'a> { .stick_to_bottom(true) .auto_shrink([false; 2]) .show(ui, |ui| { - Self::chat_frame(ui.ctx()) + self.chat_frame(ui.ctx()) .show(ui, |ui| { ui.vertical(|ui| self.render_chat(app_ctx, ui)).inner }) diff --git a/crates/notedeck_dave/src/ui/scene.rs b/crates/notedeck_dave/src/ui/scene.rs @@ -175,9 +175,6 @@ impl AgentScene { egui::Scene::new() .zoom_range(0.1..=4.0) .show(ui, &mut scene_rect, |ui| { - // Draw background grid - Self::draw_grid(ui); - // Draw agents and collect interaction responses for session in session_manager.iter() { let id = session.id; @@ -259,16 +256,19 @@ impl AgentScene { } } - // Handle keyboard input - if ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) { - if !self.selected.is_empty() { - response = SceneResponse::new(SceneAction::DeleteSelected); + // Handle keyboard input (only when no text input has focus) + if !ui.ctx().wants_keyboard_input() { + if ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) + { + if !self.selected.is_empty() { + response = SceneResponse::new(SceneAction::DeleteSelected); + } } - } - // Handle 'n' key to spawn new agent - if ui.input(|i| i.key_pressed(egui::Key::N)) { - response = SceneResponse::new(SceneAction::SpawnAgent); + // Handle 'n' key to spawn new agent + if ui.input(|i| i.key_pressed(egui::Key::N)) { + response = SceneResponse::new(SceneAction::SpawnAgent); + } } // Handle box selection completion @@ -313,63 +313,6 @@ impl AgentScene { response } - /// Draw the background grid - fn draw_grid(ui: &mut egui::Ui) { - let painter = ui.painter(); - let rect = ui.max_rect(); - let grid_spacing = 50.0; - let grid_color = ui - .visuals() - .widgets - .noninteractive - .bg_stroke - .color - .gamma_multiply(0.3); - - // Vertical lines - let start_x = (rect.min.x / grid_spacing).floor() * grid_spacing; - let mut x = start_x; - while x < rect.max.x { - painter.line_segment( - [Pos2::new(x, rect.min.y), Pos2::new(x, rect.max.y)], - egui::Stroke::new(1.0, grid_color), - ); - x += grid_spacing; - } - - // Horizontal lines - let start_y = (rect.min.y / grid_spacing).floor() * grid_spacing; - let mut y = start_y; - while y < rect.max.y { - painter.line_segment( - [Pos2::new(rect.min.x, y), Pos2::new(rect.max.x, y)], - egui::Stroke::new(1.0, grid_color), - ); - y += grid_spacing; - } - - // Draw origin axes slightly brighter - let axis_color = ui - .visuals() - .widgets - .noninteractive - .bg_stroke - .color - .gamma_multiply(0.6); - if rect.min.x < 0.0 && rect.max.x > 0.0 { - painter.line_segment( - [Pos2::new(0.0, rect.min.y), Pos2::new(0.0, rect.max.y)], - egui::Stroke::new(2.0, axis_color), - ); - } - if rect.min.y < 0.0 && rect.max.y > 0.0 { - painter.line_segment( - [Pos2::new(rect.min.x, 0.0), Pos2::new(rect.max.x, 0.0)], - egui::Stroke::new(2.0, axis_color), - ); - } - } - /// Draw a single agent unit and return the interaction Response fn draw_agent( ui: &mut egui::Ui,