notedeck

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

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:
Mcrates/notedeck_dave/src/lib.rs | 36+++++++++++++++++++++++++++++++++---
Mcrates/notedeck_dave/src/session.rs | 3+++
Mcrates/notedeck_dave/src/ui/ask_question.rs | 252++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mcrates/notedeck_dave/src/ui/dave.rs | 13++++++++++++-
Mcrates/notedeck_dave/src/ui/keybindings.rs | 6++++--
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