1use std::collections::HashSet;
20
21use crate::controller::{
22 Answer, AskUserQuestionsRequest, AskUserQuestionsResponse, Question, TurnId,
23};
24use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
25use ratatui::{
26 Frame,
27 layout::Rect,
28 style::{Modifier, Style},
29 text::{Line, Span},
30 widgets::{Block, Borders, Clear, Paragraph},
31};
32use tui_textarea::TextArea;
33
34use crate::themes::Theme;
35
36pub mod defaults {
38 pub const MAX_PANEL_PERCENT: u16 = 70;
40 pub const SELECTION_INDICATOR: &str = " \u{203A} ";
42 pub const NO_INDICATOR: &str = " ";
44 pub const TITLE: &str = " User Input Required ";
46 pub const HELP_TEXT_NAV: &str = " Up/Down: Navigate \u{00B7} Enter/Space: Select \u{00B7} Tab: Next Section \u{00B7} Esc: Cancel";
48 pub const HELP_TEXT_INPUT: &str =
50 " Type text \u{00B7} Enter: Next \u{00B7} Tab: Next Section \u{00B7} Esc: Cancel";
51 pub const QUESTION_PREFIX: &str = " \u{2237} ";
53 pub const RADIO_SELECTED: &str = "\u{25CF}";
55 pub const RADIO_UNSELECTED: &str = "\u{25CB}";
56 pub const CHECKBOX_SELECTED: &str = "\u{25A0}";
58 pub const CHECKBOX_UNSELECTED: &str = "\u{25A1}";
59}
60
61#[derive(Clone)]
63pub struct QuestionPanelConfig {
64 pub max_panel_percent: u16,
66 pub selection_indicator: String,
68 pub no_indicator: String,
70 pub title: String,
72 pub help_text_nav: String,
74 pub help_text_input: String,
76 pub question_prefix: String,
78 pub radio_selected: String,
80 pub radio_unselected: String,
82 pub checkbox_selected: String,
84 pub checkbox_unselected: String,
86}
87
88impl Default for QuestionPanelConfig {
89 fn default() -> Self {
90 Self::new()
91 }
92}
93
94impl QuestionPanelConfig {
95 pub fn new() -> Self {
97 Self {
98 max_panel_percent: defaults::MAX_PANEL_PERCENT,
99 selection_indicator: defaults::SELECTION_INDICATOR.to_string(),
100 no_indicator: defaults::NO_INDICATOR.to_string(),
101 title: defaults::TITLE.to_string(),
102 help_text_nav: defaults::HELP_TEXT_NAV.to_string(),
103 help_text_input: defaults::HELP_TEXT_INPUT.to_string(),
104 question_prefix: defaults::QUESTION_PREFIX.to_string(),
105 radio_selected: defaults::RADIO_SELECTED.to_string(),
106 radio_unselected: defaults::RADIO_UNSELECTED.to_string(),
107 checkbox_selected: defaults::CHECKBOX_SELECTED.to_string(),
108 checkbox_unselected: defaults::CHECKBOX_UNSELECTED.to_string(),
109 }
110 }
111
112 pub fn with_max_panel_percent(mut self, percent: u16) -> Self {
114 self.max_panel_percent = percent;
115 self
116 }
117
118 pub fn with_selection_indicator(mut self, indicator: impl Into<String>) -> Self {
120 self.selection_indicator = indicator.into();
121 self
122 }
123
124 pub fn with_title(mut self, title: impl Into<String>) -> Self {
126 self.title = title.into();
127 self
128 }
129
130 pub fn with_radio_symbols(
132 mut self,
133 selected: impl Into<String>,
134 unselected: impl Into<String>,
135 ) -> Self {
136 self.radio_selected = selected.into();
137 self.radio_unselected = unselected.into();
138 self
139 }
140
141 pub fn with_checkbox_symbols(
143 mut self,
144 selected: impl Into<String>,
145 unselected: impl Into<String>,
146 ) -> Self {
147 self.checkbox_selected = selected.into();
148 self.checkbox_unselected = unselected.into();
149 self
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum FocusItem {
156 Choice {
158 question_idx: usize,
159 choice_idx: usize,
160 },
161 OtherOption { question_idx: usize },
163 OtherText { question_idx: usize },
165 TextInput { question_idx: usize },
167 Submit,
169 Cancel,
171}
172
173pub enum AnswerState {
175 SingleChoice {
177 selected: Option<String>,
178 other_text: TextArea<'static>,
179 },
180 MultiChoice {
182 selected: HashSet<String>,
183 other_text: TextArea<'static>,
184 },
185 FreeText { textarea: TextArea<'static> },
187}
188
189impl AnswerState {
190 pub fn from_question(question: &Question) -> Self {
192 match question {
193 Question::SingleChoice { .. } => {
194 let other_text = TextArea::default();
195 AnswerState::SingleChoice {
196 selected: None,
197 other_text,
198 }
199 }
200 Question::MultiChoice { .. } => {
201 let other_text = TextArea::default();
202 AnswerState::MultiChoice {
203 selected: HashSet::new(),
204 other_text,
205 }
206 }
207 Question::FreeText { default_value, .. } => {
208 let mut textarea = TextArea::default();
209 if let Some(default) = default_value {
210 textarea.insert_str(default);
211 }
212 AnswerState::FreeText { textarea }
213 }
214 }
215 }
216
217 pub fn to_answer(&self, question_text: &str) -> Answer {
219 match self {
220 AnswerState::SingleChoice {
221 selected,
222 other_text,
223 } => {
224 let other = other_text.lines().join("\n");
225 let answer_values = if !other.is_empty() {
227 vec![other]
228 } else {
229 selected.iter().cloned().collect()
230 };
231 Answer {
232 question: question_text.to_string(),
233 answer: answer_values,
234 }
235 }
236 AnswerState::MultiChoice {
237 selected,
238 other_text,
239 } => {
240 let other = other_text.lines().join("\n");
241 let mut answer_values: Vec<String> = selected.iter().cloned().collect();
242 if !other.is_empty() {
244 answer_values.push(other);
245 }
246 Answer {
247 question: question_text.to_string(),
248 answer: answer_values,
249 }
250 }
251 AnswerState::FreeText { textarea } => Answer {
252 question: question_text.to_string(),
253 answer: vec![textarea.lines().join("\n")],
254 },
255 }
256 }
257
258 pub fn is_selected(&self, choice_id: &str) -> bool {
260 match self {
261 AnswerState::SingleChoice { selected, .. } => {
262 selected.as_ref().map(|s| s == choice_id).unwrap_or(false)
263 }
264 AnswerState::MultiChoice { selected, .. } => selected.contains(choice_id),
265 AnswerState::FreeText { .. } => false,
266 }
267 }
268
269 pub fn select_choice(&mut self, choice_id: &str) {
271 match self {
272 AnswerState::SingleChoice { selected, .. } => {
273 *selected = Some(choice_id.to_string());
274 }
275 AnswerState::MultiChoice { selected, .. } => {
276 if selected.contains(choice_id) {
277 selected.remove(choice_id);
278 } else {
279 selected.insert(choice_id.to_string());
280 }
281 }
282 AnswerState::FreeText { .. } => {}
283 }
284 }
285
286 pub fn textarea_mut(&mut self) -> Option<&mut TextArea<'static>> {
288 match self {
289 AnswerState::SingleChoice { other_text, .. }
290 | AnswerState::MultiChoice { other_text, .. } => Some(other_text),
291 AnswerState::FreeText { textarea } => Some(textarea),
292 }
293 }
294
295 pub fn has_other_text(&self) -> bool {
297 match self {
298 AnswerState::SingleChoice { other_text, .. }
299 | AnswerState::MultiChoice { other_text, .. } => {
300 !other_text.lines().join("").is_empty()
301 }
302 AnswerState::FreeText { .. } => false,
303 }
304 }
305
306 pub fn has_answer(&self) -> bool {
308 match self {
309 AnswerState::SingleChoice {
310 selected,
311 other_text,
312 } => selected.is_some() || !other_text.lines().join("").trim().is_empty(),
313 AnswerState::MultiChoice {
314 selected,
315 other_text,
316 } => !selected.is_empty() || !other_text.lines().join("").trim().is_empty(),
317 AnswerState::FreeText { textarea } => !textarea.lines().join("").trim().is_empty(),
318 }
319 }
320}
321
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub enum EnterAction {
325 None,
326 Selected,
327 Submit,
328 Cancel,
329}
330
331#[derive(Debug, Clone)]
333pub enum KeyAction {
334 Handled,
336 NotHandled,
338 Submitted(String, AskUserQuestionsResponse),
340 Cancelled(String),
342}
343
344pub struct QuestionPanel {
346 active: bool,
348 tool_use_id: String,
350 session_id: i64,
352 request: AskUserQuestionsRequest,
354 turn_id: Option<TurnId>,
356 answers: Vec<AnswerState>,
358 focus_items: Vec<FocusItem>,
360 focus_idx: usize,
362 config: QuestionPanelConfig,
364}
365
366impl QuestionPanel {
367 pub fn new() -> Self {
369 Self::with_config(QuestionPanelConfig::new())
370 }
371
372 pub fn with_config(config: QuestionPanelConfig) -> Self {
374 Self {
375 active: false,
376 tool_use_id: String::new(),
377 session_id: 0,
378 request: AskUserQuestionsRequest {
379 questions: Vec::new(),
380 },
381 turn_id: None,
382 answers: Vec::new(),
383 focus_items: Vec::new(),
384 focus_idx: 0,
385 config,
386 }
387 }
388
389 pub fn config(&self) -> &QuestionPanelConfig {
391 &self.config
392 }
393
394 pub fn set_config(&mut self, config: QuestionPanelConfig) {
396 self.config = config;
397 }
398
399 pub fn activate(
401 &mut self,
402 tool_use_id: String,
403 session_id: i64,
404 request: AskUserQuestionsRequest,
405 turn_id: Option<TurnId>,
406 ) {
407 self.active = true;
408 self.tool_use_id = tool_use_id;
409 self.session_id = session_id;
410
411 self.answers = request
413 .questions
414 .iter()
415 .map(AnswerState::from_question)
416 .collect();
417
418 self.focus_items = Self::build_focus_items(&request.questions);
420 self.focus_idx = 0;
421
422 self.request = request;
423 self.turn_id = turn_id;
424 }
425
426 fn build_focus_items(questions: &[Question]) -> Vec<FocusItem> {
428 let mut items = Vec::new();
429
430 for (q_idx, question) in questions.iter().enumerate() {
431 match question {
432 Question::SingleChoice { choices, .. } | Question::MultiChoice { choices, .. } => {
433 for c_idx in 0..choices.len() {
435 items.push(FocusItem::Choice {
436 question_idx: q_idx,
437 choice_idx: c_idx,
438 });
439 }
440 items.push(FocusItem::OtherOption {
442 question_idx: q_idx,
443 });
444 }
445 Question::FreeText { .. } => {
446 items.push(FocusItem::TextInput {
447 question_idx: q_idx,
448 });
449 }
450 }
451 }
452
453 items.push(FocusItem::Submit);
455 items.push(FocusItem::Cancel);
456
457 items
458 }
459
460 pub fn deactivate(&mut self) {
462 self.active = false;
463 self.tool_use_id.clear();
464 self.request.questions.clear();
465 self.answers.clear();
466 self.focus_items.clear();
467 self.focus_idx = 0;
468 }
469
470 pub fn is_active(&self) -> bool {
472 self.active
473 }
474
475 pub fn tool_use_id(&self) -> &str {
477 &self.tool_use_id
478 }
479
480 pub fn session_id(&self) -> i64 {
482 self.session_id
483 }
484
485 pub fn request(&self) -> &AskUserQuestionsRequest {
487 &self.request
488 }
489
490 pub fn turn_id(&self) -> Option<&TurnId> {
492 self.turn_id.as_ref()
493 }
494
495 pub fn build_response(&self) -> AskUserQuestionsResponse {
497 let answers = self
498 .request
499 .questions
500 .iter()
501 .zip(self.answers.iter())
502 .map(|(q, a)| a.to_answer(q.text()))
503 .collect();
504 AskUserQuestionsResponse { answers }
505 }
506
507 pub fn current_focus(&self) -> Option<&FocusItem> {
509 self.focus_items.get(self.focus_idx)
510 }
511
512 pub fn focus_next(&mut self) {
514 if !self.focus_items.is_empty() {
515 self.focus_idx = (self.focus_idx + 1) % self.focus_items.len();
516 }
517 }
518
519 pub fn focus_prev(&mut self) {
521 if !self.focus_items.is_empty() {
522 if self.focus_idx == 0 {
523 self.focus_idx = self.focus_items.len() - 1;
524 } else {
525 self.focus_idx -= 1;
526 }
527 }
528 }
529
530 pub fn focus_submit(&mut self) {
532 if let Some(idx) = self
533 .focus_items
534 .iter()
535 .position(|f| *f == FocusItem::Submit)
536 {
537 self.focus_idx = idx;
538 }
539 }
540
541 pub fn is_text_focused(&self) -> bool {
543 matches!(
544 self.current_focus(),
545 Some(
546 FocusItem::TextInput { .. }
547 | FocusItem::OtherText { .. }
548 | FocusItem::OtherOption { .. }
549 )
550 )
551 }
552
553 fn handle_enter(&mut self) -> EnterAction {
555 match self.current_focus().cloned() {
556 Some(FocusItem::Choice {
557 question_idx,
558 choice_idx,
559 }) => {
560 if let (Some(question), Some(answer)) = (
562 self.request.questions.get(question_idx),
563 self.answers.get_mut(question_idx),
564 ) {
565 let choice_text = match question {
566 Question::SingleChoice { choices, .. }
567 | Question::MultiChoice { choices, .. } => choices.get(choice_idx).cloned(),
568 _ => None,
569 };
570 if let Some(text) = choice_text {
571 answer.select_choice(&text);
572 }
573 }
574 if matches!(
576 self.request.questions.get(question_idx),
577 Some(Question::SingleChoice { .. })
578 ) {
579 self.advance_to_next_question(question_idx);
580 }
581 EnterAction::Selected
582 }
583 Some(FocusItem::OtherOption { question_idx: _ }) => {
584 self.focus_next();
586 EnterAction::Selected
587 }
588 Some(FocusItem::OtherText { question_idx }) => {
589 self.advance_to_next_question(question_idx);
591 EnterAction::Selected
592 }
593 Some(FocusItem::TextInput { question_idx }) => {
594 self.advance_to_next_question(question_idx);
596 EnterAction::Selected
597 }
598 Some(FocusItem::Submit) => {
599 if self.can_submit() {
600 EnterAction::Submit
601 } else {
602 EnterAction::None }
604 }
605 Some(FocusItem::Cancel) => EnterAction::Cancel,
606 None => EnterAction::None,
607 }
608 }
609
610 fn advance_to_next_question(&mut self, current_question_idx: usize) {
612 let next_question_idx = current_question_idx + 1;
613
614 if let Some(idx) = self.focus_items.iter().position(|f| match f {
616 FocusItem::Choice { question_idx, .. }
617 | FocusItem::OtherOption { question_idx }
618 | FocusItem::OtherText { question_idx }
619 | FocusItem::TextInput { question_idx } => *question_idx == next_question_idx,
620 FocusItem::Submit | FocusItem::Cancel => false,
621 }) {
622 self.focus_idx = idx;
623 } else {
624 self.focus_submit();
626 }
627 }
628
629 fn current_question_idx(&self) -> Option<usize> {
631 match self.current_focus() {
632 Some(FocusItem::Choice { question_idx, .. })
633 | Some(FocusItem::OtherOption { question_idx })
634 | Some(FocusItem::OtherText { question_idx })
635 | Some(FocusItem::TextInput { question_idx }) => Some(*question_idx),
636 _ => None,
637 }
638 }
639
640 fn focus_next_section(&mut self) {
643 let current_q = self.current_question_idx();
644 let next_q = current_q.map(|q| q + 1).unwrap_or(0);
645
646 if let Some(idx) = self.focus_items.iter().position(|f| match f {
648 FocusItem::Choice { question_idx, .. }
649 | FocusItem::OtherOption { question_idx }
650 | FocusItem::OtherText { question_idx }
651 | FocusItem::TextInput { question_idx } => *question_idx == next_q,
652 FocusItem::Submit | FocusItem::Cancel => false,
653 }) {
654 self.focus_idx = idx;
655 } else {
656 self.focus_submit();
658 }
659 }
660
661 fn focus_prev_section(&mut self) {
663 let current_q = self.current_question_idx();
664
665 let prev_q = match current_q {
667 Some(q) if q > 0 => q - 1,
668 _ => {
669 let last_q = self.request.questions.len().saturating_sub(1);
671 if current_q == Some(0) {
672 self.focus_submit();
674 return;
675 }
676 last_q
677 }
678 };
679
680 if let Some(idx) = self.focus_items.iter().position(|f| match f {
682 FocusItem::Choice { question_idx, .. }
683 | FocusItem::OtherOption { question_idx }
684 | FocusItem::OtherText { question_idx }
685 | FocusItem::TextInput { question_idx } => *question_idx == prev_q,
686 FocusItem::Submit | FocusItem::Cancel => false,
687 }) {
688 self.focus_idx = idx;
689 }
690 }
691
692 pub fn can_submit(&self) -> bool {
694 for (question, answer) in self.request.questions.iter().zip(self.answers.iter()) {
695 if question.is_required() && !answer.has_answer() {
696 return false;
697 }
698 }
699 true
700 }
701
702 fn handle_text_input(&mut self, key: KeyEvent) {
704 let focus = self.current_focus().cloned();
705 match focus {
706 Some(FocusItem::TextInput { question_idx }) => {
707 if let Some(answer) = self.answers.get_mut(question_idx)
709 && let Some(textarea) = answer.textarea_mut()
710 {
711 textarea.input(key);
712 }
713 }
714 Some(FocusItem::OtherText { question_idx })
715 | Some(FocusItem::OtherOption { question_idx }) => {
716 if let Some(answer) = self.answers.get_mut(question_idx) {
718 if let Some(textarea) = answer.textarea_mut() {
719 textarea.input(key);
720 }
721 if let AnswerState::SingleChoice { selected, .. } = answer {
723 *selected = None;
724 }
725 }
726 }
727 _ => {}
728 }
729 }
730
731 fn handle_space(&mut self) {
733 match self.current_focus().cloned() {
734 Some(FocusItem::Choice {
735 question_idx,
736 choice_idx,
737 }) => {
738 if let (Some(question), Some(answer)) = (
739 self.request.questions.get(question_idx),
740 self.answers.get_mut(question_idx),
741 ) {
742 let choice_text = match question {
743 Question::SingleChoice { choices, .. }
744 | Question::MultiChoice { choices, .. } => choices.get(choice_idx).cloned(),
745 _ => None,
746 };
747 if let Some(text) = choice_text {
748 answer.select_choice(&text);
749 }
750 }
751 }
752 Some(FocusItem::OtherOption { .. }) => {
753 self.focus_next();
755 }
756 _ => {}
757 }
758 }
759
760 pub fn process_key(&mut self, key: KeyEvent) -> KeyAction {
764 if !self.active {
765 return KeyAction::NotHandled;
766 }
767
768 let is_text_mode = self.is_text_focused();
770
771 match key.code {
772 KeyCode::Up => {
774 if !is_text_mode {
775 self.focus_prev();
776 return KeyAction::Handled;
777 }
778 }
779 KeyCode::Down => {
780 if !is_text_mode {
781 self.focus_next();
782 return KeyAction::Handled;
783 }
784 }
785 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
786 self.focus_prev();
787 return KeyAction::Handled;
788 }
789 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
790 self.focus_next();
791 return KeyAction::Handled;
792 }
793 KeyCode::Char('k') if !is_text_mode => {
794 self.focus_prev();
795 return KeyAction::Handled;
796 }
797 KeyCode::Char('j') if !is_text_mode => {
798 self.focus_next();
799 return KeyAction::Handled;
800 }
801
802 KeyCode::Tab => {
804 self.focus_next_section();
805 return KeyAction::Handled;
806 }
807
808 KeyCode::BackTab => {
810 self.focus_prev_section();
811 return KeyAction::Handled;
812 }
813
814 KeyCode::Esc => {
816 let tool_use_id = self.tool_use_id.clone();
817 return KeyAction::Cancelled(tool_use_id);
819 }
820
821 KeyCode::Enter => {
823 match self.handle_enter() {
824 EnterAction::Submit => {
825 let tool_use_id = self.tool_use_id.clone();
826 let response = self.build_response();
827 return KeyAction::Submitted(tool_use_id, response);
829 }
830 EnterAction::Cancel => {
831 let tool_use_id = self.tool_use_id.clone();
832 return KeyAction::Cancelled(tool_use_id);
834 }
835 EnterAction::Selected | EnterAction::None => {
836 return KeyAction::Handled;
837 }
838 }
839 }
840
841 KeyCode::Char(' ') if !is_text_mode => {
843 self.handle_space();
844 return KeyAction::Handled;
845 }
846
847 _ if is_text_mode => {
849 self.handle_text_input(key);
850 return KeyAction::Handled;
851 }
852
853 _ => {}
854 }
855
856 KeyAction::NotHandled
857 }
858
859 pub fn panel_height(&self, max_height: u16) -> u16 {
861 let mut lines = 0u16;
863
864 for question in &self.request.questions {
865 lines += 1; match question {
867 Question::SingleChoice { choices, .. } | Question::MultiChoice { choices, .. } => {
868 lines += choices.len() as u16;
869 lines += 1; }
871 Question::FreeText { .. } => {
872 lines += 1; }
874 }
875 }
876
877 let num_questions = self.request.questions.len() as u16;
879 let spacing = num_questions.saturating_sub(1);
880 let total = lines + spacing + 7;
881
882 let max_from_percent = (max_height * self.config.max_panel_percent) / 100;
884 total
885 .min(max_from_percent)
886 .min(max_height.saturating_sub(6))
887 }
888
889 pub fn render_panel(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
896 if !self.active {
897 return;
898 }
899
900 frame.render_widget(Clear, area);
902
903 self.render_panel_content(frame, area, theme);
905 }
906
907 fn render_panel_content(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
909 let inner_width = area.width.saturating_sub(4) as usize;
910 let mut lines: Vec<Line> = Vec::new();
911
912 let help_text = if self.is_text_focused() {
914 &self.config.help_text_input
915 } else {
916 &self.config.help_text_nav
917 };
918 lines.push(Line::from(Span::styled(
919 help_text.clone(),
920 theme.help_text(),
921 )));
922 lines.push(Line::from("")); for (q_idx, (question, answer)) in self
926 .request
927 .questions
928 .iter()
929 .zip(self.answers.iter())
930 .enumerate()
931 {
932 if q_idx > 0 {
934 lines.push(Line::from(""));
935 }
936
937 let required = if question.is_required() { "*" } else { "" };
939 let q_text = format!(
940 "{}{}{}",
941 self.config.question_prefix,
942 question.text(),
943 required
944 );
945 lines.push(Line::from(Span::styled(
946 truncate_text(&q_text, inner_width),
947 Style::default().add_modifier(Modifier::BOLD),
948 )));
949
950 match question {
951 Question::SingleChoice { choices, .. } => {
952 self.render_choices(
953 &mut lines,
954 q_idx,
955 choices,
956 answer,
957 false,
958 inner_width,
959 theme,
960 );
961 }
962 Question::MultiChoice { choices, .. } => {
963 self.render_choices(
964 &mut lines,
965 q_idx,
966 choices,
967 answer,
968 true,
969 inner_width,
970 theme,
971 );
972 }
973 Question::FreeText { .. } => {
974 self.render_text_input(&mut lines, q_idx, answer, inner_width, theme);
975 }
976 }
977 }
978
979 lines.push(Line::from(""));
981
982 let submit_focused = self.current_focus() == Some(&FocusItem::Submit);
984 let cancel_focused = self.current_focus() == Some(&FocusItem::Cancel);
985 let submit_enabled = self.can_submit();
986
987 let submit_style = if !submit_enabled {
988 theme.muted_text()
989 } else if submit_focused {
990 theme.button_confirm_focused()
991 } else {
992 theme.button_confirm()
993 };
994 let cancel_style = if cancel_focused {
995 theme.button_cancel_focused()
996 } else {
997 theme.button_cancel()
998 };
999
1000 let mut button_spans = vec![Span::raw(" ")];
1001
1002 if submit_focused && submit_enabled {
1004 button_spans.push(Span::styled("\u{203A} ", theme.focus_indicator()));
1005 }
1006 button_spans.push(Span::styled("Submit", submit_style));
1007 button_spans.push(Span::raw(" "));
1008
1009 if cancel_focused {
1011 button_spans.push(Span::styled("\u{203A} ", theme.focus_indicator()));
1012 }
1013 button_spans.push(Span::styled("Cancel", cancel_style));
1014
1015 lines.push(Line::from(button_spans));
1016
1017 let block = Block::default()
1018 .borders(Borders::ALL)
1019 .border_style(theme.warning())
1020 .title(Span::styled(
1021 self.config.title.clone(),
1022 theme.warning().add_modifier(Modifier::BOLD),
1023 ));
1024
1025 let paragraph = Paragraph::new(lines).block(block);
1026 frame.render_widget(paragraph, area);
1027 }
1028
1029 #[allow(clippy::too_many_arguments)]
1031 fn render_choices(
1032 &self,
1033 lines: &mut Vec<Line>,
1034 question_idx: usize,
1035 choices: &[String],
1036 answer: &AnswerState,
1037 is_multi: bool,
1038 inner_width: usize,
1039 theme: &Theme,
1040 ) {
1041 for (c_idx, choice_text) in choices.iter().enumerate() {
1042 let is_focused = self.current_focus()
1043 == Some(&FocusItem::Choice {
1044 question_idx,
1045 choice_idx: c_idx,
1046 });
1047 let is_selected = answer.is_selected(choice_text);
1048
1049 let symbol = if is_multi {
1050 if is_selected {
1051 &self.config.checkbox_selected
1052 } else {
1053 &self.config.checkbox_unselected
1054 }
1055 } else if is_selected {
1056 &self.config.radio_selected
1057 } else {
1058 &self.config.radio_unselected
1059 };
1060
1061 let prefix = if is_focused {
1062 &self.config.selection_indicator
1063 } else {
1064 &self.config.no_indicator
1065 };
1066 let display_text = truncate_text(choice_text, inner_width - 8);
1067
1068 if is_focused {
1069 lines.push(Line::from(vec![
1070 Span::styled(prefix.clone(), theme.focus_indicator()),
1071 Span::styled(format!("{} {}", symbol, display_text), theme.focused_text()),
1072 ]));
1073 } else {
1074 lines.push(Line::from(Span::styled(
1075 format!("{}{} {}", prefix, symbol, display_text),
1076 theme.muted_text(),
1077 )));
1078 }
1079 }
1080
1081 let other_focused = self.current_focus() == Some(&FocusItem::OtherOption { question_idx });
1083 let other_text_focused =
1084 self.current_focus() == Some(&FocusItem::OtherText { question_idx });
1085 let is_this_focused = other_focused || other_text_focused;
1086 let has_other = answer.has_other_text();
1087
1088 let is_other_selected = match answer {
1092 AnswerState::SingleChoice { selected, .. } => {
1093 selected.is_none() && (has_other || is_this_focused)
1094 }
1095 AnswerState::MultiChoice { .. } => has_other,
1096 _ => false,
1097 };
1098
1099 let symbol = if is_multi {
1100 if is_other_selected {
1101 &self.config.checkbox_selected
1102 } else {
1103 &self.config.checkbox_unselected
1104 }
1105 } else if is_other_selected {
1106 &self.config.radio_selected
1107 } else {
1108 &self.config.radio_unselected
1109 };
1110
1111 let prefix = if is_this_focused {
1112 &self.config.selection_indicator
1113 } else {
1114 &self.config.no_indicator
1115 };
1116
1117 let (other_text, cursor_col) = match answer {
1119 AnswerState::SingleChoice { other_text, .. }
1120 | AnswerState::MultiChoice { other_text, .. } => {
1121 let text = other_text.lines().first().cloned().unwrap_or_default();
1122 let col = other_text.cursor().1;
1123 (text, col)
1124 }
1125 _ => (String::new(), 0),
1126 };
1127
1128 if is_this_focused {
1130 let chars: Vec<char> = other_text.chars().collect();
1131 let cursor_pos = cursor_col.min(chars.len());
1132 let before: String = chars[..cursor_pos].iter().collect();
1133 let cursor_char = chars.get(cursor_pos).copied().unwrap_or(' ');
1134 let after: String = chars
1135 .get(cursor_pos + 1..)
1136 .map(|s| s.iter().collect())
1137 .unwrap_or_default();
1138
1139 lines.push(Line::from(vec![
1140 Span::styled(prefix.clone(), theme.focus_indicator()),
1141 Span::styled(format!("{} Type Something: ", symbol), theme.focused_text()),
1142 Span::styled(before, theme.focused_text()),
1143 Span::styled(cursor_char.to_string(), theme.cursor()),
1144 Span::styled(after, theme.focused_text()),
1145 ]));
1146 } else {
1147 let truncated_display = truncate_text(&other_text, inner_width - 24);
1148 lines.push(Line::from(Span::styled(
1149 format!("{}{} Type Something: {}", prefix, symbol, truncated_display),
1150 theme.muted_text(),
1151 )));
1152 }
1153 }
1154
1155 fn render_text_input(
1157 &self,
1158 lines: &mut Vec<Line>,
1159 question_idx: usize,
1160 answer: &AnswerState,
1161 inner_width: usize,
1162 theme: &Theme,
1163 ) {
1164 let is_focused = self.current_focus() == Some(&FocusItem::TextInput { question_idx });
1165
1166 let (text, cursor_col) = match answer {
1167 AnswerState::FreeText { textarea } => {
1168 let t = textarea.lines().first().cloned().unwrap_or_default();
1169 let col = textarea.cursor().1;
1170 (t, col)
1171 }
1172 _ => (String::new(), 0),
1173 };
1174
1175 let prefix = if is_focused {
1176 &self.config.selection_indicator
1177 } else {
1178 &self.config.no_indicator
1179 };
1180
1181 if is_focused {
1182 let chars: Vec<char> = text.chars().collect();
1184 let cursor_pos = cursor_col.min(chars.len());
1185 let before: String = chars[..cursor_pos].iter().collect();
1186 let cursor_char = chars.get(cursor_pos).copied().unwrap_or(' ');
1187 let after: String = chars
1188 .get(cursor_pos + 1..)
1189 .map(|s| s.iter().collect())
1190 .unwrap_or_default();
1191
1192 lines.push(Line::from(vec![
1193 Span::styled(prefix.clone(), theme.focus_indicator()),
1194 Span::styled("Type Something: ", theme.focused_text()),
1195 Span::styled(before, theme.focused_text()),
1196 Span::styled(cursor_char.to_string(), theme.cursor()),
1197 Span::styled(after, theme.focused_text()),
1198 ]));
1199 } else {
1200 let display = if text.is_empty() {
1201 "Type Something:".to_string()
1202 } else {
1203 format!("Type Something: {}", text)
1204 };
1205 let truncated = truncate_text(&display, inner_width - 4);
1206 lines.push(Line::from(Span::styled(
1207 format!("{}{}", prefix, truncated),
1208 theme.muted_text(),
1209 )));
1210 }
1211 }
1212}
1213
1214impl Default for QuestionPanel {
1215 fn default() -> Self {
1216 Self::new()
1217 }
1218}
1219
1220use super::{Widget, WidgetAction, WidgetKeyContext, WidgetKeyResult, widget_ids};
1223use std::any::Any;
1224
1225impl Widget for QuestionPanel {
1226 fn id(&self) -> &'static str {
1227 widget_ids::QUESTION_PANEL
1228 }
1229
1230 fn priority(&self) -> u8 {
1231 200 }
1233
1234 fn is_active(&self) -> bool {
1235 self.active
1236 }
1237
1238 fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
1239 if !self.active {
1240 return WidgetKeyResult::NotHandled;
1241 }
1242
1243 let is_text_mode = self.is_text_focused();
1244
1245 if !is_text_mode {
1247 if ctx.nav.is_move_up(&key) {
1248 self.focus_prev();
1249 return WidgetKeyResult::Handled;
1250 }
1251 if ctx.nav.is_move_down(&key) {
1252 self.focus_next();
1253 return WidgetKeyResult::Handled;
1254 }
1255 }
1256
1257 if key.code == KeyCode::Char('p') && key.modifiers.contains(KeyModifiers::CONTROL) {
1259 self.focus_prev();
1260 return WidgetKeyResult::Handled;
1261 }
1262 if key.code == KeyCode::Char('n') && key.modifiers.contains(KeyModifiers::CONTROL) {
1263 self.focus_next();
1264 return WidgetKeyResult::Handled;
1265 }
1266
1267 if ctx.nav.is_cancel(&key) {
1269 let tool_use_id = self.tool_use_id.clone();
1270 return WidgetKeyResult::Action(WidgetAction::CancelQuestion { tool_use_id });
1271 }
1272
1273 match self.process_key(key) {
1275 KeyAction::Submitted(tool_use_id, response) => {
1276 WidgetKeyResult::Action(WidgetAction::SubmitQuestion {
1277 tool_use_id,
1278 response,
1279 })
1280 }
1281 KeyAction::Cancelled(tool_use_id) => {
1282 WidgetKeyResult::Action(WidgetAction::CancelQuestion { tool_use_id })
1283 }
1284 KeyAction::Handled | KeyAction::NotHandled => WidgetKeyResult::Handled,
1285 }
1286 }
1287
1288 fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
1289 self.render_panel(frame, area, theme);
1290 }
1291
1292 fn required_height(&self, max_height: u16) -> u16 {
1293 if self.active {
1294 self.panel_height(max_height)
1295 } else {
1296 0
1297 }
1298 }
1299
1300 fn blocks_input(&self) -> bool {
1301 self.active
1302 }
1303
1304 fn is_overlay(&self) -> bool {
1305 false
1306 }
1307
1308 fn as_any(&self) -> &dyn Any {
1309 self
1310 }
1311
1312 fn as_any_mut(&mut self) -> &mut dyn Any {
1313 self
1314 }
1315
1316 fn into_any(self: Box<Self>) -> Box<dyn Any> {
1317 self
1318 }
1319}
1320
1321fn truncate_text(text: &str, max_width: usize) -> String {
1323 if text.chars().count() <= max_width {
1324 text.to_string()
1325 } else {
1326 let truncated: String = text.chars().take(max_width.saturating_sub(3)).collect();
1327 format!("{}...", truncated)
1328 }
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333 use super::*;
1334
1335 fn create_test_request() -> AskUserQuestionsRequest {
1336 AskUserQuestionsRequest {
1337 questions: vec![Question::SingleChoice {
1338 text: "Choose one".to_string(),
1339 choices: vec!["Option A".to_string(), "Option B".to_string()],
1340 required: true,
1341 }],
1342 }
1343 }
1344
1345 #[test]
1346 fn test_panel_activation() {
1347 let mut panel = QuestionPanel::new();
1348 assert!(!panel.is_active());
1349
1350 let request = create_test_request();
1351 panel.activate("tool_123".to_string(), 1, request, None);
1352
1353 assert!(panel.is_active());
1354 assert_eq!(panel.tool_use_id(), "tool_123");
1355 assert_eq!(panel.session_id(), 1);
1356
1357 panel.deactivate();
1358 assert!(!panel.is_active());
1359 }
1360
1361 #[test]
1362 fn test_navigation() {
1363 let mut panel = QuestionPanel::new();
1364 let request = create_test_request();
1365 panel.activate("tool_1".to_string(), 1, request, None);
1366
1367 assert_eq!(
1369 panel.current_focus(),
1370 Some(&FocusItem::Choice {
1371 question_idx: 0,
1372 choice_idx: 0
1373 })
1374 );
1375
1376 panel.focus_next();
1378 assert_eq!(
1379 panel.current_focus(),
1380 Some(&FocusItem::Choice {
1381 question_idx: 0,
1382 choice_idx: 1
1383 })
1384 );
1385
1386 panel.focus_submit();
1388 assert_eq!(panel.current_focus(), Some(&FocusItem::Submit));
1389 }
1390
1391 #[test]
1392 fn test_handle_key_cancel() {
1393 let mut panel = QuestionPanel::new();
1394 let request = create_test_request();
1395 panel.activate("tool_1".to_string(), 1, request, None);
1396
1397 let action = panel.process_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1398 match action {
1399 KeyAction::Cancelled(tool_use_id) => {
1400 assert_eq!(tool_use_id, "tool_1");
1401 }
1402 _ => panic!("Expected Cancelled action"),
1403 }
1404 panel.deactivate();
1406 assert!(!panel.is_active());
1407 }
1408
1409 #[test]
1410 fn test_answer_state_single_choice() {
1411 let mut state = AnswerState::SingleChoice {
1412 selected: None,
1413 other_text: TextArea::default(),
1414 };
1415
1416 assert!(!state.is_selected("Option A"));
1417 state.select_choice("Option A");
1418 assert!(state.is_selected("Option A"));
1419
1420 state.select_choice("Option B");
1422 assert!(!state.is_selected("Option A"));
1423 assert!(state.is_selected("Option B"));
1424 }
1425
1426 #[test]
1427 fn test_answer_state_multi_choice() {
1428 let mut state = AnswerState::MultiChoice {
1429 selected: HashSet::new(),
1430 other_text: TextArea::default(),
1431 };
1432
1433 state.select_choice("Option A");
1434 state.select_choice("Option B");
1435 assert!(state.is_selected("Option A"));
1436 assert!(state.is_selected("Option B"));
1437
1438 state.select_choice("Option A");
1440 assert!(!state.is_selected("Option A"));
1441 assert!(state.is_selected("Option B"));
1442 }
1443}