notedeck

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

commit 8d2c748fb50201a1b9ede533e53cdf837cca8a54
parent 380a14614b6f6a34d978b40a88a41da7cbd913df
Author: William Casarin <jb55@jb55.com>
Date:   Wed,  4 Feb 2026 19:43:35 -0800

fix(dave): wake UI immediately when IPC spawn request arrives

Previously the IPC listener used non-blocking accept() polled each
frame, but egui only repaints on activity. This meant spawn requests
would wait until the next user interaction.

Now the listener runs in a background thread that blocks on accept()
and calls ctx.request_repaint() when a connection arrives, ensuring
the UI processes spawns immediately.

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

Diffstat:
Mcrates/notedeck_chrome/src/chrome.rs | 6+++++-
Mcrates/notedeck_dave/src/backend/traits.rs | 1+
Mcrates/notedeck_dave/src/ipc.rs | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mcrates/notedeck_dave/src/lib.rs | 83+++++++++++++++++++++++++++++--------------------------------------------------
Mcrates/notedeck_dave/src/ui/directory_picker.rs | 4++--
Mcrates/notedeck_dave/src/ui/scene.rs | 1+
Mcrates/notedeck_dave/src/ui/session_list.rs | 1+
7 files changed, 138 insertions(+), 80 deletions(-)

diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs @@ -152,7 +152,11 @@ impl Chrome { stop_debug_mode(notedeck.options()); let context = &mut notedeck.app_context(); - let dave = Dave::new(cc.wgpu_render_state.as_ref(), context.ndb.clone()); + let dave = Dave::new( + cc.wgpu_render_state.as_ref(), + context.ndb.clone(), + cc.egui_ctx.clone(), + ); let mut chrome = Chrome::default(); if !app_args.iter().any(|arg| arg == "--no-columns-app") { diff --git a/crates/notedeck_dave/src/backend/traits.rs b/crates/notedeck_dave/src/backend/traits.rs @@ -19,6 +19,7 @@ pub trait AiBackend: Send + Sync { /// /// Returns a receiver that will receive tokens and tool calls as they arrive, /// plus an optional JoinHandle to the spawned task for cleanup on session deletion. + #[allow(clippy::too_many_arguments)] fn stream_request( &self, messages: Vec<crate::Message>, diff --git a/crates/notedeck_dave/src/ipc.rs b/crates/notedeck_dave/src/ipc.rs @@ -78,13 +78,35 @@ pub use unix::*; mod unix { use super::*; use std::io::{BufRead, BufReader, Write}; - use std::os::unix::net::UnixListener; + use std::os::unix::net::{UnixListener, UnixStream}; + use std::sync::mpsc; + use std::thread; + + /// A pending IPC connection that needs to be processed + pub struct PendingConnection { + pub stream: UnixStream, + pub cwd: PathBuf, + } + + /// Handle to the IPC listener background thread + pub struct IpcListener { + receiver: mpsc::Receiver<PendingConnection>, + } + + impl IpcListener { + /// Poll for pending connections (non-blocking) + pub fn try_recv(&self) -> Option<PendingConnection> { + self.receiver.try_recv().ok() + } + } - /// Creates a non-blocking Unix domain socket listener. + /// Creates an IPC listener that runs in a background thread. + /// + /// The background thread blocks on accept() and calls request_repaint() + /// when a connection arrives, ensuring the UI wakes up immediately. /// /// Returns None if the socket cannot be created (e.g., permission issues). - /// The socket file is removed if it already exists (stale from crash). - pub fn create_listener() -> Option<UnixListener> { + pub fn create_listener(ctx: egui::Context) -> Option<IpcListener> { let path = socket_path(); // Ensure parent directory exists @@ -103,28 +125,62 @@ mod unix { } } - // Create and bind the listener - match UnixListener::bind(&path) { + // Create and bind the listener (blocking mode for the background thread) + let listener = match UnixListener::bind(&path) { Ok(listener) => { - // Set non-blocking for polling in event loop - if let Err(e) = listener.set_nonblocking(true) { - tracing::warn!("Failed to set socket non-blocking: {}", e); - return None; - } tracing::info!("IPC listener started at {}", path.display()); - Some(listener) + listener } Err(e) => { tracing::warn!("Failed to create IPC listener: {}", e); - None + return None; } - } + }; + + // Channel for sending connections to the main thread + let (sender, receiver) = mpsc::channel(); + + // Spawn background thread to handle incoming connections + thread::Builder::new() + .name("ipc-listener".to_string()) + .spawn(move || { + for stream in listener.incoming() { + match stream { + Ok(mut stream) => { + // Parse the request in the background thread + match handle_connection(&mut stream) { + Ok(cwd) => { + let pending = PendingConnection { stream, cwd }; + if sender.send(pending).is_err() { + // Main thread dropped the receiver, exit + tracing::debug!("IPC listener: main thread gone, exiting"); + break; + } + // Wake up the UI to process the connection + ctx.request_repaint(); + } + Err(e) => { + // Send error response directly + let response = SpawnResponse::error(&e); + let _ = send_response(&mut stream, &response); + tracing::warn!("IPC spawn-agent failed: {}", e); + } + } + } + Err(e) => { + tracing::warn!("IPC accept error: {}", e); + } + } + } + tracing::debug!("IPC listener thread exiting"); + }) + .ok()?; + + Some(IpcListener { receiver }) } /// Handles a single IPC connection, returning the cwd if valid spawn request. - pub fn handle_connection( - stream: &mut std::os::unix::net::UnixStream, - ) -> Result<PathBuf, String> { + pub fn handle_connection(stream: &mut UnixStream) -> Result<PathBuf, String> { // Read the request line let mut reader = BufReader::new(stream.try_clone().map_err(|e| e.to_string())?); let mut line = String::new(); @@ -154,10 +210,7 @@ mod unix { } /// Sends a response back to the client - pub fn send_response( - stream: &mut std::os::unix::net::UnixStream, - response: &SpawnResponse, - ) -> std::io::Result<()> { + pub fn send_response(stream: &mut UnixStream, response: &SpawnResponse) -> std::io::Result<()> { let json = serde_json::to_string(response)?; writeln!(stream, "{}", json)?; stream.flush() @@ -166,7 +219,28 @@ mod unix { // Stub for non-Unix platforms (Windows) #[cfg(not(unix))] -pub fn create_listener() -> Option<()> { - tracing::info!("IPC spawn-agent not supported on this platform"); - None +pub mod non_unix { + use std::path::PathBuf; + + /// Stub for PendingConnection on non-Unix platforms + pub struct PendingConnection { + pub cwd: PathBuf, + } + + /// Stub for IpcListener on non-Unix platforms + pub struct IpcListener; + + impl IpcListener { + pub fn try_recv(&self) -> Option<PendingConnection> { + None + } + } + + pub fn create_listener(_ctx: egui::Context) -> Option<IpcListener> { + tracing::info!("IPC spawn-agent not supported on this platform"); + None + } } + +#[cfg(not(unix))] +pub use non_unix::*; diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -89,9 +89,8 @@ pub struct Dave { directory_picker: DirectoryPicker, /// Current overlay taking over the UI (if any) active_overlay: DaveOverlay, - /// IPC listener for external spawn-agent commands (Unix only) - #[cfg(unix)] - ipc_listener: Option<std::os::unix::net::UnixListener>, + /// IPC listener for external spawn-agent commands + ipc_listener: Option<ipc::IpcListener>, } /// Calculate an anonymous user_id from a keypair @@ -136,7 +135,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr )) } - pub fn new(render_state: Option<&RenderState>, ndb: nostrdb::Ndb) -> Self { + pub fn new(render_state: Option<&RenderState>, ndb: nostrdb::Ndb, ctx: egui::Context) -> Self { let model_config = ModelConfig::default(); //let model_config = ModelConfig::ollama(); @@ -166,9 +165,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let directory_picker = DirectoryPicker::new(); - // Create IPC listener for external spawn-agent commands (Unix only) - #[cfg(unix)] - let ipc_listener = ipc::create_listener(); + // Create IPC listener for external spawn-agent commands + let ipc_listener = ipc::create_listener(ctx); Dave { backend, @@ -188,7 +186,6 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr directory_picker, // Auto-show directory picker on startup since there are no sessions active_overlay: DaveOverlay::DirectoryPicker, - #[cfg(unix)] ipc_listener, } } @@ -772,59 +769,40 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } - /// Poll for IPC spawn-agent commands from external tools (Unix only) - #[cfg(unix)] + /// Poll for IPC spawn-agent commands from external tools fn poll_ipc_commands(&mut self) { - use std::io::ErrorKind; - let Some(listener) = self.ipc_listener.as_ref() else { return; }; - // Non-blocking accept - check for incoming connections - match listener.accept() { - Ok((mut stream, _)) => { - // Handle the connection - match ipc::handle_connection(&mut stream) { - Ok(cwd) => { - // Create the session and get its ID - let id = self.session_manager.new_session(cwd.clone()); - self.directory_picker.add_recent(cwd); - - // Focus on new session - if let Some(session) = self.session_manager.get_mut(id) { - session.focus_requested = true; - if self.show_scene { - self.scene.select(id); - self.scene.focus_on(session.scene_position); - } - } - - // Close directory picker if open - if self.active_overlay == DaveOverlay::DirectoryPicker { - self.active_overlay = DaveOverlay::None; - } + // Drain all pending connections (non-blocking) + while let Some(mut pending) = listener.try_recv() { + // Create the session and get its ID + let id = self.session_manager.new_session(pending.cwd.clone()); + self.directory_picker.add_recent(pending.cwd); - // Send success response - let response = ipc::SpawnResponse::ok(id); - let _ = ipc::send_response(&mut stream, &response); - - tracing::info!("Spawned agent via IPC (session {})", id); - } - Err(e) => { - // Send error response - let response = ipc::SpawnResponse::error(&e); - let _ = ipc::send_response(&mut stream, &response); - tracing::warn!("IPC spawn-agent failed: {}", e); - } + // Focus on new session + if let Some(session) = self.session_manager.get_mut(id) { + session.focus_requested = true; + if self.show_scene { + self.scene.select(id); + self.scene.focus_on(session.scene_position); } } - Err(ref e) if e.kind() == ErrorKind::WouldBlock => { - // No pending connections, this is normal + + // Close directory picker if open + if self.active_overlay == DaveOverlay::DirectoryPicker { + self.active_overlay = DaveOverlay::None; } - Err(e) => { - tracing::warn!("IPC accept error: {}", e); + + // Send success response back to the client + #[cfg(unix)] + { + let response = ipc::SpawnResponse::ok(id); + let _ = ipc::send_response(&mut pending.stream, &response); } + + tracing::info!("Spawned agent via IPC (session {})", id); } } @@ -1523,8 +1501,7 @@ impl notedeck::App for Dave { } } - // Poll for external spawn-agent commands via IPC (Unix only) - #[cfg(unix)] + // Poll for external spawn-agent commands via IPC self.poll_ipc_commands(); // Handle global keybindings (when no text input has focus) diff --git a/crates/notedeck_dave/src/ui/directory_picker.rs b/crates/notedeck_dave/src/ui/directory_picker.rs @@ -1,6 +1,6 @@ use crate::ui::keybind_hint::paint_keybind_hint; use egui::{RichText, Vec2}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// Maximum number of recent directories to store const MAX_RECENT_DIRECTORIES: usize = 10; @@ -323,7 +323,7 @@ impl DirectoryPicker { } /// Abbreviate a path for display (e.g., replace home dir with ~) -fn abbreviate_path(path: &PathBuf) -> String { +fn abbreviate_path(path: &Path) -> String { if let Some(home) = dirs::home_dir() { if let Ok(relative) = path.strip_prefix(&home) { return format!("~/{}", relative.display()); diff --git a/crates/notedeck_dave/src/ui/scene.rs b/crates/notedeck_dave/src/ui/scene.rs @@ -299,6 +299,7 @@ impl AgentScene { /// Draw a single agent unit and return the interaction Response /// `keybind_number` is the 1-indexed number displayed when Ctrl is held (matches Ctrl+N keybindings) + #[allow(clippy::too_many_arguments)] fn draw_agent( ui: &mut egui::Ui, id: SessionId, diff --git a/crates/notedeck_dave/src/ui/session_list.rs b/crates/notedeck_dave/src/ui/session_list.rs @@ -127,6 +127,7 @@ impl<'a> SessionListUi<'a> { action } + #[allow(clippy::too_many_arguments)] fn session_item_ui( &self, ui: &mut egui::Ui,