commit f732a0f6f0b72fa47c3dfbab80e1b62b814ef21a
parent dbb427c946cc7c431866a2558ac39e6314fa22b7
Author: William Casarin <jb55@jb55.com>
Date: Tue, 27 Jan 2026 13:58:11 -0800
dave: implement AskUserQuestion tool support
Add support for Claude Code's AskUserQuestion tool, which allows the AI
to ask the user multiple-choice questions during a conversation.
- Add QuestionOption, UserQuestion, AskUserQuestionInput structs for
parsing the tool input from Claude
- Add QuestionAnswer struct for tracking user selections
- Create new ask_question.rs UI module with radio buttons/checkboxes
and "Other" text input support
- Extend DaveUi with question_answers state and builder method
- Add QuestionResponse variant to DaveAction
- Implement handle_question_response() to format answers as JSON and
send through the permission channel
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat:
6 files changed, 338 insertions(+), 3 deletions(-)
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -28,7 +28,8 @@ use std::time::Instant;
pub use avatar::DaveAvatar;
pub use config::{AiProvider, DaveSettings, ModelConfig};
pub use messages::{
- DaveApiResponse, Message, PermissionResponse, PermissionResponseType, ToolResult,
+ AskUserQuestionInput, DaveApiResponse, Message, PermissionResponse, PermissionResponseType,
+ QuestionAnswer, ToolResult,
};
pub use quaternion::Quaternion;
pub use session::{ChatSession, SessionId, SessionManager};
@@ -393,6 +394,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
.has_pending_permission(has_pending_permission)
.plan_mode_active(plan_mode_active)
.permission_message_state(session.permission_message_state)
+ .question_answers(&mut session.question_answers)
.ui(app_ctx, ui);
if response.action.is_some() {
@@ -505,6 +507,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
.has_pending_permission(has_pending_permission)
.plan_mode_active(plan_mode_active)
.permission_message_state(session.permission_message_state)
+ .question_answers(&mut session.question_answers)
.ui(app_ctx, ui)
} else {
DaveResponse::default()
@@ -574,6 +577,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
.has_pending_permission(has_pending_permission)
.plan_mode_active(plan_mode_active)
.permission_message_state(session.permission_message_state)
+ .question_answers(&mut session.question_answers)
.ui(app_ctx, ui)
} else {
DaveResponse::default()
@@ -745,6 +749,100 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
}
}
+ /// Handle a user's response to an AskUserQuestion tool call
+ fn handle_question_response(&mut self, request_id: uuid::Uuid, answers: Vec<QuestionAnswer>) {
+ if let Some(session) = self.session_manager.get_active_mut() {
+ // Find the original AskUserQuestion request to get the question labels
+ let questions_input = session.chat.iter().find_map(|msg| {
+ if let Message::PermissionRequest(req) = msg {
+ if req.id == request_id && req.tool_name == "AskUserQuestion" {
+ serde_json::from_value::<AskUserQuestionInput>(req.tool_input.clone()).ok()
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ });
+
+ // Format answers as JSON for the tool response
+ let formatted_response = if let Some(questions) = questions_input {
+ let mut answers_obj = serde_json::Map::new();
+ for (q_idx, (question, answer)) in
+ questions.questions.iter().zip(answers.iter()).enumerate()
+ {
+ let mut answer_obj = serde_json::Map::new();
+
+ // Map selected indices to option labels
+ let selected_labels: Vec<String> = answer
+ .selected
+ .iter()
+ .filter_map(|&idx| question.options.get(idx).map(|o| o.label.clone()))
+ .collect();
+
+ answer_obj.insert(
+ "selected".to_string(),
+ serde_json::Value::Array(
+ selected_labels
+ .into_iter()
+ .map(serde_json::Value::String)
+ .collect(),
+ ),
+ );
+
+ if let Some(ref other) = answer.other_text {
+ if !other.is_empty() {
+ answer_obj
+ .insert("other".to_string(), serde_json::Value::String(other.clone()));
+ }
+ }
+
+ // Use header as the key, fall back to question index
+ let key = if !question.header.is_empty() {
+ question.header.clone()
+ } else {
+ format!("question_{}", q_idx)
+ };
+ answers_obj.insert(key, serde_json::Value::Object(answer_obj));
+ }
+
+ serde_json::json!({ "answers": answers_obj }).to_string()
+ } else {
+ // Fallback: just serialize the answers directly
+ serde_json::to_string(&answers).unwrap_or_else(|_| "{}".to_string())
+ };
+
+ // Mark the request as allowed in the UI
+ for msg in &mut session.chat {
+ if let Message::PermissionRequest(req) = msg {
+ if req.id == request_id {
+ req.response = Some(messages::PermissionResponseType::Allowed);
+ break;
+ }
+ }
+ }
+
+ // Clean up answer state
+ session.question_answers.remove(&request_id);
+
+ // Send the response through the permission channel
+ // AskUserQuestion responses are sent as Allow with the formatted answers as the message
+ if let Some(sender) = session.pending_permissions.remove(&request_id) {
+ let response = PermissionResponse::Allow {
+ message: Some(formatted_response),
+ };
+ if sender.send(response).is_err() {
+ tracing::error!(
+ "Failed to send question response for request {}",
+ request_id
+ );
+ }
+ } else {
+ tracing::warn!("No pending permission found for request {}", request_id);
+ }
+ }
+ }
+
/// Switch to agent by index in the ordered list (0-indexed)
fn switch_to_agent_by_index(&mut self, index: usize) {
let ids = self.session_manager.session_ids();
@@ -1132,6 +1230,12 @@ impl notedeck::App for Dave {
session.focus_requested = true;
}
}
+ DaveAction::QuestionResponse {
+ request_id,
+ answers,
+ } => {
+ self.handle_question_response(request_id, answers);
+ }
}
}
diff --git a/crates/notedeck_dave/src/messages.rs b/crates/notedeck_dave/src/messages.rs
@@ -1,9 +1,43 @@
use crate::tools::{ToolCall, ToolResponse};
use async_openai::types::*;
use nostrdb::{Ndb, Transaction};
+use serde::{Deserialize, Serialize};
use tokio::sync::oneshot;
use uuid::Uuid;
+/// A question option from AskUserQuestion
+#[derive(Debug, Clone, Deserialize)]
+pub struct QuestionOption {
+ pub label: String,
+ pub description: String,
+}
+
+/// A single question from AskUserQuestion
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct UserQuestion {
+ pub question: String,
+ pub header: String,
+ #[serde(default)]
+ pub multi_select: bool,
+ pub options: Vec<QuestionOption>,
+}
+
+/// Parsed AskUserQuestion tool input
+#[derive(Debug, Clone, Deserialize)]
+pub struct AskUserQuestionInput {
+ pub questions: Vec<UserQuestion>,
+}
+
+/// User's answer to a question
+#[derive(Debug, Clone, Default, Serialize)]
+pub struct QuestionAnswer {
+ /// Selected option indices
+ pub selected: Vec<usize>,
+ /// Custom "Other" text if provided
+ pub other_text: Option<String>,
+}
+
/// A request for user permission to use a tool (displayable data only)
#[derive(Debug, Clone)]
pub struct PermissionRequest {
diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs
@@ -2,7 +2,7 @@ use std::collections::HashMap;
use std::sync::mpsc::Receiver;
use crate::agent_status::AgentStatus;
-use crate::messages::PermissionResponse;
+use crate::messages::{PermissionResponse, QuestionAnswer};
use crate::{DaveApiResponse, Message};
use claude_agent_sdk_rs::PermissionMode;
use tokio::sync::oneshot;
@@ -43,6 +43,8 @@ pub struct ChatSession {
pub permission_mode: PermissionMode,
/// State for permission response message (tentative accept/deny)
pub permission_message_state: PermissionMessageState,
+ /// State for pending AskUserQuestion responses (keyed by request UUID)
+ pub question_answers: HashMap<Uuid, Vec<QuestionAnswer>>,
}
impl Drop for ChatSession {
@@ -74,6 +76,7 @@ impl ChatSession {
focus_requested: false,
permission_mode: PermissionMode::Default,
permission_message_state: PermissionMessageState::None,
+ question_answers: HashMap::new(),
}
}
diff --git a/crates/notedeck_dave/src/ui/ask_question.rs b/crates/notedeck_dave/src/ui/ask_question.rs
@@ -0,0 +1,158 @@
+//! UI for rendering AskUserQuestion tool calls from Claude Code
+
+use crate::messages::{AskUserQuestionInput, PermissionRequest, QuestionAnswer};
+use std::collections::HashMap;
+use uuid::Uuid;
+
+use super::badge;
+use super::DaveAction;
+
+/// Render an AskUserQuestion tool call with selectable options
+///
+/// Returns a `DaveAction::QuestionResponse` when the user submits their answers.
+pub fn ask_user_question_ui(
+ request: &PermissionRequest,
+ questions: &AskUserQuestionInput,
+ answers_map: &mut HashMap<Uuid, Vec<QuestionAnswer>>,
+ ui: &mut egui::Ui,
+) -> Option<DaveAction> {
+ let mut action = None;
+ let inner_margin = 12.0;
+ let corner_radius = 8.0;
+
+ // Get or initialize answer state for this request
+ let num_questions = questions.questions.len();
+ let answers = answers_map
+ .entry(request.id)
+ .or_insert_with(|| vec![QuestionAnswer::default(); num_questions]);
+
+ 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| {
+ for (q_idx, question) in questions.questions.iter().enumerate() {
+ // Ensure we have an answer entry for this question
+ if q_idx >= answers.len() {
+ answers.push(QuestionAnswer::default());
+ }
+
+ ui.add_space(4.0);
+
+ // Header badge and question text
+ ui.horizontal(|ui| {
+ badge::StatusBadge::new(&question.header)
+ .variant(badge::BadgeVariant::Info)
+ .show(ui);
+ ui.add_space(8.0);
+ ui.label(egui::RichText::new(&question.question).strong());
+ });
+
+ ui.add_space(8.0);
+
+ // Options
+ for (opt_idx, option) in question.options.iter().enumerate() {
+ let is_selected = answers[q_idx].selected.contains(&opt_idx);
+ let other_is_selected = answers[q_idx].other_text.is_some();
+
+ ui.horizontal(|ui| {
+ if question.multi_select {
+ // Checkbox for multi-select
+ let mut checked = is_selected;
+ if ui.checkbox(&mut checked, "").changed() {
+ if checked {
+ answers[q_idx].selected.push(opt_idx);
+ } else {
+ answers[q_idx].selected.retain(|&i| i != opt_idx);
+ }
+ }
+ } else {
+ // Radio button for single-select
+ let selected = is_selected && !other_is_selected;
+ if ui.radio(selected, "").clicked() {
+ answers[q_idx].selected = vec![opt_idx];
+ answers[q_idx].other_text = None;
+ }
+ }
+
+ ui.vertical(|ui| {
+ ui.label(egui::RichText::new(&option.label));
+ ui.label(
+ egui::RichText::new(&option.description)
+ .weak()
+ .size(11.0),
+ );
+ });
+ });
+
+ ui.add_space(4.0);
+ }
+
+ // "Other" option
+ ui.horizontal(|ui| {
+ let other_selected = answers[q_idx].other_text.is_some();
+ if question.multi_select {
+ let mut checked = other_selected;
+ if ui.checkbox(&mut checked, "").changed() {
+ if checked {
+ answers[q_idx].other_text = Some(String::new());
+ } else {
+ answers[q_idx].other_text = None;
+ }
+ }
+ } else if ui.radio(other_selected, "").clicked() {
+ answers[q_idx].selected.clear();
+ answers[q_idx].other_text = Some(String::new());
+ }
+
+ ui.label("Other:");
+
+ // Text input for "Other"
+ if let Some(ref mut text) = answers[q_idx].other_text {
+ ui.add(
+ egui::TextEdit::singleline(text)
+ .desired_width(200.0)
+ .hint_text("Type your answer..."),
+ );
+ }
+ });
+
+ ui.add_space(8.0);
+ if q_idx < questions.questions.len() - 1 {
+ ui.separator();
+ }
+ }
+
+ // Submit button
+ ui.add_space(8.0);
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ let button_text_color = ui.visuals().widgets.active.fg_stroke.color;
+ let submit_response = badge::ActionButton::new(
+ "Submit",
+ egui::Color32::from_rgb(34, 139, 34),
+ button_text_color,
+ )
+ .keybind("Enter")
+ .show(ui);
+
+ if submit_response.clicked()
+ || ui.input(|i| {
+ i.key_pressed(egui::Key::Enter) && !i.modifiers.shift
+ })
+ {
+ action = Some(DaveAction::QuestionResponse {
+ request_id: request.id,
+ answers: answers.clone(),
+ });
+ }
+ });
+ });
+ });
+
+ action
+}
diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs
@@ -3,7 +3,8 @@ use crate::{
config::DaveSettings,
file_update::FileUpdate,
messages::{
- Message, PermissionRequest, PermissionResponse, PermissionResponseType, ToolResult,
+ AskUserQuestionInput, Message, PermissionRequest, PermissionResponse,
+ PermissionResponseType, QuestionAnswer, ToolResult,
},
session::PermissionMessageState,
tools::{PresentNotesCall, QueryCall, ToolCall, ToolCalls, ToolResponse},
@@ -14,6 +15,7 @@ use notedeck::{
tr, Accounts, AppContext, Images, Localization, MediaJobSender, NoteAction, NoteContext,
};
use notedeck_ui::{app_images, icons::search_icon, NoteOptions, ProfilePic};
+use std::collections::HashMap;
use uuid::Uuid;
/// DaveUi holds all of the data it needs to render itself
@@ -29,6 +31,8 @@ pub struct DaveUi<'a> {
plan_mode_active: bool,
/// State for tentative permission response (waiting for message)
permission_message_state: PermissionMessageState,
+ /// State for AskUserQuestion responses (selected options per question)
+ question_answers: Option<&'a mut HashMap<Uuid, Vec<QuestionAnswer>>>,
}
/// The response the app generates. The response contains an optional
@@ -92,6 +96,11 @@ pub enum DaveAction {
TentativeAccept,
/// Enter tentative deny mode (Shift+click on No)
TentativeDeny,
+ /// User responded to an AskUserQuestion
+ QuestionResponse {
+ request_id: Uuid,
+ answers: Vec<QuestionAnswer>,
+ },
}
impl<'a> DaveUi<'a> {
@@ -112,6 +121,7 @@ impl<'a> DaveUi<'a> {
focus_requested,
plan_mode_active: false,
permission_message_state: PermissionMessageState::None,
+ question_answers: None,
}
}
@@ -120,6 +130,14 @@ impl<'a> DaveUi<'a> {
self
}
+ pub fn question_answers(
+ mut self,
+ answers: &'a mut HashMap<Uuid, Vec<QuestionAnswer>>,
+ ) -> Self {
+ self.question_answers = Some(answers);
+ self
+ }
+
pub fn compact(mut self, compact: bool) -> Self {
self.compact = compact;
self
@@ -327,6 +345,22 @@ impl<'a> DaveUi<'a> {
});
}
None => {
+ // Check if this is an AskUserQuestion tool call
+ if request.tool_name == "AskUserQuestion" {
+ if let Ok(questions) =
+ serde_json::from_value::<AskUserQuestionInput>(request.tool_input.clone())
+ {
+ if let Some(ref mut answers_map) = self.question_answers {
+ return super::ask_user_question_ui(
+ request,
+ &questions,
+ answers_map,
+ ui,
+ );
+ }
+ }
+ }
+
// Check if this is a file update (Edit or Write tool)
if let Some(file_update) =
FileUpdate::from_tool_call(&request.tool_name, &request.tool_input)
diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs
@@ -1,3 +1,4 @@
+mod ask_question;
pub mod badge;
mod dave;
pub mod diff;
@@ -7,6 +8,7 @@ pub mod scene;
pub mod session_list;
mod settings;
+pub use ask_question::ask_user_question_ui;
pub use dave::{DaveAction, DaveResponse, DaveUi};
pub use keybind_hint::{keybind_hint, paint_keybind_hint};
pub use keybindings::{check_keybindings, KeyAction};