notedeck

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

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:
Mcrates/notedeck_dave/src/lib.rs | 106++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/notedeck_dave/src/messages.rs | 34++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/session.rs | 5++++-
Acrates/notedeck_dave/src/ui/ask_question.rs | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/dave.rs | 36+++++++++++++++++++++++++++++++++++-
Mcrates/notedeck_dave/src/ui/mod.rs | 2++
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};