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