notedeck

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

commit 380a14614b6f6a34d978b40a88a41da7cbd913df
parent 1a04d2bf54dd6face4d86159b050d53db08d0684
Author: William Casarin <jb55@jb55.com>
Date:   Sat, 31 Jan 2026 16:28:39 -0800

Merge branch 'rts2' into rts3

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 62+++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/notedeck_dave/src/ui/dave.rs | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 194 insertions(+), 11 deletions(-)

diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -935,6 +935,17 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } + /// Exit plan mode for the active session (switch to Default mode) + fn exit_plan_mode(&mut self, ctx: &egui::Context) { + if let Some(session) = self.session_manager.get_active_mut() { + session.permission_mode = PermissionMode::Default; + let session_id = format!("dave-session-{}", session.id); + self.backend + .set_permission_mode(session_id, PermissionMode::Default, ctx.clone()); + tracing::debug!("Exited plan mode for session {}", session.id); + } + } + /// Get the first pending permission request ID for the active session fn first_pending_permission(&self) -> Option<uuid::Uuid> { self.session_manager @@ -944,25 +955,28 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr /// Check if the first pending permission is an AskUserQuestion tool call fn has_pending_question(&self) -> bool { - let Some(session) = self.session_manager.get_active() else { - return false; - }; + self.pending_permission_tool_name() == Some("AskUserQuestion") + } - // Get the first pending permission request ID - let Some(request_id) = session.pending_permissions.keys().next() else { - return false; - }; + /// Check if the first pending permission is an ExitPlanMode tool call + fn has_pending_exit_plan_mode(&self) -> bool { + self.pending_permission_tool_name() == Some("ExitPlanMode") + } + + /// Get the tool name of the first pending permission request + fn pending_permission_tool_name(&self) -> Option<&str> { + let session = self.session_manager.get_active()?; + let request_id = session.pending_permissions.keys().next()?; - // Find the corresponding PermissionRequest in chat to check tool_name for msg in &session.chat { if let Message::PermissionRequest(req) = msg { - if &req.id == request_id && req.tool_name == "AskUserQuestion" { - return true; + if &req.id == request_id { + return Some(&req.tool_name); } } } - false + None } /// Handle a permission response (from UI button or keybinding) @@ -1666,6 +1680,8 @@ impl notedeck::App for Dave { match tentative_state { crate::session::PermissionMessageState::TentativeAccept => { // Send permission Allow with the message from input + // If this is ExitPlanMode, also exit plan mode + let is_exit_plan_mode = self.has_pending_exit_plan_mode(); if let Some(request_id) = self.first_pending_permission() { let message = self .session_manager @@ -1676,6 +1692,9 @@ impl notedeck::App for Dave { if let Some(session) = self.session_manager.get_active_mut() { session.input.clear(); } + if is_exit_plan_mode { + self.exit_plan_mode(ui.ctx()); + } self.handle_permission_response( request_id, PermissionResponse::Allow { message }, @@ -1747,6 +1766,27 @@ impl notedeck::App for Dave { } => { self.handle_question_response(request_id, answers); } + DaveAction::ExitPlanMode { + request_id, + approved, + } => { + if approved { + // Exit plan mode and allow the tool call + self.exit_plan_mode(ui.ctx()); + self.handle_permission_response( + request_id, + PermissionResponse::Allow { message: None }, + ); + } else { + // Deny the tool call + self.handle_permission_response( + request_id, + PermissionResponse::Deny { + reason: "User rejected plan".into(), + }, + ); + } + } } } diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -108,6 +108,11 @@ pub enum DaveAction { request_id: Uuid, answers: Vec<QuestionAnswer>, }, + /// User approved or rejected an ExitPlanMode request + ExitPlanMode { + request_id: Uuid, + approved: bool, + }, } impl<'a> DaveUi<'a> { @@ -404,6 +409,11 @@ impl<'a> DaveUi<'a> { }); } None => { + // Check if this is an ExitPlanMode tool call + if request.tool_name == "ExitPlanMode" { + return self.exit_plan_mode_ui(request, ui); + } + // Check if this is an AskUserQuestion tool call if request.tool_name == "AskUserQuestion" { if let Ok(questions) = @@ -608,6 +618,139 @@ impl<'a> DaveUi<'a> { }); } + /// Render ExitPlanMode tool call with Approve/Reject buttons + fn exit_plan_mode_ui( + &self, + request: &PermissionRequest, + ui: &mut egui::Ui, + ) -> Option<DaveAction> { + let mut action = None; + let inner_margin = 12.0; + let corner_radius = 8.0; + + // The plan content is in tool_input.plan field + let plan_content = request + .tool_input + .get("plan") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + egui::Frame::new() + .fill(ui.visuals().widgets.noninteractive.bg_fill) + .inner_margin(inner_margin) + .corner_radius(corner_radius) + .stroke(egui::Stroke::new(1.0, ui.visuals().selection.stroke.color)) + .show(ui, |ui| { + ui.vertical(|ui| { + // Header with badge + ui.horizontal(|ui| { + super::badge::StatusBadge::new("PLAN") + .variant(super::badge::BadgeVariant::Info) + .show(ui); + ui.add_space(8.0); + ui.label(egui::RichText::new("Plan ready for approval").strong()); + }); + + ui.add_space(8.0); + + // Display the plan content as plain text (TODO: markdown rendering) + ui.add( + egui::Label::new( + egui::RichText::new(&plan_content) + .monospace() + .size(11.0) + .color(ui.visuals().text_color()), + ) + .wrap_mode(egui::TextWrapMode::Wrap), + ); + + ui.add_space(8.0); + + // Approve/Reject buttons with shift support for adding message + let shift_held = ui.input(|i| i.modifiers.shift); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let button_text_color = ui.visuals().widgets.active.fg_stroke.color; + + // Reject button (red) + let reject_response = super::badge::ActionButton::new( + "Reject", + egui::Color32::from_rgb(178, 34, 34), + button_text_color, + ) + .keybind("2") + .show(ui) + .on_hover_text("Press 2 to reject, Shift+2 to reject with message"); + + if reject_response.clicked() { + if shift_held { + action = Some(DaveAction::TentativeDeny); + } else { + action = Some(DaveAction::ExitPlanMode { + request_id: request.id, + approved: false, + }); + } + } + + // Approve button (green) + let approve_response = super::badge::ActionButton::new( + "Approve", + egui::Color32::from_rgb(34, 139, 34), + button_text_color, + ) + .keybind("1") + .show(ui) + .on_hover_text("Press 1 to approve, Shift+1 to approve with message"); + + if approve_response.clicked() { + if shift_held { + action = Some(DaveAction::TentativeAccept); + } else { + action = Some(DaveAction::ExitPlanMode { + request_id: request.id, + approved: true, + }); + } + } + + // Show tentative state indicator OR shift hint + match self.permission_message_state { + PermissionMessageState::TentativeAccept => { + ui.label( + egui::RichText::new("✓ Will Approve") + .color(egui::Color32::from_rgb(100, 180, 100)) + .strong(), + ); + } + PermissionMessageState::TentativeDeny => { + ui.label( + egui::RichText::new("✗ Will Reject") + .color(egui::Color32::from_rgb(200, 100, 100)) + .strong(), + ); + } + PermissionMessageState::None => { + let hint_color = if shift_held { + ui.visuals().warn_fg_color + } else { + ui.visuals().weak_text_color() + }; + ui.label( + egui::RichText::new("(⇧ for message)") + .color(hint_color) + .small(), + ); + } + } + }); + }); + }); + + action + } + /// Render tool result metadata as a compact line fn tool_result_ui(result: &ToolResult, ui: &mut egui::Ui) { // Compact single-line display with subdued styling