commit 0293fb9abea757d241cd95f6a161a93a4a26a1e4
parent f732a0f6f0b72fa47c3dfbab80e1b62b814ef21a
Author: William Casarin <jb55@jb55.com>
Date: Tue, 27 Jan 2026 14:22:01 -0800
dave: improve AskUserQuestion UI
- Show one question at a time with progress indicator
- Add number key selection for options (1-9)
- Use keybind_hint for option numbers instead of StatusBadge
- Use ↵ symbol for enter key hint
- Skip global 1/2 accept/deny keybindings when question is pending
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat:
5 files changed, 225 insertions(+), 85 deletions(-)
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -395,6 +395,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
.plan_mode_active(plan_mode_active)
.permission_message_state(session.permission_message_state)
.question_answers(&mut session.question_answers)
+ .question_index(&mut session.question_index)
.ui(app_ctx, ui);
if response.action.is_some() {
@@ -508,6 +509,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
.plan_mode_active(plan_mode_active)
.permission_message_state(session.permission_message_state)
.question_answers(&mut session.question_answers)
+ .question_index(&mut session.question_index)
.ui(app_ctx, ui)
} else {
DaveResponse::default()
@@ -578,6 +580,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
.plan_mode_active(plan_mode_active)
.permission_message_state(session.permission_message_state)
.question_answers(&mut session.question_answers)
+ .question_index(&mut session.question_index)
.ui(app_ctx, ui)
} else {
DaveResponse::default()
@@ -707,6 +710,29 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
.and_then(|session| session.pending_permissions.keys().next().copied())
}
+ /// Check if the first pending permission is an AskUserQuestion tool call
+ fn has_pending_question(&self) -> bool {
+ let Some(session) = self.session_manager.get_active() else {
+ return false;
+ };
+
+ // Get the first pending permission request ID
+ let Some(request_id) = session.pending_permissions.keys().next() else {
+ return false;
+ };
+
+ // Find the corresponding PermissionRequest in chat to check tool_name
+ for msg in &session.chat {
+ if let Message::PermissionRequest(req) = msg {
+ if &req.id == request_id && req.tool_name == "AskUserQuestion" {
+ return true;
+ }
+ }
+ }
+
+ false
+ }
+
/// Handle a permission response (from UI button or keybinding)
fn handle_permission_response(&mut self, request_id: uuid::Uuid, response: PermissionResponse) {
if let Some(session) = self.session_manager.get_active_mut() {
@@ -1020,14 +1046,18 @@ impl notedeck::App for Dave {
// Handle global keybindings (when no text input has focus)
let has_pending_permission = self.first_pending_permission().is_some();
+ let has_pending_question = self.has_pending_question();
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)
- {
+ if let Some(key_action) = check_keybindings(
+ ui.ctx(),
+ has_pending_permission,
+ has_pending_question,
+ in_tentative_state,
+ ) {
match key_action {
KeyAction::AcceptPermission => {
if let Some(request_id) = self.first_pending_permission() {
diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs
@@ -45,6 +45,8 @@ pub struct ChatSession {
pub permission_message_state: PermissionMessageState,
/// State for pending AskUserQuestion responses (keyed by request UUID)
pub question_answers: HashMap<Uuid, Vec<QuestionAnswer>>,
+ /// Current question index for multi-question AskUserQuestion (keyed by request UUID)
+ pub question_index: HashMap<Uuid, usize>,
}
impl Drop for ChatSession {
@@ -77,6 +79,7 @@ impl ChatSession {
permission_mode: PermissionMode::Default,
permission_message_state: PermissionMessageState::None,
question_answers: HashMap::new(),
+ question_index: HashMap::new(),
}
}
diff --git a/crates/notedeck_dave/src/ui/ask_question.rs b/crates/notedeck_dave/src/ui/ask_question.rs
@@ -5,27 +5,47 @@ use std::collections::HashMap;
use uuid::Uuid;
use super::badge;
+use super::keybind_hint;
use super::DaveAction;
/// Render an AskUserQuestion tool call with selectable options
///
-/// Returns a `DaveAction::QuestionResponse` when the user submits their answers.
+/// Shows one question at a time with numbered options.
+/// Returns a `DaveAction::QuestionResponse` when the user submits all answers.
pub fn ask_user_question_ui(
request: &PermissionRequest,
questions: &AskUserQuestionInput,
answers_map: &mut HashMap<Uuid, Vec<QuestionAnswer>>,
+ index_map: &mut HashMap<Uuid, usize>,
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();
+
+ // Get or initialize answer state for this request
let answers = answers_map
.entry(request.id)
.or_insert_with(|| vec![QuestionAnswer::default(); num_questions]);
+ // Get current question index
+ let current_idx = *index_map.entry(request.id).or_insert(0);
+
+ // Ensure we have a valid index
+ if current_idx >= num_questions {
+ // All questions answered, shouldn't happen but handle gracefully
+ return None;
+ }
+
+ let question = &questions.questions[current_idx];
+
+ // Ensure we have an answer entry for this question
+ while answers.len() <= current_idx {
+ answers.push(QuestionAnswer::default());
+ }
+
egui::Frame::new()
.fill(ui.visuals().widgets.noninteractive.bg_fill)
.inner_margin(inner_margin)
@@ -36,119 +56,193 @@ pub fn ask_user_question_ui(
))
.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
+ // Progress indicator if multiple questions
+ if num_questions > 1 {
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.label(
+ egui::RichText::new(format!(
+ "Question {} of {}",
+ current_idx + 1,
+ num_questions
+ ))
+ .weak()
+ .size(11.0),
+ );
});
+ 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());
+ });
- // 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.add_space(8.0);
- ui.vertical(|ui| {
- ui.label(egui::RichText::new(&option.label));
- ui.label(
- egui::RichText::new(&option.description)
- .weak()
- .size(11.0),
- );
- });
- });
+ // Check for number key presses
+ let pressed_number = ui.input(|i| {
+ for n in 1..=9 {
+ let key = match n {
+ 1 => egui::Key::Num1,
+ 2 => egui::Key::Num2,
+ 3 => egui::Key::Num3,
+ 4 => egui::Key::Num4,
+ 5 => egui::Key::Num5,
+ 6 => egui::Key::Num6,
+ 7 => egui::Key::Num7,
+ 8 => egui::Key::Num8,
+ 9 => egui::Key::Num9,
+ _ => unreachable!(),
+ };
+ if i.key_pressed(key) && !i.modifiers.shift && !i.modifiers.ctrl {
+ return Some(n);
+ }
+ }
+ None
+ });
- ui.add_space(4.0);
+ // Options (numbered 1-N)
+ let num_options = question.options.len();
+ for (opt_idx, option) in question.options.iter().enumerate() {
+ let option_num = opt_idx + 1;
+ let is_selected = answers[current_idx].selected.contains(&opt_idx);
+ let other_is_selected = answers[current_idx].other_text.is_some();
+
+ // Handle keyboard selection
+ if pressed_number == Some(option_num) {
+ if question.multi_select {
+ if is_selected {
+ answers[current_idx].selected.retain(|&i| i != opt_idx);
+ } else {
+ answers[current_idx].selected.push(opt_idx);
+ }
+ } else {
+ answers[current_idx].selected = vec![opt_idx];
+ answers[current_idx].other_text = None;
+ }
}
- // "Other" option
ui.horizontal(|ui| {
- let other_selected = answers[q_idx].other_text.is_some();
+ // Number hint
+ keybind_hint(ui, &option_num.to_string());
+
if question.multi_select {
- let mut checked = other_selected;
+ // Checkbox for multi-select
+ let mut checked = is_selected;
if ui.checkbox(&mut checked, "").changed() {
if checked {
- answers[q_idx].other_text = Some(String::new());
+ answers[current_idx].selected.push(opt_idx);
} else {
- answers[q_idx].other_text = None;
+ answers[current_idx].selected.retain(|&i| i != opt_idx);
}
}
- } else if ui.radio(other_selected, "").clicked() {
- answers[q_idx].selected.clear();
- answers[q_idx].other_text = Some(String::new());
+ } else {
+ // Radio button for single-select
+ let selected = is_selected && !other_is_selected;
+ if ui.radio(selected, "").clicked() {
+ answers[current_idx].selected = vec![opt_idx];
+ answers[current_idx].other_text = None;
+ }
}
- 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.vertical(|ui| {
+ ui.label(egui::RichText::new(&option.label));
+ ui.label(
+ egui::RichText::new(&option.description)
+ .weak()
+ .size(11.0),
);
- }
+ });
});
- ui.add_space(8.0);
- if q_idx < questions.questions.len() - 1 {
- ui.separator();
+ ui.add_space(4.0);
+ }
+
+ // "Other" option (numbered as last option + 1)
+ let other_num = num_options + 1;
+ let other_selected = answers[current_idx].other_text.is_some();
+
+ // Handle keyboard selection for "Other"
+ if pressed_number == Some(other_num) {
+ if question.multi_select {
+ if other_selected {
+ answers[current_idx].other_text = None;
+ } else {
+ answers[current_idx].other_text = Some(String::new());
+ }
+ } else {
+ answers[current_idx].selected.clear();
+ answers[current_idx].other_text = Some(String::new());
}
}
+ ui.horizontal(|ui| {
+ // Number hint for "Other"
+ keybind_hint(ui, &other_num.to_string());
+
+ if question.multi_select {
+ let mut checked = other_selected;
+ if ui.checkbox(&mut checked, "").changed() {
+ if checked {
+ answers[current_idx].other_text = Some(String::new());
+ } else {
+ answers[current_idx].other_text = None;
+ }
+ }
+ } else if ui.radio(other_selected, "").clicked() {
+ answers[current_idx].selected.clear();
+ answers[current_idx].other_text = Some(String::new());
+ }
+
+ ui.label("Other:");
+
+ // Text input for "Other"
+ if let Some(text) = &mut answers[current_idx].other_text {
+ ui.add(
+ egui::TextEdit::singleline(text)
+ .desired_width(200.0)
+ .hint_text("Type your answer..."),
+ );
+ }
+ });
+
// 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 is_last_question = current_idx == num_questions - 1;
+ let button_label = if is_last_question {
+ "Submit"
+ } else {
+ "Next"
+ };
+
let submit_response = badge::ActionButton::new(
- "Submit",
+ button_label,
egui::Color32::from_rgb(34, 139, 34),
button_text_color,
)
- .keybind("Enter")
+ .keybind("\u{21B5}") // ↵ enter symbol
.show(ui);
if submit_response.clicked()
- || ui.input(|i| {
- i.key_pressed(egui::Key::Enter) && !i.modifiers.shift
- })
+ || ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift)
{
- action = Some(DaveAction::QuestionResponse {
- request_id: request.id,
- answers: answers.clone(),
- });
+ if is_last_question {
+ // All questions answered, submit
+ action = Some(DaveAction::QuestionResponse {
+ request_id: request.id,
+ answers: answers.clone(),
+ });
+ } else {
+ // Move to next question
+ index_map.insert(request.id, current_idx + 1);
+ }
}
});
});
diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs
@@ -33,6 +33,8 @@ pub struct DaveUi<'a> {
permission_message_state: PermissionMessageState,
/// State for AskUserQuestion responses (selected options per question)
question_answers: Option<&'a mut HashMap<Uuid, Vec<QuestionAnswer>>>,
+ /// Current question index for multi-question AskUserQuestion
+ question_index: Option<&'a mut HashMap<Uuid, usize>>,
}
/// The response the app generates. The response contains an optional
@@ -122,6 +124,7 @@ impl<'a> DaveUi<'a> {
plan_mode_active: false,
permission_message_state: PermissionMessageState::None,
question_answers: None,
+ question_index: None,
}
}
@@ -138,6 +141,11 @@ impl<'a> DaveUi<'a> {
self
}
+ pub fn question_index(mut self, index: &'a mut HashMap<Uuid, usize>) -> Self {
+ self.question_index = Some(index);
+ self
+ }
+
pub fn compact(mut self, compact: bool) -> Self {
self.compact = compact;
self
@@ -350,11 +358,14 @@ impl<'a> DaveUi<'a> {
if let Ok(questions) =
serde_json::from_value::<AskUserQuestionInput>(request.tool_input.clone())
{
- if let Some(ref mut answers_map) = self.question_answers {
+ if let (Some(answers_map), Some(index_map)) =
+ (&mut self.question_answers, &mut self.question_index)
+ {
return super::ask_user_question_ui(
request,
&questions,
answers_map,
+ index_map,
ui,
);
}
diff --git a/crates/notedeck_dave/src/ui/keybindings.rs b/crates/notedeck_dave/src/ui/keybindings.rs
@@ -43,6 +43,7 @@ pub enum KeyAction {
pub fn check_keybindings(
ctx: &egui::Context,
has_pending_permission: bool,
+ has_pending_question: bool,
in_tentative_state: bool,
) -> Option<KeyAction> {
// Escape in tentative state cancels the tentative mode
@@ -140,13 +141,14 @@ pub fn check_keybindings(
return Some(action);
}
- // When there's a pending permission:
+ // When there's a pending permission (but NOT an AskUserQuestion):
// - 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
// IMPORTANT: Only handle these when no text input has focus, to avoid
// capturing keypresses when user is typing a message in tentative state
- if has_pending_permission && !ctx.wants_keyboard_input() {
+ // AskUserQuestion uses number keys for option selection, so we skip these bindings
+ if has_pending_permission && !has_pending_question && !ctx.wants_keyboard_input() {
// Shift+1 = tentative accept, Shift+2 = tentative deny
// Note: egui may report shifted keys as their symbol (e.g., Shift+1 as Exclamationmark)
// We check for both the symbol key and Shift+Num key to handle different behaviors