1use std::collections::HashSet;
15
16use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
17use crate::controller::{
18 Answer, AskUserQuestionsRequest, AskUserQuestionsResponse, Question, TurnId,
19};
20use ratatui::{
21 layout::Rect,
22 style::{Modifier, Style},
23 text::{Line, Span},
24 widgets::{Block, Borders, Clear, Paragraph},
25 Frame,
26};
27use tui_textarea::TextArea;
28
29use crate::tui::themes::Theme;
30
31const MAX_PANEL_PERCENT: u16 = 70;
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum FocusItem {
37 Choice {
39 question_idx: usize,
40 choice_idx: usize,
41 },
42 OtherOption { question_idx: usize },
44 OtherText { question_idx: usize },
46 TextInput { question_idx: usize },
48 Submit,
50 Cancel,
52}
53
54pub enum AnswerState {
56 SingleChoice {
58 selected: Option<String>,
59 other_text: TextArea<'static>,
60 },
61 MultiChoice {
63 selected: HashSet<String>,
64 other_text: TextArea<'static>,
65 },
66 FreeText { textarea: TextArea<'static> },
68}
69
70impl AnswerState {
71 pub fn from_question(question: &Question) -> Self {
73 match question {
74 Question::SingleChoice { .. } => {
75 let other_text = TextArea::default();
76 AnswerState::SingleChoice {
77 selected: None,
78 other_text,
79 }
80 }
81 Question::MultiChoice { .. } => {
82 let other_text = TextArea::default();
83 AnswerState::MultiChoice {
84 selected: HashSet::new(),
85 other_text,
86 }
87 }
88 Question::FreeText { default_value, .. } => {
89 let mut textarea = TextArea::default();
90 if let Some(default) = default_value {
91 textarea.insert_str(default);
92 }
93 AnswerState::FreeText { textarea }
94 }
95 }
96 }
97
98 pub fn to_answer(&self, question_text: &str) -> Answer {
100 match self {
101 AnswerState::SingleChoice {
102 selected,
103 other_text,
104 } => {
105 let other = other_text.lines().join("\n");
106 let answer_values = if !other.is_empty() {
108 vec![other]
109 } else {
110 selected.iter().cloned().collect()
111 };
112 Answer {
113 question: question_text.to_string(),
114 answer: answer_values,
115 }
116 }
117 AnswerState::MultiChoice {
118 selected,
119 other_text,
120 } => {
121 let other = other_text.lines().join("\n");
122 let mut answer_values: Vec<String> = selected.iter().cloned().collect();
123 if !other.is_empty() {
125 answer_values.push(other);
126 }
127 Answer {
128 question: question_text.to_string(),
129 answer: answer_values,
130 }
131 }
132 AnswerState::FreeText { textarea } => Answer {
133 question: question_text.to_string(),
134 answer: vec![textarea.lines().join("\n")],
135 },
136 }
137 }
138
139 pub fn is_selected(&self, choice_id: &str) -> bool {
141 match self {
142 AnswerState::SingleChoice { selected, .. } => {
143 selected.as_ref().map(|s| s == choice_id).unwrap_or(false)
144 }
145 AnswerState::MultiChoice { selected, .. } => selected.contains(choice_id),
146 AnswerState::FreeText { .. } => false,
147 }
148 }
149
150 pub fn select_choice(&mut self, choice_id: &str) {
152 match self {
153 AnswerState::SingleChoice { selected, .. } => {
154 *selected = Some(choice_id.to_string());
155 }
156 AnswerState::MultiChoice { selected, .. } => {
157 if selected.contains(choice_id) {
158 selected.remove(choice_id);
159 } else {
160 selected.insert(choice_id.to_string());
161 }
162 }
163 AnswerState::FreeText { .. } => {}
164 }
165 }
166
167 pub fn textarea_mut(&mut self) -> Option<&mut TextArea<'static>> {
169 match self {
170 AnswerState::SingleChoice { other_text, .. }
171 | AnswerState::MultiChoice { other_text, .. } => Some(other_text),
172 AnswerState::FreeText { textarea } => Some(textarea),
173 }
174 }
175
176 pub fn has_other_text(&self) -> bool {
178 match self {
179 AnswerState::SingleChoice { other_text, .. }
180 | AnswerState::MultiChoice { other_text, .. } => {
181 !other_text.lines().join("").is_empty()
182 }
183 AnswerState::FreeText { .. } => false,
184 }
185 }
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum EnterAction {
191 None,
192 Selected,
193 Submit,
194 Cancel,
195}
196
197#[derive(Debug, Clone)]
199pub enum KeyAction {
200 Handled,
202 NotHandled,
204 Submitted(String, AskUserQuestionsResponse),
206 Cancelled(String),
208}
209
210pub struct QuestionPanel {
212 active: bool,
214 tool_use_id: String,
216 session_id: i64,
218 request: AskUserQuestionsRequest,
220 turn_id: Option<TurnId>,
222 answers: Vec<AnswerState>,
224 focus_items: Vec<FocusItem>,
226 focus_idx: usize,
228}
229
230impl QuestionPanel {
231 pub fn new() -> Self {
233 Self {
234 active: false,
235 tool_use_id: String::new(),
236 session_id: 0,
237 request: AskUserQuestionsRequest {
238 questions: Vec::new(),
239 },
240 turn_id: None,
241 answers: Vec::new(),
242 focus_items: Vec::new(),
243 focus_idx: 0,
244 }
245 }
246
247 pub fn activate(
249 &mut self,
250 tool_use_id: String,
251 session_id: i64,
252 request: AskUserQuestionsRequest,
253 turn_id: Option<TurnId>,
254 ) {
255 self.active = true;
256 self.tool_use_id = tool_use_id;
257 self.session_id = session_id;
258
259 self.answers = request
261 .questions
262 .iter()
263 .map(AnswerState::from_question)
264 .collect();
265
266 self.focus_items = Self::build_focus_items(&request.questions);
268 self.focus_idx = 0;
269
270 self.request = request;
271 self.turn_id = turn_id;
272 }
273
274 fn build_focus_items(questions: &[Question]) -> Vec<FocusItem> {
276 let mut items = Vec::new();
277
278 for (q_idx, question) in questions.iter().enumerate() {
279 match question {
280 Question::SingleChoice { choices, .. } | Question::MultiChoice { choices, .. } => {
281 for c_idx in 0..choices.len() {
283 items.push(FocusItem::Choice {
284 question_idx: q_idx,
285 choice_idx: c_idx,
286 });
287 }
288 items.push(FocusItem::OtherOption { question_idx: q_idx });
290 }
291 Question::FreeText { .. } => {
292 items.push(FocusItem::TextInput { question_idx: q_idx });
293 }
294 }
295 }
296
297 items.push(FocusItem::Submit);
299 items.push(FocusItem::Cancel);
300
301 items
302 }
303
304 pub fn deactivate(&mut self) {
306 self.active = false;
307 self.tool_use_id.clear();
308 self.request.questions.clear();
309 self.answers.clear();
310 self.focus_items.clear();
311 self.focus_idx = 0;
312 }
313
314 pub fn is_active(&self) -> bool {
316 self.active
317 }
318
319 pub fn tool_use_id(&self) -> &str {
321 &self.tool_use_id
322 }
323
324 pub fn session_id(&self) -> i64 {
326 self.session_id
327 }
328
329 pub fn request(&self) -> &AskUserQuestionsRequest {
331 &self.request
332 }
333
334 pub fn turn_id(&self) -> Option<&TurnId> {
336 self.turn_id.as_ref()
337 }
338
339 pub fn build_response(&self) -> AskUserQuestionsResponse {
341 let answers = self
342 .request
343 .questions
344 .iter()
345 .zip(self.answers.iter())
346 .map(|(q, a)| a.to_answer(q.text()))
347 .collect();
348 AskUserQuestionsResponse { answers }
349 }
350
351 pub fn current_focus(&self) -> Option<&FocusItem> {
353 self.focus_items.get(self.focus_idx)
354 }
355
356 pub fn focus_next(&mut self) {
358 if !self.focus_items.is_empty() {
359 self.focus_idx = (self.focus_idx + 1) % self.focus_items.len();
360 }
361 }
362
363 pub fn focus_prev(&mut self) {
365 if !self.focus_items.is_empty() {
366 if self.focus_idx == 0 {
367 self.focus_idx = self.focus_items.len() - 1;
368 } else {
369 self.focus_idx -= 1;
370 }
371 }
372 }
373
374 pub fn focus_submit(&mut self) {
376 if let Some(idx) = self.focus_items.iter().position(|f| *f == FocusItem::Submit) {
377 self.focus_idx = idx;
378 }
379 }
380
381 pub fn is_text_focused(&self) -> bool {
383 matches!(
384 self.current_focus(),
385 Some(FocusItem::TextInput { .. } | FocusItem::OtherText { .. } | FocusItem::OtherOption { .. })
386 )
387 }
388
389 fn handle_enter(&mut self) -> EnterAction {
391 match self.current_focus().cloned() {
392 Some(FocusItem::Choice {
393 question_idx,
394 choice_idx,
395 }) => {
396 if let (Some(question), Some(answer)) = (
398 self.request.questions.get(question_idx),
399 self.answers.get_mut(question_idx),
400 ) {
401 let choice_text = match question {
402 Question::SingleChoice { choices, .. }
403 | Question::MultiChoice { choices, .. } => {
404 choices.get(choice_idx).cloned()
405 }
406 _ => None,
407 };
408 if let Some(text) = choice_text {
409 answer.select_choice(&text);
410 }
411 }
412 if matches!(
414 self.request.questions.get(question_idx),
415 Some(Question::SingleChoice { .. })
416 ) {
417 self.advance_to_next_question(question_idx);
418 }
419 EnterAction::Selected
420 }
421 Some(FocusItem::OtherOption { question_idx: _ }) => {
422 self.focus_next();
424 EnterAction::Selected
425 }
426 Some(FocusItem::OtherText { question_idx }) => {
427 self.advance_to_next_question(question_idx);
429 EnterAction::Selected
430 }
431 Some(FocusItem::TextInput { question_idx }) => {
432 self.advance_to_next_question(question_idx);
434 EnterAction::Selected
435 }
436 Some(FocusItem::Submit) => EnterAction::Submit,
437 Some(FocusItem::Cancel) => EnterAction::Cancel,
438 None => EnterAction::None,
439 }
440 }
441
442 fn advance_to_next_question(&mut self, current_question_idx: usize) {
444 let next_question_idx = current_question_idx + 1;
445
446 if let Some(idx) = self.focus_items.iter().position(|f| match f {
448 FocusItem::Choice { question_idx, .. }
449 | FocusItem::OtherOption { question_idx }
450 | FocusItem::OtherText { question_idx }
451 | FocusItem::TextInput { question_idx } => *question_idx == next_question_idx,
452 FocusItem::Submit | FocusItem::Cancel => false,
453 }) {
454 self.focus_idx = idx;
455 } else {
456 self.focus_submit();
458 }
459 }
460
461 fn handle_text_input(&mut self, key: KeyEvent) {
463 let focus = self.current_focus().cloned();
464 match focus {
465 Some(FocusItem::TextInput { question_idx }) => {
466 if let Some(answer) = self.answers.get_mut(question_idx) {
468 if let Some(textarea) = answer.textarea_mut() {
469 textarea.input(key);
470 }
471 }
472 }
473 Some(FocusItem::OtherText { question_idx })
474 | Some(FocusItem::OtherOption { question_idx }) => {
475 if let Some(answer) = self.answers.get_mut(question_idx) {
477 if let Some(textarea) = answer.textarea_mut() {
478 textarea.input(key);
479 }
480 if let AnswerState::SingleChoice { selected, .. } = answer {
482 *selected = None;
483 }
484 }
485 }
486 _ => {}
487 }
488 }
489
490 fn handle_space(&mut self) {
492 match self.current_focus().cloned() {
493 Some(FocusItem::Choice {
494 question_idx,
495 choice_idx,
496 }) => {
497 if let (Some(question), Some(answer)) = (
498 self.request.questions.get(question_idx),
499 self.answers.get_mut(question_idx),
500 ) {
501 let choice_text = match question {
502 Question::SingleChoice { choices, .. }
503 | Question::MultiChoice { choices, .. } => {
504 choices.get(choice_idx).cloned()
505 }
506 _ => None,
507 };
508 if let Some(text) = choice_text {
509 answer.select_choice(&text);
510 }
511 }
512 }
513 Some(FocusItem::OtherOption { .. }) => {
514 self.focus_next();
516 }
517 _ => {}
518 }
519 }
520
521 pub fn process_key(&mut self, key: KeyEvent) -> KeyAction {
525 if !self.active {
526 return KeyAction::NotHandled;
527 }
528
529 let is_text_mode = self.is_text_focused();
531
532 match key.code {
533 KeyCode::Up => {
535 if !is_text_mode {
536 self.focus_prev();
537 return KeyAction::Handled;
538 }
539 }
540 KeyCode::Down => {
541 if !is_text_mode {
542 self.focus_next();
543 return KeyAction::Handled;
544 }
545 }
546 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
547 self.focus_prev();
548 return KeyAction::Handled;
549 }
550 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
551 self.focus_next();
552 return KeyAction::Handled;
553 }
554 KeyCode::Char('k') if !is_text_mode => {
555 self.focus_prev();
556 return KeyAction::Handled;
557 }
558 KeyCode::Char('j') if !is_text_mode => {
559 self.focus_next();
560 return KeyAction::Handled;
561 }
562
563 KeyCode::Tab => {
565 self.focus_submit();
566 return KeyAction::Handled;
567 }
568
569 KeyCode::Esc => {
571 let tool_use_id = self.tool_use_id.clone();
572 return KeyAction::Cancelled(tool_use_id);
574 }
575
576 KeyCode::Enter => {
578 match self.handle_enter() {
579 EnterAction::Submit => {
580 let tool_use_id = self.tool_use_id.clone();
581 let response = self.build_response();
582 return KeyAction::Submitted(tool_use_id, response);
584 }
585 EnterAction::Cancel => {
586 let tool_use_id = self.tool_use_id.clone();
587 return KeyAction::Cancelled(tool_use_id);
589 }
590 EnterAction::Selected | EnterAction::None => {
591 return KeyAction::Handled;
592 }
593 }
594 }
595
596 KeyCode::Char(' ') if !is_text_mode => {
598 self.handle_space();
599 return KeyAction::Handled;
600 }
601
602 _ if is_text_mode => {
604 self.handle_text_input(key);
605 return KeyAction::Handled;
606 }
607
608 _ => {}
609 }
610
611 KeyAction::NotHandled
612 }
613
614 pub fn panel_height(&self, max_height: u16) -> u16 {
616 let mut lines = 0u16;
618
619 for question in &self.request.questions {
620 lines += 1; match question {
622 Question::SingleChoice { choices, .. } | Question::MultiChoice { choices, .. } => {
623 lines += choices.len() as u16;
624 lines += 1; }
626 Question::FreeText { .. } => {
627 lines += 1; }
629 }
630 }
631
632 let num_questions = self.request.questions.len() as u16;
634 let spacing = if num_questions > 1 { num_questions - 1 } else { 0 };
635 let total = lines + spacing + 7;
636
637 let max_from_percent = (max_height * MAX_PANEL_PERCENT) / 100;
639 total.min(max_from_percent).min(max_height.saturating_sub(6))
640 }
641
642 pub fn render_panel(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
649 if !self.active {
650 return;
651 }
652
653 frame.render_widget(Clear, area);
655
656 self.render_panel_content(frame, area, theme);
658 }
659
660 fn render_panel_content(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
662 let inner_width = area.width.saturating_sub(4) as usize;
663 let mut lines: Vec<Line> = Vec::new();
664
665 let help_text = if self.is_text_focused() {
667 " Type text | Enter: Next | Tab: Submit | Esc: Cancel"
668 } else {
669 " Up/Down: Navigate | Enter/Space: Select | Tab: Submit | Esc: Cancel"
670 };
671 lines.push(Line::from(Span::styled(help_text, theme.help_text())));
672 lines.push(Line::from("")); const QUESTION_PREFIX: &str = " \u{2237} "; for (q_idx, (question, answer)) in self
679 .request
680 .questions
681 .iter()
682 .zip(self.answers.iter())
683 .enumerate()
684 {
685 if q_idx > 0 {
687 lines.push(Line::from(""));
688 }
689
690 let required = if question.is_required() { "*" } else { "" };
692 let q_text = format!("{}{}{}", QUESTION_PREFIX, question.text(), required);
693 lines.push(Line::from(Span::styled(
694 truncate_text(&q_text, inner_width),
695 Style::default().add_modifier(Modifier::BOLD),
696 )));
697
698 match question {
699 Question::SingleChoice { choices, .. } => {
700 self.render_choices(&mut lines, q_idx, choices, answer, false, inner_width, theme);
701 }
702 Question::MultiChoice { choices, .. } => {
703 self.render_choices(&mut lines, q_idx, choices, answer, true, inner_width, theme);
704 }
705 Question::FreeText { .. } => {
706 self.render_text_input(&mut lines, q_idx, answer, inner_width, theme);
707 }
708 }
709 }
710
711 lines.push(Line::from(""));
713
714 let submit_focused = self.current_focus() == Some(&FocusItem::Submit);
716 let cancel_focused = self.current_focus() == Some(&FocusItem::Cancel);
717
718 let submit_style = if submit_focused {
719 theme.button_confirm_focused()
720 } else {
721 theme.button_confirm()
722 };
723 let cancel_style = if cancel_focused {
724 theme.button_cancel_focused()
725 } else {
726 theme.button_cancel()
727 };
728
729 let mut button_spans = vec![Span::raw(" ")];
730
731 if submit_focused {
733 button_spans.push(Span::styled("\u{203A} ", theme.focus_indicator()));
734 }
735 button_spans.push(Span::styled("Submit", submit_style));
736 button_spans.push(Span::raw(" "));
737
738 if cancel_focused {
740 button_spans.push(Span::styled("\u{203A} ", theme.focus_indicator()));
741 }
742 button_spans.push(Span::styled("Cancel", cancel_style));
743
744 lines.push(Line::from(button_spans));
745
746 let block = Block::default()
747 .borders(Borders::ALL)
748 .border_style(theme.warning())
749 .title(Span::styled(
750 " User Input Required ",
751 theme.warning().add_modifier(Modifier::BOLD),
752 ));
753
754 let paragraph = Paragraph::new(lines).block(block);
755 frame.render_widget(paragraph, area);
756 }
757
758 fn render_choices(
760 &self,
761 lines: &mut Vec<Line>,
762 question_idx: usize,
763 choices: &[String],
764 answer: &AnswerState,
765 is_multi: bool,
766 inner_width: usize,
767 theme: &Theme,
768 ) {
769 const INDICATOR: &str = " \u{203A} "; const NO_INDICATOR: &str = " "; for (c_idx, choice_text) in choices.iter().enumerate() {
774 let is_focused = self.current_focus()
775 == Some(&FocusItem::Choice {
776 question_idx,
777 choice_idx: c_idx,
778 });
779 let is_selected = answer.is_selected(choice_text);
780
781 let symbol = if is_multi {
782 if is_selected { "\u{25A0}" } else { "\u{25A1}" } } else {
784 if is_selected { "\u{25CF}" } else { "\u{25CB}" } };
786
787 let prefix = if is_focused { INDICATOR } else { NO_INDICATOR };
788 let display_text = truncate_text(choice_text, inner_width - 8);
789
790 if is_focused {
791 lines.push(Line::from(vec![
792 Span::styled(prefix, theme.focus_indicator()),
793 Span::styled(format!("{} {}", symbol, display_text), theme.focused_text()),
794 ]));
795 } else {
796 lines.push(Line::from(Span::styled(
797 format!("{}{} {}", prefix, symbol, display_text),
798 theme.muted_text(),
799 )));
800 }
801 }
802
803 let other_focused = self.current_focus() == Some(&FocusItem::OtherOption { question_idx });
805 let other_text_focused = self.current_focus() == Some(&FocusItem::OtherText { question_idx });
806 let is_this_focused = other_focused || other_text_focused;
807 let has_other = answer.has_other_text();
808
809 let is_other_selected = match answer {
813 AnswerState::SingleChoice { selected, .. } => selected.is_none() && (has_other || is_this_focused),
814 AnswerState::MultiChoice { .. } => has_other,
815 _ => false,
816 };
817
818 let symbol = if is_multi {
819 if is_other_selected { "\u{25A0}" } else { "\u{25A1}" }
820 } else {
821 if is_other_selected { "\u{25CF}" } else { "\u{25CB}" }
822 };
823
824 let prefix = if is_this_focused { INDICATOR } else { NO_INDICATOR };
825
826 let (other_text, cursor_col) = match answer {
828 AnswerState::SingleChoice { other_text, .. }
829 | AnswerState::MultiChoice { other_text, .. } => {
830 let text = other_text.lines().first().cloned().unwrap_or_default();
831 let col = other_text.cursor().1;
832 (text, col)
833 }
834 _ => (String::new(), 0),
835 };
836
837 if is_this_focused {
839 let chars: Vec<char> = other_text.chars().collect();
840 let cursor_pos = cursor_col.min(chars.len());
841 let before: String = chars[..cursor_pos].iter().collect();
842 let cursor_char = chars.get(cursor_pos).copied().unwrap_or(' ');
843 let after: String = chars.get(cursor_pos + 1..).map(|s| s.iter().collect()).unwrap_or_default();
844
845 lines.push(Line::from(vec![
846 Span::styled(prefix, theme.focus_indicator()),
847 Span::styled(format!("{} Type Something: ", symbol), theme.focused_text()),
848 Span::styled(before, theme.focused_text()),
849 Span::styled(cursor_char.to_string(), theme.cursor()),
850 Span::styled(after, theme.focused_text()),
851 ]));
852 } else {
853 let truncated_display = truncate_text(&other_text, inner_width - 24);
854 lines.push(Line::from(Span::styled(
855 format!("{}{} Type Something: {}", prefix, symbol, truncated_display),
856 theme.muted_text(),
857 )));
858 }
859 }
860
861 fn render_text_input(
863 &self,
864 lines: &mut Vec<Line>,
865 question_idx: usize,
866 answer: &AnswerState,
867 inner_width: usize,
868 theme: &Theme,
869 ) {
870 const INDICATOR: &str = " \u{203A} "; const NO_INDICATOR: &str = " "; let is_focused = self.current_focus() == Some(&FocusItem::TextInput { question_idx });
875
876 let (text, cursor_col) = match answer {
877 AnswerState::FreeText { textarea } => {
878 let t = textarea.lines().first().cloned().unwrap_or_default();
879 let col = textarea.cursor().1;
880 (t, col)
881 }
882 _ => (String::new(), 0),
883 };
884
885 let prefix = if is_focused { INDICATOR } else { NO_INDICATOR };
886
887 if is_focused {
888 let chars: Vec<char> = text.chars().collect();
890 let cursor_pos = cursor_col.min(chars.len());
891 let before: String = chars[..cursor_pos].iter().collect();
892 let cursor_char = chars.get(cursor_pos).copied().unwrap_or(' ');
893 let after: String = chars.get(cursor_pos + 1..).map(|s| s.iter().collect()).unwrap_or_default();
894
895 lines.push(Line::from(vec![
896 Span::styled(prefix, theme.focus_indicator()),
897 Span::styled("Type Something: ", theme.focused_text()),
898 Span::styled(before, theme.focused_text()),
899 Span::styled(cursor_char.to_string(), theme.cursor()),
900 Span::styled(after, theme.focused_text()),
901 ]));
902 } else {
903 let display = if text.is_empty() {
904 "Type Something:".to_string()
905 } else {
906 format!("Type Something: {}", text)
907 };
908 let truncated = truncate_text(&display, inner_width - 4);
909 lines.push(Line::from(Span::styled(
910 format!("{}{}", prefix, truncated),
911 theme.muted_text(),
912 )));
913 }
914 }
915}
916
917impl Default for QuestionPanel {
918 fn default() -> Self {
919 Self::new()
920 }
921}
922
923use std::any::Any;
926use super::{widget_ids, Widget, WidgetAction, WidgetKeyResult};
927
928impl Widget for QuestionPanel {
929 fn id(&self) -> &'static str {
930 widget_ids::QUESTION_PANEL
931 }
932
933 fn priority(&self) -> u8 {
934 200 }
936
937 fn is_active(&self) -> bool {
938 self.active
939 }
940
941 fn handle_key(&mut self, key: KeyEvent, _theme: &Theme) -> WidgetKeyResult {
942 if !self.active {
943 return WidgetKeyResult::NotHandled;
944 }
945
946 match self.process_key(key) {
947 KeyAction::Submitted(tool_use_id, response) => {
948 WidgetKeyResult::Action(WidgetAction::SubmitQuestion {
949 tool_use_id,
950 response,
951 })
952 }
953 KeyAction::Cancelled(tool_use_id) => {
954 WidgetKeyResult::Action(WidgetAction::CancelQuestion { tool_use_id })
955 }
956 KeyAction::Handled | KeyAction::NotHandled => WidgetKeyResult::Handled,
957 }
958 }
959
960 fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
961 self.render_panel(frame, area, theme);
962 }
963
964 fn required_height(&self, max_height: u16) -> u16 {
965 if self.active {
966 self.panel_height(max_height)
967 } else {
968 0
969 }
970 }
971
972 fn blocks_input(&self) -> bool {
973 self.active
974 }
975
976 fn is_overlay(&self) -> bool {
977 false
978 }
979
980 fn as_any(&self) -> &dyn Any {
981 self
982 }
983
984 fn as_any_mut(&mut self) -> &mut dyn Any {
985 self
986 }
987
988 fn into_any(self: Box<Self>) -> Box<dyn Any> {
989 self
990 }
991}
992
993fn truncate_text(text: &str, max_width: usize) -> String {
995 if text.chars().count() <= max_width {
996 text.to_string()
997 } else {
998 let truncated: String = text.chars().take(max_width.saturating_sub(3)).collect();
999 format!("{}...", truncated)
1000 }
1001}
1002
1003#[cfg(test)]
1004mod tests {
1005 use super::*;
1006
1007 fn create_test_request() -> AskUserQuestionsRequest {
1008 AskUserQuestionsRequest {
1009 questions: vec![
1010 Question::SingleChoice {
1011 text: "Choose one".to_string(),
1012 choices: vec!["Option A".to_string(), "Option B".to_string()],
1013 required: true,
1014 },
1015 ],
1016 }
1017 }
1018
1019 #[test]
1020 fn test_panel_activation() {
1021 let mut panel = QuestionPanel::new();
1022 assert!(!panel.is_active());
1023
1024 let request = create_test_request();
1025 panel.activate("tool_123".to_string(), 1, request, None);
1026
1027 assert!(panel.is_active());
1028 assert_eq!(panel.tool_use_id(), "tool_123");
1029 assert_eq!(panel.session_id(), 1);
1030
1031 panel.deactivate();
1032 assert!(!panel.is_active());
1033 }
1034
1035 #[test]
1036 fn test_navigation() {
1037 let mut panel = QuestionPanel::new();
1038 let request = create_test_request();
1039 panel.activate("tool_1".to_string(), 1, request, None);
1040
1041 assert_eq!(
1043 panel.current_focus(),
1044 Some(&FocusItem::Choice {
1045 question_idx: 0,
1046 choice_idx: 0
1047 })
1048 );
1049
1050 panel.focus_next();
1052 assert_eq!(
1053 panel.current_focus(),
1054 Some(&FocusItem::Choice {
1055 question_idx: 0,
1056 choice_idx: 1
1057 })
1058 );
1059
1060 panel.focus_submit();
1062 assert_eq!(panel.current_focus(), Some(&FocusItem::Submit));
1063 }
1064
1065 #[test]
1066 fn test_handle_key_cancel() {
1067 let mut panel = QuestionPanel::new();
1068 let request = create_test_request();
1069 panel.activate("tool_1".to_string(), 1, request, None);
1070
1071 let action = panel.process_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1072 match action {
1073 KeyAction::Cancelled(tool_use_id) => {
1074 assert_eq!(tool_use_id, "tool_1");
1075 }
1076 _ => panic!("Expected Cancelled action"),
1077 }
1078 panel.deactivate();
1080 assert!(!panel.is_active());
1081 }
1082
1083 #[test]
1084 fn test_answer_state_single_choice() {
1085 let mut state = AnswerState::SingleChoice {
1086 selected: None,
1087 other_text: TextArea::default(),
1088 };
1089
1090 assert!(!state.is_selected("Option A"));
1091 state.select_choice("Option A");
1092 assert!(state.is_selected("Option A"));
1093
1094 state.select_choice("Option B");
1096 assert!(!state.is_selected("Option A"));
1097 assert!(state.is_selected("Option B"));
1098 }
1099
1100 #[test]
1101 fn test_answer_state_multi_choice() {
1102 let mut state = AnswerState::MultiChoice {
1103 selected: HashSet::new(),
1104 other_text: TextArea::default(),
1105 };
1106
1107 state.select_choice("Option A");
1108 state.select_choice("Option B");
1109 assert!(state.is_selected("Option A"));
1110 assert!(state.is_selected("Option B"));
1111
1112 state.select_choice("Option A");
1114 assert!(!state.is_selected("Option A"));
1115 assert!(state.is_selected("Option B"));
1116 }
1117}