notedeck

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

ask_question.rs (11226B)


      1 //! UI for rendering AskUserQuestion tool calls from Claude Code
      2 
      3 use crate::messages::{AskUserQuestionInput, PermissionRequest, QuestionAnswer};
      4 use std::collections::HashMap;
      5 use uuid::Uuid;
      6 
      7 use super::badge;
      8 use super::keybind_hint;
      9 use super::DaveAction;
     10 
     11 /// Render an AskUserQuestion tool call with selectable options
     12 ///
     13 /// Shows one question at a time with numbered options.
     14 /// Returns a `DaveAction::QuestionResponse` when the user submits all answers.
     15 pub fn ask_user_question_ui(
     16     request: &PermissionRequest,
     17     questions: &AskUserQuestionInput,
     18     answers_map: &mut HashMap<Uuid, Vec<QuestionAnswer>>,
     19     index_map: &mut HashMap<Uuid, usize>,
     20     ui: &mut egui::Ui,
     21 ) -> Option<DaveAction> {
     22     let mut action = None;
     23     let inner_margin = 12.0;
     24     let corner_radius = 8.0;
     25 
     26     let num_questions = questions.questions.len();
     27 
     28     // Get or initialize answer state for this request
     29     let answers = answers_map
     30         .entry(request.id)
     31         .or_insert_with(|| vec![QuestionAnswer::default(); num_questions]);
     32 
     33     // Get current question index
     34     let current_idx = *index_map.entry(request.id).or_insert(0);
     35 
     36     // Ensure we have a valid index
     37     if current_idx >= num_questions {
     38         // All questions answered, shouldn't happen but handle gracefully
     39         return None;
     40     }
     41 
     42     let question = &questions.questions[current_idx];
     43 
     44     // Ensure we have an answer entry for this question
     45     while answers.len() <= current_idx {
     46         answers.push(QuestionAnswer::default());
     47     }
     48 
     49     egui::Frame::new()
     50         .fill(ui.visuals().widgets.noninteractive.bg_fill)
     51         .inner_margin(inner_margin)
     52         .corner_radius(corner_radius)
     53         .stroke(egui::Stroke::new(1.0, ui.visuals().selection.stroke.color))
     54         .show(ui, |ui| {
     55             ui.vertical(|ui| {
     56                 // Progress indicator if multiple questions
     57                 if num_questions > 1 {
     58                     ui.horizontal(|ui| {
     59                         ui.label(
     60                             egui::RichText::new(format!(
     61                                 "Question {} of {}",
     62                                 current_idx + 1,
     63                                 num_questions
     64                             ))
     65                             .weak()
     66                             .size(11.0),
     67                         );
     68                     });
     69                     ui.add_space(4.0);
     70                 }
     71 
     72                 // Header badge and question text
     73                 ui.horizontal(|ui| {
     74                     badge::StatusBadge::new(&question.header)
     75                         .variant(badge::BadgeVariant::Info)
     76                         .show(ui);
     77                     ui.add_space(8.0);
     78                     ui.label(egui::RichText::new(&question.question).strong());
     79                 });
     80 
     81                 ui.add_space(8.0);
     82 
     83                 // Check for number key presses
     84                 let pressed_number = ui.input(|i| {
     85                     for n in 1..=9 {
     86                         let key = match n {
     87                             1 => egui::Key::Num1,
     88                             2 => egui::Key::Num2,
     89                             3 => egui::Key::Num3,
     90                             4 => egui::Key::Num4,
     91                             5 => egui::Key::Num5,
     92                             6 => egui::Key::Num6,
     93                             7 => egui::Key::Num7,
     94                             8 => egui::Key::Num8,
     95                             9 => egui::Key::Num9,
     96                             _ => unreachable!(),
     97                         };
     98                         if i.key_pressed(key) && !i.modifiers.shift && !i.modifiers.ctrl {
     99                             return Some(n);
    100                         }
    101                     }
    102                     None
    103                 });
    104 
    105                 // Options (numbered 1-N)
    106                 let num_options = question.options.len();
    107                 for (opt_idx, option) in question.options.iter().enumerate() {
    108                     let option_num = opt_idx + 1;
    109                     let is_selected = answers[current_idx].selected.contains(&opt_idx);
    110                     let other_is_selected = answers[current_idx].other_text.is_some();
    111 
    112                     // Handle keyboard selection
    113                     if pressed_number == Some(option_num) {
    114                         if question.multi_select {
    115                             if is_selected {
    116                                 answers[current_idx].selected.retain(|&i| i != opt_idx);
    117                             } else {
    118                                 answers[current_idx].selected.push(opt_idx);
    119                             }
    120                         } else {
    121                             answers[current_idx].selected = vec![opt_idx];
    122                             answers[current_idx].other_text = None;
    123                         }
    124                     }
    125 
    126                     ui.horizontal(|ui| {
    127                         // Number hint
    128                         keybind_hint(ui, &option_num.to_string());
    129 
    130                         if question.multi_select {
    131                             // Checkbox for multi-select
    132                             let mut checked = is_selected;
    133                             if ui.checkbox(&mut checked, "").changed() {
    134                                 if checked {
    135                                     answers[current_idx].selected.push(opt_idx);
    136                                 } else {
    137                                     answers[current_idx].selected.retain(|&i| i != opt_idx);
    138                                 }
    139                             }
    140                         } else {
    141                             // Radio button for single-select
    142                             let selected = is_selected && !other_is_selected;
    143                             if ui.radio(selected, "").clicked() {
    144                                 answers[current_idx].selected = vec![opt_idx];
    145                                 answers[current_idx].other_text = None;
    146                             }
    147                         }
    148 
    149                         ui.vertical(|ui| {
    150                             ui.label(egui::RichText::new(&option.label));
    151                             ui.label(egui::RichText::new(&option.description).weak().size(11.0));
    152                         });
    153                     });
    154 
    155                     ui.add_space(4.0);
    156                 }
    157 
    158                 // "Other" option (numbered as last option + 1)
    159                 let other_num = num_options + 1;
    160                 let other_selected = answers[current_idx].other_text.is_some();
    161 
    162                 // Handle keyboard selection for "Other"
    163                 if pressed_number == Some(other_num) {
    164                     if question.multi_select {
    165                         if other_selected {
    166                             answers[current_idx].other_text = None;
    167                         } else {
    168                             answers[current_idx].other_text = Some(String::new());
    169                         }
    170                     } else {
    171                         answers[current_idx].selected.clear();
    172                         answers[current_idx].other_text = Some(String::new());
    173                     }
    174                 }
    175 
    176                 ui.horizontal(|ui| {
    177                     // Number hint for "Other"
    178                     keybind_hint(ui, &other_num.to_string());
    179 
    180                     if question.multi_select {
    181                         let mut checked = other_selected;
    182                         if ui.checkbox(&mut checked, "").changed() {
    183                             if checked {
    184                                 answers[current_idx].other_text = Some(String::new());
    185                             } else {
    186                                 answers[current_idx].other_text = None;
    187                             }
    188                         }
    189                     } else if ui.radio(other_selected, "").clicked() {
    190                         answers[current_idx].selected.clear();
    191                         answers[current_idx].other_text = Some(String::new());
    192                     }
    193 
    194                     ui.label("Other:");
    195 
    196                     // Text input for "Other"
    197                     if let Some(text) = &mut answers[current_idx].other_text {
    198                         ui.add(
    199                             egui::TextEdit::singleline(text)
    200                                 .desired_width(200.0)
    201                                 .hint_text("Type your answer..."),
    202                         );
    203                     }
    204                 });
    205 
    206                 // Submit button
    207                 ui.add_space(8.0);
    208                 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
    209                     let button_text_color = ui.visuals().widgets.active.fg_stroke.color;
    210 
    211                     let is_last_question = current_idx == num_questions - 1;
    212                     let button_label = if is_last_question { "Submit" } else { "Next" };
    213 
    214                     let submit_response = badge::ActionButton::new(
    215                         button_label,
    216                         egui::Color32::from_rgb(34, 139, 34),
    217                         button_text_color,
    218                     )
    219                     .keybind("\u{21B5}") // ↵ enter symbol
    220                     .show(ui);
    221 
    222                     if submit_response.clicked()
    223                         || ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift)
    224                     {
    225                         if is_last_question {
    226                             // All questions answered, submit
    227                             action = Some(DaveAction::QuestionResponse {
    228                                 request_id: request.id,
    229                                 answers: answers.clone(),
    230                             });
    231                         } else {
    232                             // Move to next question
    233                             index_map.insert(request.id, current_idx + 1);
    234                         }
    235                     }
    236                 });
    237             });
    238         });
    239 
    240     action
    241 }
    242 
    243 /// Render a compact summary of an answered AskUserQuestion
    244 ///
    245 /// Shows the question header(s) and selected answer(s) in a single line.
    246 /// Uses pre-computed AnswerSummary to avoid per-frame allocations.
    247 pub fn ask_user_question_summary_ui(summary: &crate::messages::AnswerSummary, ui: &mut egui::Ui) {
    248     let inner_margin = 8.0;
    249     let corner_radius = 6.0;
    250 
    251     egui::Frame::new()
    252         .fill(ui.visuals().widgets.noninteractive.bg_fill)
    253         .inner_margin(inner_margin)
    254         .corner_radius(corner_radius)
    255         .show(ui, |ui| {
    256             ui.horizontal_wrapped(|ui| {
    257                 for (idx, entry) in summary.entries.iter().enumerate() {
    258                     // Add separator between questions
    259                     if idx > 0 {
    260                         ui.separator();
    261                     }
    262 
    263                     // Header badge
    264                     badge::StatusBadge::new(&entry.header)
    265                         .variant(badge::BadgeVariant::Info)
    266                         .show(ui);
    267 
    268                     // Pre-computed answer text
    269                     ui.label(
    270                         egui::RichText::new(&entry.answer)
    271                             .color(egui::Color32::from_rgb(100, 180, 100)),
    272                     );
    273                 }
    274             });
    275         });
    276 }