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 }