notedeck

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

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:
Mcrates/notedeck_dave/src/lib.rs | 32++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/dave.rs | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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