notedeck

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

commit b2ceec2d49bb2c1330ef0c8336682b762d3be30d
parent 37ab24c79b46a44bc79358db5bfe52d2de5e36b8
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 26 Jan 2026 17:05:56 -0800

dave: add interrupt/stop functionality for AI operations

Add the ability to interrupt in-progress AI queries:
- Add SessionCommand::Interrupt to backend actor pattern
- Handle interrupts in the streaming select! loop with highest priority
- Add interrupt_session method to AiBackend trait
- Add DaveAction::Interrupt and Stop button in UI
- Show Stop button when agent status is Working, Ask button otherwise

Uses claude-agent-sdk-rs interrupt() which sends {"subtype": "interrupt"}
to the CLI, stopping the current operation while preserving session history.

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

Diffstat:
Mcrates/notedeck_dave/src/backend/claude.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/notedeck_dave/src/backend/openai.rs | 5+++++
Mcrates/notedeck_dave/src/backend/traits.rs | 4++++
Mcrates/notedeck_dave/src/file_update.rs | 6++++--
Mcrates/notedeck_dave/src/lib.rs | 25+++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/dave.rs | 26++++++++++++++++++++++++--
Mcrates/notedeck_dave/src/ui/diff.rs | 10+++++++---
7 files changed, 126 insertions(+), 9 deletions(-)

diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs @@ -25,6 +25,10 @@ enum SessionCommand { response_tx: mpsc::Sender<DaveApiResponse>, ctx: egui::Context, }, + /// Interrupt the current query - stops the stream but preserves session + Interrupt { + ctx: egui::Context, + }, Shutdown, } @@ -212,7 +216,7 @@ async fn session_actor(session_id: String, mut command_rx: tokio_mpsc::Receiver< let mut pending_tools: HashMap<String, (String, serde_json::Value)> = HashMap::new(); - // Stream response with select! to handle both stream and permission requests + // Stream response with select! to handle stream, permission requests, and interrupts let mut stream = client.receive_response(); let mut stream_done = false; @@ -220,7 +224,39 @@ async fn session_actor(session_id: String, mut command_rx: tokio_mpsc::Receiver< tokio::select! { biased; - // Handle permission requests first (they're blocking the SDK) + // Check for interrupt command (highest priority) + Some(cmd) = command_rx.recv() => { + match cmd { + SessionCommand::Interrupt { ctx: interrupt_ctx } => { + tracing::debug!("Session {} received interrupt", session_id); + if let Err(err) = client.interrupt().await { + tracing::error!("Failed to send interrupt: {}", err); + } + // Let the stream end naturally - it will send a Result message + // The session history is preserved by the CLI + interrupt_ctx.request_repaint(); + } + SessionCommand::Query { response_tx: new_tx, .. } => { + // A new query came in while we're still streaming - shouldn't happen + // but handle gracefully by rejecting it + let _ = new_tx.send(DaveApiResponse::Failed( + "Query already in progress".to_string() + )); + } + SessionCommand::Shutdown => { + tracing::debug!("Session actor {} shutting down during query", session_id); + // Drop stream and disconnect - break to exit loop first + drop(stream); + if let Err(err) = client.disconnect().await { + tracing::warn!("Error disconnecting session {}: {}", session_id, err); + } + tracing::debug!("Session {} actor exited", session_id); + return; + } + } + } + + // Handle permission requests (they're blocking the SDK) Some(perm_req) = perm_rx.recv() => { // Forward permission request to UI let request_id = Uuid::new_v4(); @@ -356,6 +392,14 @@ async fn session_actor(session_id: String, mut command_rx: tokio_mpsc::Receiver< tracing::debug!("Query complete for session {}", session_id); // Don't disconnect - keep the connection alive for subsequent queries } + SessionCommand::Interrupt { ctx } => { + // Interrupt received when not in a query - just request repaint + tracing::debug!( + "Session {} received interrupt but no query active", + session_id + ); + ctx.request_repaint(); + } SessionCommand::Shutdown => { tracing::debug!("Session actor {} shutting down", session_id); break; @@ -450,6 +494,17 @@ impl AiBackend for ClaudeBackend { }); } } + + fn interrupt_session(&self, session_id: String, ctx: egui::Context) { + if let Some(handle) = self.sessions.get(&session_id) { + let command_tx = handle.command_tx.clone(); + tokio::spawn(async move { + if let Err(err) = command_tx.send(SessionCommand::Interrupt { ctx }).await { + tracing::warn!("Failed to send interrupt command: {}", err); + } + }); + } + } } /// Extract string content from a tool response, handling various JSON structures diff --git a/crates/notedeck_dave/src/backend/openai.rs b/crates/notedeck_dave/src/backend/openai.rs @@ -166,4 +166,9 @@ impl AiBackend for OpenAiBackend { // OpenAI backend doesn't maintain persistent connections per session // No cleanup needed } + + fn interrupt_session(&self, _session_id: String, _ctx: egui::Context) { + // OpenAI backend doesn't support interrupts - requests complete atomically + // The JoinHandle can be aborted from the session side if needed + } } diff --git a/crates/notedeck_dave/src/backend/traits.rs b/crates/notedeck_dave/src/backend/traits.rs @@ -33,4 +33,8 @@ pub trait AiBackend: Send + Sync { /// Clean up resources associated with a session. /// Called when a session is deleted to allow backends to shut down any persistent connections. fn cleanup_session(&self, session_id: String); + + /// Interrupt the current query for a session. + /// This stops any in-progress work but preserves the session history. + fn interrupt_session(&self, session_id: String, ctx: egui::Context); } diff --git a/crates/notedeck_dave/src/file_update.rs b/crates/notedeck_dave/src/file_update.rs @@ -11,7 +11,10 @@ pub struct FileUpdate { #[derive(Debug, Clone)] pub enum FileUpdateType { /// Edit: replace old_string with new_string - Edit { old_string: String, new_string: String }, + Edit { + old_string: String, + new_string: String, + }, /// Write: create/overwrite entire file Write { content: String }, } @@ -99,5 +102,4 @@ impl FileUpdate { } } } - } diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -352,6 +352,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr ui.heading(&session.title); ui.separator(); + let is_working = session.status() + == crate::agent_status::AgentStatus::Working; + // Render chat UI for selected session let response = DaveUi::new( self.model_config.trial, @@ -359,6 +362,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr &mut session.input, ) .compact(true) + .is_working(is_working) .ui(app_ctx, ui); if response.action.is_some() { @@ -440,7 +444,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let chat_response = ui .allocate_new_ui(egui::UiBuilder::new().max_rect(chat_rect), |ui| { if let Some(session) = self.session_manager.get_active_mut() { + let is_working = session.status() == crate::agent_status::AgentStatus::Working; DaveUi::new(self.model_config.trial, &session.chat, &mut session.input) + .is_working(is_working) .ui(app_ctx, ui) } else { DaveResponse::default() @@ -492,7 +498,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } else { // Show chat if let Some(session) = self.session_manager.get_active_mut() { + let is_working = session.status() == crate::agent_status::AgentStatus::Working; DaveUi::new(self.model_config.trial, &session.chat, &mut session.input) + .is_working(is_working) .ui(app_ctx, ui) } else { DaveResponse::default() @@ -513,6 +521,20 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } + /// Handle an interrupt action - stop the current AI operation + fn handle_interrupt(&mut self, ui: &egui::Ui) { + if let Some(session) = self.session_manager.get_active_mut() { + let session_id = format!("dave-session-{}", session.id); + // Send interrupt to backend + self.backend.interrupt_session(session_id, ui.ctx().clone()); + // Clear the incoming token receiver so we stop processing + session.incoming_tokens = None; + // Clear pending permissions since we're interrupting + session.pending_permissions.clear(); + tracing::debug!("Interrupted session {}", session.id); + } + } + /// Handle a user send action triggered by the ui fn handle_user_send(&mut self, app_ctx: &AppContext, ui: &egui::Ui) { if let Some(session) = self.session_manager.get_active_mut() { @@ -632,6 +654,9 @@ impl notedeck::App for Dave { } } } + DaveAction::Interrupt => { + self.handle_interrupt(ui); + } } } diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -1,3 +1,4 @@ +use super::diff; use crate::{ config::DaveSettings, file_update::FileUpdate, @@ -6,7 +7,6 @@ use crate::{ }, tools::{PresentNotesCall, QueryCall, ToolCall, ToolCalls, ToolResponse}, }; -use super::diff; use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers}; use nostrdb::{Ndb, Transaction}; use notedeck::{ @@ -21,6 +21,7 @@ pub struct DaveUi<'a> { trial: bool, input: &'a mut String, compact: bool, + is_working: bool, } /// The response the app generates. The response contains an optional @@ -78,6 +79,8 @@ pub enum DaveAction { request_id: Uuid, response: PermissionResponse, }, + /// User wants to interrupt/stop the current AI operation + Interrupt, } impl<'a> DaveUi<'a> { @@ -87,6 +90,7 @@ impl<'a> DaveUi<'a> { chat, input, compact: false, + is_working: false, } } @@ -95,6 +99,11 @@ impl<'a> DaveUi<'a> { self } + pub fn is_working(mut self, is_working: bool) -> Self { + self.is_working = is_working; + self + } + fn chat_margin(&self, ctx: &egui::Context) -> i8 { if self.compact || notedeck::ui::is_narrow(ctx) { 20 @@ -544,7 +553,20 @@ impl<'a> DaveUi<'a> { ui.horizontal(|ui| { ui.with_layout(Layout::right_to_left(Align::Max), |ui| { let mut dave_response = DaveResponse::none(); - if ui + + // Show Stop button when working, Ask button otherwise + if self.is_working { + if ui + .add(egui::Button::new(tr!( + i18n, + "Stop", + "Button to interrupt/stop the AI operation" + ))) + .clicked() + { + dave_response = DaveResponse::new(DaveAction::Interrupt); + } + } else if ui .add(egui::Button::new(tr!( i18n, "Ask", diff --git a/crates/notedeck_dave/src/ui/diff.rs b/crates/notedeck_dave/src/ui/diff.rs @@ -17,7 +17,7 @@ pub fn file_update_ui(update: &FileUpdate, ui: &mut Ui) { .corner_radius(4.0) .show(ui, |ui| { egui::ScrollArea::vertical() - .max_height(300.0) + .max_height(ui.available_height() * 0.8) .show(ui, |ui| { render_diff_lines(&diff_lines, &update.update_type, ui); }); @@ -54,8 +54,12 @@ fn render_diff_lines(lines: &[DiffLine], update_type: &FileUpdateType, ui: &mut // Render line numbers (only for edits, not writes) if matches!(update_type, FileUpdateType::Edit { .. }) { - let old_str = old_num.map(|n| format!("{:4}", n)).unwrap_or_else(|| " ".to_string()); - let new_str = new_num.map(|n| format!("{:4}", n)).unwrap_or_else(|| " ".to_string()); + let old_str = old_num + .map(|n| format!("{:4}", n)) + .unwrap_or_else(|| " ".to_string()); + let new_str = new_num + .map(|n| format!("{:4}", n)) + .unwrap_or_else(|| " ".to_string()); ui.label( RichText::new(format!("{} {}", old_str, new_str))