commit 73b215c83a3c92ab895075b71f29865eee53ad85
parent 7bae0945a7d0e1595c84afa45a2081ac876d98ce
Author: William Casarin <jb55@jb55.com>
Date: Fri, 30 Jan 2026 13:28:16 -0800
feat(dave): handle ExitPlanMode tool with custom UI
Add dedicated UI for ExitPlanMode tool calls that displays:
- PLAN badge with "Plan ready for approval" header
- The plan content extracted from tool_input.plan field
- Approve/Reject buttons (keybinds 1/2)
When approved, automatically exits plan mode (switches to Default
permission mode) before allowing the tool call.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat:
2 files changed, 135 insertions(+), 0 deletions(-)
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -772,6 +772,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
@@ -1572,6 +1583,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,99 @@ 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
+ 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 the plan");
+
+ if reject_response.clicked() {
+ 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 and exit plan mode");
+
+ if approve_response.clicked() {
+ action = Some(DaveAction::ExitPlanMode {
+ request_id: request.id,
+ approved: true,
+ });
+ }
+ });
+ });
+ });
+
+ 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