commit 6af1fbbf02bebc63205e28ae26ba42c1493bb395
parent 07efe78bb9ceb5741c156380dfa80d19ee74882e
Author: William Casarin <jb55@jb55.com>
Date: Sun, 25 Jan 2026 22:03:10 -0800
dave: improve permission request UX with response state tracking
- Add PermissionResponseType enum to track Allow/Denied state in messages
- Update UI to show different states: pending (with buttons) vs responded
- Make pending state more compact: show tool name with inline value or
description+command format instead of full JSON
- Buttons disappear after clicking, replaced by "Allowed/Denied" badge
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat:
4 files changed, 168 insertions(+), 70 deletions(-)
diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs
@@ -117,6 +117,7 @@ impl AiBackend for ClaudeBackend {
id: Uuid::new_v4(),
tool_name: tool_name.clone(),
tool_input: tool_input.clone(),
+ response: None,
};
let pending = PendingPermission {
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -21,7 +21,7 @@ use std::sync::Arc;
pub use avatar::DaveAvatar;
pub use config::{AiProvider, DaveSettings, ModelConfig};
-pub use messages::{DaveApiResponse, Message, PermissionResponse};
+pub use messages::{DaveApiResponse, Message, PermissionResponse, PermissionResponseType};
pub use quaternion::Quaternion;
pub use session::{ChatSession, SessionId, SessionManager};
pub use tools::{
@@ -433,6 +433,23 @@ impl notedeck::App for Dave {
} => {
// Send the permission response back to the callback
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::Deny { .. } => {
+ messages::PermissionResponseType::Denied
+ }
+ };
+
+ for msg in &mut session.chat {
+ if let Message::PermissionRequest(req) = msg {
+ if req.id == request_id {
+ req.response = Some(response_type);
+ break;
+ }
+ }
+ }
+
if let Some(sender) = session.pending_permissions.remove(&request_id) {
if sender.send(response).is_err() {
tracing::error!(
diff --git a/crates/notedeck_dave/src/messages.rs b/crates/notedeck_dave/src/messages.rs
@@ -13,6 +13,8 @@ pub struct PermissionRequest {
pub tool_name: String,
/// The arguments the tool will be called with
pub tool_input: serde_json::Value,
+ /// The user's response (None if still pending)
+ pub response: Option<PermissionResponseType>,
}
/// A permission request with the response channel (for channel communication)
@@ -32,6 +34,13 @@ pub enum PermissionResponse {
Deny { reason: String },
}
+/// The recorded response type for display purposes (without channel details)
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PermissionResponseType {
+ Allowed,
+ Denied,
+}
+
#[derive(Debug, Clone)]
pub enum Message {
System(String),
diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs
@@ -1,6 +1,6 @@
use crate::{
config::DaveSettings,
- messages::{Message, PermissionRequest, PermissionResponse},
+ messages::{Message, PermissionRequest, PermissionResponse, PermissionResponseType},
tools::{PresentNotesCall, QueryCall, ToolCall, ToolCalls, ToolResponse},
};
use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers};
@@ -196,91 +196,162 @@ impl<'a> DaveUi<'a> {
//ui.label(format!("tool_response: {:?}", tool_response));
}
- /// Render a permission request with Allow/Deny buttons
+ /// Render a permission request with Allow/Deny buttons or response state
fn permission_request_ui(request: &PermissionRequest, ui: &mut egui::Ui) -> Option<DaveAction> {
let mut action = None;
- egui::Frame::new()
- .fill(ui.visuals().widgets.noninteractive.bg_fill)
- .inner_margin(12.0)
- .corner_radius(8.0)
- .stroke(egui::Stroke::new(1.0, ui.visuals().warn_fg_color))
- .show(ui, |ui| {
- ui.vertical(|ui| {
- // Header
- ui.horizontal(|ui| {
- ui.label(egui::RichText::new("🔐").size(18.0));
- ui.label(
- egui::RichText::new(format!(
- "Claude wants to use: {}",
- request.tool_name
- ))
- .strong(),
- );
+ match request.response {
+ Some(PermissionResponseType::Allowed) => {
+ // Responded state: Allowed
+ egui::Frame::new()
+ .fill(ui.visuals().widgets.noninteractive.bg_fill)
+ .inner_margin(8.0)
+ .corner_radius(6.0)
+ .show(ui, |ui| {
+ ui.horizontal(|ui| {
+ ui.label(
+ egui::RichText::new("Allowed")
+ .color(egui::Color32::from_rgb(100, 180, 100))
+ .strong(),
+ );
+ ui.label(
+ egui::RichText::new(&request.tool_name)
+ .color(ui.visuals().text_color()),
+ );
+ });
});
+ }
+ Some(PermissionResponseType::Denied) => {
+ // Responded state: Denied
+ egui::Frame::new()
+ .fill(ui.visuals().widgets.noninteractive.bg_fill)
+ .inner_margin(8.0)
+ .corner_radius(6.0)
+ .show(ui, |ui| {
+ ui.horizontal(|ui| {
+ ui.label(
+ egui::RichText::new("Denied")
+ .color(egui::Color32::from_rgb(200, 100, 100))
+ .strong(),
+ );
+ ui.label(
+ egui::RichText::new(&request.tool_name)
+ .color(ui.visuals().text_color()),
+ );
+ });
+ });
+ }
+ None => {
+ // Parse tool input for display
+ let obj = request.tool_input.as_object();
+ let description = obj
+ .and_then(|o| o.get("description"))
+ .and_then(|v| v.as_str());
+ let command = obj.and_then(|o| o.get("command")).and_then(|v| v.as_str());
+ let single_value = obj
+ .filter(|o| o.len() == 1)
+ .and_then(|o| o.values().next())
+ .and_then(|v| v.as_str());
+
+ // Pending state: Show Allow/Deny buttons
+ egui::Frame::new()
+ .fill(ui.visuals().widgets.noninteractive.bg_fill)
+ .inner_margin(8.0)
+ .corner_radius(6.0)
+ .stroke(egui::Stroke::new(1.0, ui.visuals().warn_fg_color))
+ .show(ui, |ui| {
+ // Tool info display
+ if let Some(desc) = description {
+ // Format: ToolName: description
+ ui.horizontal(|ui| {
+ ui.label(
+ egui::RichText::new(format!("{}:", request.tool_name)).strong(),
+ );
+ ui.label(desc);
+
+ Self::permission_buttons(request, ui, &mut action);
+ });
+ // Command on next line if present
+ if let Some(cmd) = command {
+ ui.add(
+ egui::Label::new(egui::RichText::new(cmd).monospace())
+ .wrap_mode(egui::TextWrapMode::Wrap),
+ );
+ }
+ } else if let Some(value) = single_value {
+ // Format: ToolName `value`
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new(&request.tool_name).strong());
+ ui.label(egui::RichText::new(value).monospace());
+
+ Self::permission_buttons(request, ui, &mut action);
+ });
+ } else {
+ // Fallback: show JSON
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new(&request.tool_name).strong());
- ui.add_space(8.0);
-
- // Tool arguments in a code-like box
- egui::Frame::new()
- .fill(ui.visuals().extreme_bg_color)
- .inner_margin(8.0)
- .corner_radius(4.0)
- .show(ui, |ui| {
+ 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());
ui.add(
egui::Label::new(
- egui::RichText::new(formatted).monospace().size(12.0),
+ egui::RichText::new(formatted).monospace().size(11.0),
)
.wrap_mode(egui::TextWrapMode::Wrap),
);
- });
-
- ui.add_space(12.0);
-
- // Buttons
- ui.horizontal(|ui| {
- if ui
- .add(
- egui::Button::new(
- egui::RichText::new("Allow")
- .color(ui.visuals().widgets.active.fg_stroke.color),
- )
- .fill(egui::Color32::from_rgb(34, 139, 34)), // Forest green
- )
- .clicked()
- {
- action = Some(DaveAction::PermissionResponse {
- request_id: request.id,
- response: PermissionResponse::Allow,
- });
}
+ });
+ }
+ }
- ui.add_space(8.0);
+ action
+ }
- if ui
- .add(
- egui::Button::new(
- egui::RichText::new("Deny")
- .color(ui.visuals().widgets.active.fg_stroke.color),
- )
- .fill(egui::Color32::from_rgb(178, 34, 34)), // Firebrick red
- )
- .clicked()
- {
- action = Some(DaveAction::PermissionResponse {
- request_id: request.id,
- response: PermissionResponse::Deny {
- reason: "User denied".to_string(),
- },
- });
- }
- });
+ /// Render Allow/Deny buttons aligned to the right
+ fn permission_buttons(
+ request: &PermissionRequest,
+ ui: &mut egui::Ui,
+ action: &mut Option<DaveAction>,
+ ) {
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ // Deny button (red)
+ if ui
+ .add(
+ egui::Button::new(
+ egui::RichText::new("Deny")
+ .color(ui.visuals().widgets.active.fg_stroke.color),
+ )
+ .fill(egui::Color32::from_rgb(178, 34, 34)),
+ )
+ .clicked()
+ {
+ *action = Some(DaveAction::PermissionResponse {
+ request_id: request.id,
+ response: PermissionResponse::Deny {
+ reason: "User denied".into(),
+ },
});
- });
+ }
- action
+ // Allow button (green)
+ if ui
+ .add(
+ egui::Button::new(
+ egui::RichText::new("Allow")
+ .color(ui.visuals().widgets.active.fg_stroke.color),
+ )
+ .fill(egui::Color32::from_rgb(34, 139, 34)),
+ )
+ .clicked()
+ {
+ *action = Some(DaveAction::PermissionResponse {
+ request_id: request.id,
+ response: PermissionResponse::Allow,
+ });
+ }
+ });
}
fn search_call_ui(ctx: &mut AppContext, query_call: &QueryCall, ui: &mut egui::Ui) {