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:
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,