notedeck

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

commit 5991b9965d5ba6044c03521f44adfc84d4079ff4
parent 7e46d846662fcde2234a5df93b6fc6772aca6493
Author: William Casarin <jb55@jb55.com>
Date:   Mon,  9 Feb 2026 11:40:14 -0800

dave: make external editor non-blocking with async polling

Use Command::spawn() instead of status() to avoid freezing the render
loop while the editor is open. Track the spawned process via EditorJob
struct on SessionManager, poll with try_wait() each frame, and update
session input when the editor exits.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mcrates/notedeck_dave/src/session.rs | 13+++++++++++++
2 files changed, 92 insertions(+), 23 deletions(-)

diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -1439,17 +1439,26 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } - /// Open an external editor for composing the input text + /// Open an external editor for composing the input text (non-blocking) fn open_external_editor(&mut self) { + use crate::session::EditorJob; use std::process::Command; + // Don't spawn another editor if one is already pending + if self.session_manager.pending_editor.is_some() { + tracing::warn!("External editor already in progress"); + return; + } + let Some(session) = self.session_manager.get_active_mut() else { return; }; + let session_id = session.id; + let input_content = session.input.clone(); // Create temp file with current input content let temp_path = std::env::temp_dir().join("notedeck_input.txt"); - if let Err(e) = std::fs::write(&temp_path, &session.input) { + if let Err(e) = std::fs::write(&temp_path, &input_content) { tracing::error!("Failed to write temp file for external editor: {}", e); return; } @@ -1458,10 +1467,10 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let visual = std::env::var("VISUAL").ok(); let editor = std::env::var("EDITOR").ok(); - let result = if let Some(visual_editor) = visual { + let spawn_result = if let Some(visual_editor) = visual { // $VISUAL is set - use it directly (assumes GUI editor) tracing::debug!("Opening external editor via $VISUAL: {}", visual_editor); - Command::new(&visual_editor).arg(&temp_path).status() + Command::new(&visual_editor).arg(&temp_path).spawn() } else { // Fall back to terminal + $EDITOR let editor_cmd = editor.unwrap_or_else(|| "vim".to_string()); @@ -1480,35 +1489,79 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr .arg("-e") .arg(&editor_cmd) .arg(&temp_path) - .status() + .spawn() }; - match result { - Ok(status) if status.success() => { - // Read the edited content back - match std::fs::read_to_string(&temp_path) { - Ok(content) => { - // Re-get mutable session reference after potential borrow issues - if let Some(session) = self.session_manager.get_active_mut() { - session.input = content; - session.focus_requested = true; + match spawn_result { + Ok(child) => { + self.session_manager.pending_editor = Some(EditorJob { + child, + temp_path, + session_id, + }); + tracing::debug!("External editor spawned for session {}", session_id); + } + Err(e) => { + tracing::error!("Failed to spawn external editor: {}", e); + // Clean up temp file on spawn failure + let _ = std::fs::remove_file(&temp_path); + } + } + } + + /// Poll for external editor completion (called each frame) + fn poll_editor_job(&mut self) { + let Some(ref mut job) = self.session_manager.pending_editor else { + return; + }; + + // Non-blocking check if child has exited + match job.child.try_wait() { + Ok(Some(status)) => { + // Editor has exited + let session_id = job.session_id; + let temp_path = job.temp_path.clone(); + + if status.success() { + // Read the edited content back + match std::fs::read_to_string(&temp_path) { + Ok(content) => { + if let Some(session) = self.session_manager.get_mut(session_id) { + session.input = content; + session.focus_requested = true; + tracing::debug!( + "External editor completed, updated input for session {}", + session_id + ); + } + } + Err(e) => { + tracing::error!("Failed to read temp file after editing: {}", e); } } - Err(e) => { - tracing::error!("Failed to read temp file after editing: {}", e); - } + } else { + tracing::warn!("External editor exited with status: {}", status); + } + + // Clean up temp file + if let Err(e) = std::fs::remove_file(&temp_path) { + tracing::error!("Failed to remove temp file: {}", e); } + + // Clear the pending editor + self.session_manager.pending_editor = None; } - Ok(status) => { - tracing::warn!("External editor exited with status: {}", status); + Ok(None) => { + // Editor still running, nothing to do } Err(e) => { - tracing::error!("Failed to spawn external editor: {}", e); + tracing::error!("Failed to poll editor process: {}", e); + // Clean up on error + let temp_path = job.temp_path.clone(); + let _ = std::fs::remove_file(&temp_path); + self.session_manager.pending_editor = None; } } - - // Clean up temp file - let _ = std::fs::remove_file(&temp_path); } /// Try to find a common terminal emulator @@ -1688,6 +1741,9 @@ impl notedeck::App for Dave { // Poll for external spawn-agent commands via IPC self.poll_ipc_commands(); + // Poll for external editor completion + self.poll_editor_job(); + // Handle global keybindings (when no text input has focus) let has_pending_permission = self.first_pending_permission().is_some(); let has_pending_question = self.has_pending_question(); diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -289,12 +289,24 @@ impl ChatSession { } } +/// Tracks a pending external editor process +pub struct EditorJob { + /// The spawned editor process + pub child: std::process::Child, + /// Path to the temp file being edited + pub temp_path: PathBuf, + /// Session ID that initiated the editor + pub session_id: SessionId, +} + /// Manages multiple chat sessions pub struct SessionManager { sessions: HashMap<SessionId, ChatSession>, order: Vec<SessionId>, // Sorted by recency (most recent first) active: Option<SessionId>, next_id: SessionId, + /// Pending external editor job (only one at a time) + pub pending_editor: Option<EditorJob>, } impl Default for SessionManager { @@ -310,6 +322,7 @@ impl SessionManager { order: Vec::new(), active: None, next_id: 1, + pending_editor: None, } }