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:
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);
}