notedeck

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

commit d71ee50d203a7533f8182d10e7d76f39861a4c64
parent d04c945646c312e92d1c8f6575bc82bee2ddc2ba
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 27 Jan 2026 10:35:59 -0800

dave: add Shift+1/2 to approve/deny with custom message

Allow users to provide a custom response when approving or denying
tool permission requests:

- Shift+1 or Shift+click Yes: enter tentative accept mode
- Shift+2 or Shift+click No: enter tentative deny mode
- Type message in existing chat input, press Enter to send
- Press Escape to cancel tentative mode

Visual indicators show current state (Will Accept/Will Deny) and
a "+ message" hint appears when Shift is held.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_dave/src/backend/claude.rs | 10++++++++--
Mcrates/notedeck_dave/src/lib.rs | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/notedeck_dave/src/messages.rs | 6+++---
Mcrates/notedeck_dave/src/session.rs | 14++++++++++++++
Mcrates/notedeck_dave/src/ui/dave.rs | 117++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcrates/notedeck_dave/src/ui/keybindings.rs | 52+++++++++++++++++++++++++++++++++++++++-------------
6 files changed, 268 insertions(+), 51 deletions(-)

diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs @@ -303,8 +303,14 @@ async fn session_actor(session_id: String, mut command_rx: tokio_mpsc::Receiver< let callback_tx = perm_req.response_tx; tokio::spawn(async move { let result = match ui_resp_rx.await { - Ok(PermissionResponse::Allow) => { - tracing::debug!("User allowed tool: {}", tool_name); + Ok(PermissionResponse::Allow { message }) => { + if let Some(msg) = &message { + tracing::debug!("User allowed tool {} with message: {}", tool_name, msg); + } else { + tracing::debug!("User allowed tool: {}", tool_name); + } + // Note: message is handled in lib.rs by adding a User message to chat + // SDK's PermissionResultAllow doesn't have a message field PermissionResult::Allow(PermissionResultAllow::default()) } Ok(PermissionResponse::Deny { reason }) => { diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -387,6 +387,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr .interrupt_pending(interrupt_pending) .has_pending_permission(has_pending_permission) .plan_mode_active(plan_mode_active) + .permission_message_state(session.permission_message_state) .ui(app_ctx, ui); if response.action.is_some() { @@ -497,6 +498,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr .interrupt_pending(interrupt_pending) .has_pending_permission(has_pending_permission) .plan_mode_active(plan_mode_active) + .permission_message_state(session.permission_message_state) .ui(app_ctx, ui) } else { DaveResponse::default() @@ -565,6 +567,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr .interrupt_pending(interrupt_pending) .has_pending_permission(has_pending_permission) .plan_mode_active(plan_mode_active) + .permission_message_state(session.permission_message_state) .ui(app_ctx, ui) } else { DaveResponse::default() @@ -697,10 +700,21 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if let Some(session) = self.session_manager.get_active_mut() { // Record the response type in the message for UI display let response_type = match &response { - PermissionResponse::Allow => messages::PermissionResponseType::Allowed, + PermissionResponse::Allow { .. } => messages::PermissionResponseType::Allowed, PermissionResponse::Deny { .. } => messages::PermissionResponseType::Denied, }; + // If Allow has a message, add it as a User message to the chat + // (SDK doesn't support message field on Allow, so we inject it as context) + if let PermissionResponse::Allow { message: Some(msg) } = &response { + if !msg.is_empty() { + session.chat.push(Message::User(msg.clone())); + } + } + + // Clear permission message state + session.permission_message_state = crate::session::PermissionMessageState::None; + for msg in &mut session.chat { if let Message::PermissionRequest(req) = msg { if req.id == request_id { @@ -855,11 +869,19 @@ impl notedeck::App for Dave { // Handle global keybindings (when no text input has focus) let has_pending_permission = self.first_pending_permission().is_some(); - if let Some(key_action) = check_keybindings(ui.ctx(), has_pending_permission) { + let in_tentative_state = self + .session_manager + .get_active() + .map(|s| s.permission_message_state != crate::session::PermissionMessageState::None) + .unwrap_or(false); + if let Some(key_action) = check_keybindings(ui.ctx(), has_pending_permission, in_tentative_state) { match key_action { KeyAction::AcceptPermission => { if let Some(request_id) = self.first_pending_permission() { - self.handle_permission_response(request_id, PermissionResponse::Allow); + self.handle_permission_response( + request_id, + PermissionResponse::Allow { message: None }, + ); // Restore input focus after permission response if let Some(session) = self.session_manager.get_active_mut() { session.focus_requested = true; @@ -871,7 +893,7 @@ impl notedeck::App for Dave { self.handle_permission_response( request_id, PermissionResponse::Deny { - reason: "User denied via keyboard".into(), + reason: "User denied".into(), }, ); // Restore input focus after permission response @@ -880,6 +902,29 @@ impl notedeck::App for Dave { } } } + KeyAction::TentativeAccept => { + // Enter tentative accept mode - user will type message, then Enter to send + if let Some(session) = self.session_manager.get_active_mut() { + session.permission_message_state = + crate::session::PermissionMessageState::TentativeAccept; + session.focus_requested = true; + } + } + KeyAction::TentativeDeny => { + // Enter tentative deny mode - user will type message, then Enter to send + if let Some(session) = self.session_manager.get_active_mut() { + session.permission_message_state = + crate::session::PermissionMessageState::TentativeDeny; + session.focus_requested = true; + } + } + KeyAction::CancelTentative => { + // Cancel tentative mode + if let Some(session) = self.session_manager.get_active_mut() { + session.permission_message_state = + crate::session::PermissionMessageState::None; + } + } KeyAction::SwitchToAgent(index) => { self.switch_to_agent_by_index(index); } @@ -930,7 +975,56 @@ impl notedeck::App for Dave { self.handle_new_chat(); } DaveAction::Send => { - self.handle_user_send(ctx, ui); + // Check if we're in tentative state - if so, send permission response with message + let tentative_state = self + .session_manager + .get_active() + .map(|s| s.permission_message_state) + .unwrap_or(crate::session::PermissionMessageState::None); + + match tentative_state { + crate::session::PermissionMessageState::TentativeAccept => { + // Send permission Allow with the message from input + if let Some(request_id) = self.first_pending_permission() { + let message = self + .session_manager + .get_active() + .map(|s| s.input.clone()) + .filter(|m| !m.is_empty()); + // Clear input + if let Some(session) = self.session_manager.get_active_mut() { + session.input.clear(); + } + self.handle_permission_response( + request_id, + PermissionResponse::Allow { message }, + ); + } + } + crate::session::PermissionMessageState::TentativeDeny => { + // Send permission Deny with the message from input + if let Some(request_id) = self.first_pending_permission() { + let reason = self + .session_manager + .get_active() + .map(|s| s.input.clone()) + .filter(|m| !m.is_empty()) + .unwrap_or_else(|| "User denied".into()); + // Clear input + if let Some(session) = self.session_manager.get_active_mut() { + session.input.clear(); + } + self.handle_permission_response( + request_id, + PermissionResponse::Deny { reason }, + ); + } + } + crate::session::PermissionMessageState::None => { + // Normal send behavior + self.handle_user_send(ctx, ui); + } + } } DaveAction::ShowSessionList => { self.show_session_list = !self.show_session_list; @@ -950,6 +1044,22 @@ impl notedeck::App for Dave { DaveAction::Interrupt => { self.handle_interrupt(ui); } + DaveAction::TentativeAccept => { + // Enter tentative accept mode (from Shift+click) + if let Some(session) = self.session_manager.get_active_mut() { + session.permission_message_state = + crate::session::PermissionMessageState::TentativeAccept; + session.focus_requested = true; + } + } + DaveAction::TentativeDeny => { + // Enter tentative deny mode (from Shift+click) + if let Some(session) = self.session_manager.get_active_mut() { + session.permission_message_state = + crate::session::PermissionMessageState::TentativeDeny; + session.focus_requested = true; + } + } } } diff --git a/crates/notedeck_dave/src/messages.rs b/crates/notedeck_dave/src/messages.rs @@ -28,9 +28,9 @@ pub struct PendingPermission { /// The user's response to a permission request #[derive(Debug, Clone)] pub enum PermissionResponse { - /// Allow the tool to execute - Allow, - /// Deny the tool execution with an optional reason + /// Allow the tool to execute, with an optional message for the AI + Allow { message: Option<String> }, + /// Deny the tool execution with a reason Deny { reason: String }, } diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -10,6 +10,17 @@ use uuid::Uuid; pub type SessionId = u32; +/// State for permission response with message +#[derive(Default, Clone, Copy, PartialEq)] +pub enum PermissionMessageState { + #[default] + None, + /// User pressed Shift+1, waiting for message then will Allow + TentativeAccept, + /// User pressed Shift+2, waiting for message then will Deny + TentativeDeny, +} + /// A single chat session with Dave pub struct ChatSession { pub id: SessionId, @@ -30,6 +41,8 @@ pub struct ChatSession { pub focus_requested: bool, /// Permission mode for Claude (Default or Plan) pub permission_mode: PermissionMode, + /// State for permission response message (tentative accept/deny) + pub permission_message_state: PermissionMessageState, } impl Drop for ChatSession { @@ -60,6 +73,7 @@ impl ChatSession { cached_status: AgentStatus::Idle, focus_requested: false, permission_mode: PermissionMode::Default, + permission_message_state: PermissionMessageState::None, } } diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -5,6 +5,7 @@ use crate::{ messages::{ Message, PermissionRequest, PermissionResponse, PermissionResponseType, ToolResult, }, + session::PermissionMessageState, tools::{PresentNotesCall, QueryCall, ToolCall, ToolCalls, ToolResponse}, }; use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers}; @@ -26,6 +27,8 @@ pub struct DaveUi<'a> { has_pending_permission: bool, focus_requested: &'a mut bool, plan_mode_active: bool, + /// State for tentative permission response (waiting for message) + permission_message_state: PermissionMessageState, } /// The response the app generates. The response contains an optional @@ -85,6 +88,10 @@ pub enum DaveAction { }, /// User wants to interrupt/stop the current AI operation Interrupt, + /// Enter tentative accept mode (Shift+click on Yes) + TentativeAccept, + /// Enter tentative deny mode (Shift+click on No) + TentativeDeny, } impl<'a> DaveUi<'a> { @@ -104,9 +111,15 @@ impl<'a> DaveUi<'a> { has_pending_permission: false, focus_requested, plan_mode_active: false, + permission_message_state: PermissionMessageState::None, } } + pub fn permission_message_state(mut self, state: PermissionMessageState) -> Self { + self.permission_message_state = state; + self + } + pub fn compact(mut self, compact: bool) -> Self { self.compact = compact; self @@ -215,7 +228,7 @@ impl<'a> DaveUi<'a> { } /// Render a chat message (user, assistant, tool call/response, etc) - fn render_chat(&self, ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { + fn render_chat(&mut self, ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { let mut response = DaveResponse::default(); for message in self.chat { match message { @@ -241,7 +254,7 @@ impl<'a> DaveUi<'a> { } } Message::PermissionRequest(request) => { - if let Some(action) = Self::permission_request_ui(request, ui) { + if let Some(action) = self.permission_request_ui(request, ui) { response = DaveResponse::new(action); } } @@ -259,7 +272,11 @@ impl<'a> DaveUi<'a> { } /// Render a permission request with Allow/Deny buttons or response state - fn permission_request_ui(request: &PermissionRequest, ui: &mut egui::Ui) -> Option<DaveAction> { + fn permission_request_ui( + &mut self, + request: &PermissionRequest, + ui: &mut egui::Ui, + ) -> Option<DaveAction> { let mut action = None; let inner_margin = 8.0; @@ -324,7 +341,7 @@ impl<'a> DaveUi<'a> { // Header with file path and buttons ui.horizontal(|ui| { diff::file_path_header(&file_update, ui); - Self::permission_buttons(request, ui, &mut action); + self.permission_buttons(request, ui, &mut action); }); // Diff view @@ -356,7 +373,7 @@ impl<'a> DaveUi<'a> { ui.label(egui::RichText::new(&request.tool_name).strong()); ui.label(desc); - Self::permission_buttons(request, ui, &mut action); + self.permission_buttons(request, ui, &mut action); }); // Command on next line if present if let Some(cmd) = command { @@ -371,14 +388,14 @@ impl<'a> DaveUi<'a> { ui.label(egui::RichText::new(&request.tool_name).strong()); ui.label(egui::RichText::new(value).monospace()); - Self::permission_buttons(request, ui, &mut action); + self.permission_buttons(request, ui, &mut action); }); } else { // Fallback: show JSON ui.horizontal(|ui| { ui.label(egui::RichText::new(&request.tool_name).strong()); - Self::permission_buttons(request, ui, &mut action); + self.permission_buttons(request, ui, &mut action); }); let formatted = serde_json::to_string_pretty(&request.tool_input) .unwrap_or_else(|_| request.tool_input.to_string()); @@ -399,13 +416,16 @@ impl<'a> DaveUi<'a> { /// Render Allow/Deny buttons aligned to the right with keybinding hints fn permission_buttons( + &self, request: &PermissionRequest, ui: &mut egui::Ui, action: &mut Option<DaveAction>, ) { + let shift_held = ui.input(|i| i.modifiers.shift); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // Deny button (red) with [2] hint - if ui + let deny_response = ui .add( egui::Button::new( egui::RichText::new("[2] No") @@ -413,19 +433,25 @@ impl<'a> DaveUi<'a> { ) .fill(egui::Color32::from_rgb(178, 34, 34)), ) - .on_hover_text("Press 2 to deny") - .clicked() - { - *action = Some(DaveAction::PermissionResponse { - request_id: request.id, - response: PermissionResponse::Deny { - reason: "User denied".into(), - }, - }); + .on_hover_text("Press 2 to deny, Shift+2 to deny with message"); + + if deny_response.clicked() { + if shift_held { + // Shift+click: enter tentative deny mode + *action = Some(DaveAction::TentativeDeny); + } else { + // Normal click: immediate deny + *action = Some(DaveAction::PermissionResponse { + request_id: request.id, + response: PermissionResponse::Deny { + reason: "User denied".into(), + }, + }); + } } // Allow button (green) with [1] hint - if ui + let allow_response = ui .add( egui::Button::new( egui::RichText::new("[1] Yes") @@ -433,13 +459,47 @@ impl<'a> DaveUi<'a> { ) .fill(egui::Color32::from_rgb(34, 139, 34)), ) - .on_hover_text("Press 1 to allow") - .clicked() - { - *action = Some(DaveAction::PermissionResponse { - request_id: request.id, - response: PermissionResponse::Allow, - }); + .on_hover_text("Press 1 to allow, Shift+1 to allow with message"); + + if allow_response.clicked() { + if shift_held { + // Shift+click: enter tentative accept mode + *action = Some(DaveAction::TentativeAccept); + } else { + // Normal click: immediate allow + *action = Some(DaveAction::PermissionResponse { + request_id: request.id, + response: PermissionResponse::Allow { message: None }, + }); + } + } + + // Show tentative state indicator OR shift hint + match self.permission_message_state { + PermissionMessageState::TentativeAccept => { + ui.label( + egui::RichText::new("✓ Will Accept") + .color(egui::Color32::from_rgb(100, 180, 100)) + .strong(), + ); + } + PermissionMessageState::TentativeDeny => { + ui.label( + egui::RichText::new("✗ Will Deny") + .color(egui::Color32::from_rgb(200, 100, 100)) + .strong(), + ); + } + PermissionMessageState::None => { + // Show hint when Shift is held + if shift_held { + ui.label( + egui::RichText::new("+ message") + .color(ui.visuals().warn_fg_color) + .italics(), + ); + } + } } }); } @@ -657,15 +717,16 @@ impl<'a> DaveUi<'a> { super::paint_keybind_hint(ui, hint_pos, "P", 18.0); } - // Request focus if flagged (e.g., after spawning a new agent) + // Request focus if flagged (e.g., after spawning a new agent or entering tentative state) if *self.focus_requested { r.request_focus(); *self.focus_requested = false; } // Unfocus text input when there's a pending permission request - // so keyboard shortcuts (Y/A/N/D) can be used to respond - if self.has_pending_permission { + // UNLESS we're in tentative state (user needs to type message) + let in_tentative_state = self.permission_message_state != PermissionMessageState::None; + if self.has_pending_permission && !in_tentative_state { r.surrender_focus(); } diff --git a/crates/notedeck_dave/src/ui/keybindings.rs b/crates/notedeck_dave/src/ui/keybindings.rs @@ -7,6 +7,12 @@ pub enum KeyAction { AcceptPermission, /// Deny a pending permission request DenyPermission, + /// Tentatively accept, waiting for message (Shift+1) + TentativeAccept, + /// Tentatively deny, waiting for message (Shift+2) + TentativeDeny, + /// Cancel tentative state (Escape when tentative) + CancelTentative, /// Switch to agent by number (0-indexed) SwitchToAgent(usize), /// Cycle to next agent @@ -26,8 +32,17 @@ pub enum KeyAction { /// Check for keybinding actions. /// Most keybindings use Ctrl modifier to avoid conflicts with text input. /// Exception: 1/2 for permission responses work without Ctrl since input is unfocused. -pub fn check_keybindings(ctx: &egui::Context, has_pending_permission: bool) -> Option<KeyAction> { - // Escape works even when text input has focus (to interrupt AI) +pub fn check_keybindings( + ctx: &egui::Context, + has_pending_permission: bool, + in_tentative_state: bool, +) -> Option<KeyAction> { + // Escape in tentative state cancels the tentative mode + if in_tentative_state && ctx.input(|i| i.key_pressed(Key::Escape)) { + return Some(KeyAction::CancelTentative); + } + + // Escape otherwise works to interrupt AI (even when text input has focus) if ctx.input(|i| i.key_pressed(Key::Escape)) { return Some(KeyAction::Interrupt); } @@ -95,22 +110,33 @@ pub fn check_keybindings(ctx: &egui::Context, has_pending_permission: bool) -> O return Some(action); } - // When there's a pending permission, 1 = accept, 2 = deny (no Ctrl needed) - // Input is unfocused when permission is pending, so bare keys work + // When there's a pending permission: + // - 1 = accept, 2 = deny (no modifiers) + // - Shift+1 = tentative accept, Shift+2 = tentative deny (for adding message) // This is checked AFTER Ctrl+number so Ctrl bindings take precedence if has_pending_permission { + let shift = egui::Modifiers::SHIFT; + if let Some(action) = ctx.input(|i| { - // Only trigger on bare keypresses (no modifiers) - if i.modifiers.any() { - return None; + // Shift+1 = tentative accept, Shift+2 = tentative deny + if i.modifiers.matches_exact(shift) { + if i.key_pressed(Key::Num1) { + return Some(KeyAction::TentativeAccept); + } else if i.key_pressed(Key::Num2) { + return Some(KeyAction::TentativeDeny); + } } - if i.key_pressed(Key::Num1) { - Some(KeyAction::AcceptPermission) - } else if i.key_pressed(Key::Num2) { - Some(KeyAction::DenyPermission) - } else { - None + + // Bare keypresses (no modifiers) for immediate accept/deny + if !i.modifiers.any() { + if i.key_pressed(Key::Num1) { + return Some(KeyAction::AcceptPermission); + } else if i.key_pressed(Key::Num2) { + return Some(KeyAction::DenyPermission); + } } + + None }) { return Some(action); }