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