1use std::collections::HashSet;
20
21use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
22use crate::controller::{
23 Answer, AskUserQuestionsRequest, AskUserQuestionsResponse, Question, TurnId,
24};
25use ratatui::{
26 layout::Rect,
27 style::{Modifier, Style},
28 text::{Line, Span},
29 widgets::{Block, Borders, Clear, Paragraph},
30 Frame,
31};
32use tui_textarea::TextArea;
33
34use crate::tui::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 = " Type text \u{00B7} Enter: Next \u{00B7} Tab: Next Section \u{00B7} Esc: Cancel";
50 pub const QUESTION_PREFIX: &str = " \u{2237} ";
52 pub const RADIO_SELECTED: &str = "\u{25CF}";
54 pub const RADIO_UNSELECTED: &str = "\u{25CB}";
55 pub const CHECKBOX_SELECTED: &str = "\u{25A0}";
57 pub const CHECKBOX_UNSELECTED: &str = "\u{25A1}";
58}
59
60#[derive(Clone)]
62pub struct QuestionPanelConfig {
63 pub max_panel_percent: u16,
65 pub selection_indicator: String,
67 pub no_indicator: String,
69 pub title: String,
71 pub help_text_nav: String,
73 pub help_text_input: String,
75 pub question_prefix: String,
77 pub radio_selected: String,
79 pub radio_unselected: String,
81 pub checkbox_selected: String,
83 pub checkbox_unselected: String,
85}
86
87impl Default for QuestionPanelConfig {
88 fn default() -> Self {
89 Self::new()
90 }
91}
92
93impl QuestionPanelConfig {
94 pub fn new() -> Self {
96 Self {
97 max_panel_percent: defaults::MAX_PANEL_PERCENT,
98 selection_indicator: defaults::SELECTION_INDICATOR.to_string(),
99 no_indicator: defaults::NO_INDICATOR.to_string(),
100 title: defaults::TITLE.to_string(),
101 help_text_nav: defaults::HELP_TEXT_NAV.to_string(),
102 help_text_input: defaults::HELP_TEXT_INPUT.to_string(),
103 question_prefix: defaults::QUESTION_PREFIX.to_string(),
104 radio_selected: defaults::RADIO_SELECTED.to_string(),
105 radio_unselected: defaults::RADIO_UNSELECTED.to_string(),
106 checkbox_selected: defaults::CHECKBOX_SELECTED.to_string(),
107 checkbox_unselected: defaults::CHECKBOX_UNSELECTED.to_string(),
108 }
109 }
110
111 pub fn with_max_panel_percent(mut self, percent: u16) -> Self {
113 self.max_panel_percent = percent;
114 self
115 }
116
117 pub fn with_selection_indicator(mut self, indicator: impl Into<String>) -> Self {
119 self.selection_indicator = indicator.into();
120 self
121 }
122
123 pub fn with_title(mut self, title: impl Into<String>) -> Self {
125 self.title = title.into();
126 self
127 }
128
129 pub fn with_radio_symbols(mut self, selected: impl Into<String>, unselected: impl Into<String>) -> Self {
131 self.radio_selected = selected.into();
132 self.radio_unselected = unselected.into();
133 self
134 }
135
136 pub fn with_checkbox_symbols(mut self, selected: impl Into<String>, unselected: impl Into<String>) -> Self {
138 self.checkbox_selected = selected.into();
139 self.checkbox_unselected = unselected.into();
140 self
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum FocusItem {
147 Choice {
149 question_idx: usize,
150 choice_idx: usize,
151 },
152 OtherOption { question_idx: usize },
154 OtherText { question_idx: usize },
156 TextInput { question_idx: usize },
158 Submit,
160 Cancel,
162}
163
164pub enum AnswerState {
166 SingleChoice {
168 selected: Option<String>,
169 other_text: TextArea<'static>,
170 },
171 MultiChoice {
173 selected: HashSet<String>,
174 other_text: TextArea<'static>,
175 },
176 FreeText { textarea: TextArea<'static> },
178}
179
180impl AnswerState {
181 pub fn from_question(question: &Question) -> Self {
183 match question {
184 Question::SingleChoice { .. } => {
185 let other_text = TextArea::default();
186 AnswerState::SingleChoice {
187 selected: None,
188 other_text,
189 }
190 }
191 Question::MultiChoice { .. } => {
192 let other_text = TextArea::default();
193 AnswerState::MultiChoice {
194 selected: HashSet::new(),
195 other_text,
196 }
197 }
198 Question::FreeText { default_value, .. } => {
199 let mut textarea = TextArea::default();
200 if let Some(default) = default_value {
201 textarea.insert_str(default);
202 }
203 AnswerState::FreeText { textarea }
204 }
205 }
206 }
207
208 pub fn to_answer(&self, question_text: &str) -> Answer {
210 match self {
211 AnswerState::SingleChoice {
212 selected,
213 other_text,
214 } => {
215 let other = other_text.lines().join("\n");
216 let answer_values = if !other.is_empty() {
218 vec![other]
219 } else {
220 selected.iter().cloned().collect()
221 };
222 Answer {
223 question: question_text.to_string(),
224 answer: answer_values,
225 }
226 }
227 AnswerState::MultiChoice {
228 selected,
229 other_text,
230 } => {
231 let other = other_text.lines().join("\n");
232 let mut answer_values: Vec<String> = selected.iter().cloned().collect();
233 if !other.is_empty() {
235 answer_values.push(other);
236 }
237 Answer {
238 question: question_text.to_string(),
239 answer: answer_values,
240 }
241 }
242 AnswerState::FreeText { textarea } => Answer {
243 question: question_text.to_string(),
244 answer: vec![textarea.lines().join("\n")],
245 },
246 }
247 }
248
249 pub fn is_selected(&self, choice_id: &str) -> bool {
251 match self {
252 AnswerState::SingleChoice { selected, .. } => {
253 selected.as_ref().map(|s| s == choice_id).unwrap_or(false)
254 }
255 AnswerState::MultiChoice { selected, .. } => selected.contains(choice_id),
256 AnswerState::FreeText { .. } => false,
257 }
258 }
259
260 pub fn select_choice(&mut self, choice_id: &str) {
262 match self {
263 AnswerState::SingleChoice { selected, .. } => {
264 *selected = Some(choice_id.to_string());
265 }
266 AnswerState::MultiChoice { selected, .. } => {
267 if selected.contains(choice_id) {
268 selected.remove(choice_id);
269 } else {
270 selected.insert(choice_id.to_string());
271 }
272 }
273 AnswerState::FreeText { .. } => {}
274 }
275 }
276
277 pub fn textarea_mut(&mut self) -> Option<&mut TextArea<'static>> {
279 match self {
280 AnswerState::SingleChoice { other_text, .. }
281 | AnswerState::MultiChoice { other_text, .. } => Some(other_text),
282 AnswerState::FreeText { textarea } => Some(textarea),
283 }
284 }
285
286 pub fn has_other_text(&self) -> bool {
288 match self {
289 AnswerState::SingleChoice { other_text, .. }
290 | AnswerState::MultiChoice { other_text, .. } => {
291 !other_text.lines().join("").is_empty()
292 }
293 AnswerState::FreeText { .. } => false,
294 }
295 }
296
297 pub fn has_answer(&self) -> bool {
299 match self {
300 AnswerState::SingleChoice { selected, other_text } => {
301 selected.is_some() || !other_text.lines().join("").trim().is_empty()
302 }
303 AnswerState::MultiChoice { selected, other_text } => {
304 !selected.is_empty() || !other_text.lines().join("").trim().is_empty()
305 }
306 AnswerState::FreeText { textarea } => {
307 !textarea.lines().join("").trim().is_empty()
308 }
309 }
310 }
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub enum EnterAction {
316 None,
317 Selected,
318 Submit,
319 Cancel,
320}
321
322#[derive(Debug, Clone)]
324pub enum KeyAction {
325 Handled,
327 NotHandled,
329 Submitted(String, AskUserQuestionsResponse),
331 Cancelled(String),
333}
334
335pub struct QuestionPanel {
337 active: bool,
339 tool_use_id: String,
341 session_id: i64,
343 request: AskUserQuestionsRequest,
345 turn_id: Option<TurnId>,
347 answers: Vec<AnswerState>,
349 focus_items: Vec<FocusItem>,
351 focus_idx: usize,
353 config: QuestionPanelConfig,
355}
356
357impl QuestionPanel {
358 pub fn new() -> Self {
360 Self::with_config(QuestionPanelConfig::new())
361 }
362
363 pub fn with_config(config: QuestionPanelConfig) -> Self {
365 Self {
366 active: false,
367 tool_use_id: String::new(),
368 session_id: 0,
369 request: AskUserQuestionsRequest {
370 questions: Vec::new(),
371 },
372 turn_id: None,
373 answers: Vec::new(),
374 focus_items: Vec::new(),
375 focus_idx: 0,
376 config,
377 }
378 }
379
380 pub fn config(&self) -> &QuestionPanelConfig {
382 &self.config
383 }
384
385 pub fn set_config(&mut self, config: QuestionPanelConfig) {
387 self.config = config;
388 }
389
390 pub fn activate(
392 &mut self,
393 tool_use_id: String,
394 session_id: i64,
395 request: AskUserQuestionsRequest,
396 turn_id: Option<TurnId>,
397 ) {
398 self.active = true;
399 self.tool_use_id = tool_use_id;
400 self.session_id = session_id;
401
402 self.answers = request
404 .questions
405 .iter()
406 .map(AnswerState::from_question)
407 .collect();
408
409 self.focus_items = Self::build_focus_items(&request.questions);
411 self.focus_idx = 0;
412
413 self.request = request;
414 self.turn_id = turn_id;
415 }
416
417 fn build_focus_items(questions: &[Question]) -> Vec<FocusItem> {
419 let mut items = Vec::new();
420
421 for (q_idx, question) in questions.iter().enumerate() {
422 match question {
423 Question::SingleChoice { choices, .. } | Question::MultiChoice { choices, .. } => {
424 for c_idx in 0..choices.len() {
426 items.push(FocusItem::Choice {
427 question_idx: q_idx,
428 choice_idx: c_idx,
429 });
430 }
431 items.push(FocusItem::OtherOption { question_idx: q_idx });
433 }
434 Question::FreeText { .. } => {
435 items.push(FocusItem::TextInput { question_idx: q_idx });
436 }
437 }
438 }
439
440 items.push(FocusItem::Submit);
442 items.push(FocusItem::Cancel);
443
444 items
445 }
446
447 pub fn deactivate(&mut self) {
449 self.active = false;
450 self.tool_use_id.clear();
451 self.request.questions.clear();
452 self.answers.clear();
453 self.focus_items.clear();
454 self.focus_idx = 0;
455 }
456
457 pub fn is_active(&self) -> bool {
459 self.active
460 }
461
462 pub fn tool_use_id(&self) -> &str {
464 &self.tool_use_id
465 }
466
467 pub fn session_id(&self) -> i64 {
469 self.session_id
470 }
471
472 pub fn request(&self) -> &AskUserQuestionsRequest {
474 &self.request
475 }
476
477 pub fn turn_id(&self) -> Option<&TurnId> {
479 self.turn_id.as_ref()
480 }
481
482 pub fn build_response(&self) -> AskUserQuestionsResponse {
484 let answers = self
485 .request
486 .questions
487 .iter()
488 .zip(self.answers.iter())
489 .map(|(q, a)| a.to_answer(q.text()))
490 .collect();
491 AskUserQuestionsResponse { answers }
492 }
493
494 pub fn current_focus(&self) -> Option<&FocusItem> {
496 self.focus_items.get(self.focus_idx)
497 }
498
499 pub fn focus_next(&mut self) {
501 if !self.focus_items.is_empty() {
502 self.focus_idx = (self.focus_idx + 1) % self.focus_items.len();
503 }
504 }
505
506 pub fn focus_prev(&mut self) {
508 if !self.focus_items.is_empty() {
509 if self.focus_idx == 0 {
510 self.focus_idx = self.focus_items.len() - 1;
511 } else {
512 self.focus_idx -= 1;
513 }
514 }
515 }
516
517 pub fn focus_submit(&mut self) {
519 if let Some(idx) = self.focus_items.iter().position(|f| *f == FocusItem::Submit) {
520 self.focus_idx = idx;
521 }
522 }
523
524 pub fn is_text_focused(&self) -> bool {
526 matches!(
527 self.current_focus(),
528 Some(FocusItem::TextInput { .. } | FocusItem::OtherText { .. } | FocusItem::OtherOption { .. })
529 )
530 }
531
532 fn handle_enter(&mut self) -> EnterAction {
534 match self.current_focus().cloned() {
535 Some(FocusItem::Choice {
536 question_idx,
537 choice_idx,
538 }) => {
539 if let (Some(question), Some(answer)) = (
541 self.request.questions.get(question_idx),
542 self.answers.get_mut(question_idx),
543 ) {
544 let choice_text = match question {
545 Question::SingleChoice { choices, .. }
546 | Question::MultiChoice { choices, .. } => {
547 choices.get(choice_idx).cloned()
548 }
549 _ => None,
550 };
551 if let Some(text) = choice_text {
552 answer.select_choice(&text);
553 }
554 }
555 if matches!(
557 self.request.questions.get(question_idx),
558 Some(Question::SingleChoice { .. })
559 ) {
560 self.advance_to_next_question(question_idx);
561 }
562 EnterAction::Selected
563 }
564 Some(FocusItem::OtherOption { question_idx: _ }) => {
565 self.focus_next();
567 EnterAction::Selected
568 }
569 Some(FocusItem::OtherText { question_idx }) => {
570 self.advance_to_next_question(question_idx);
572 EnterAction::Selected
573 }
574 Some(FocusItem::TextInput { question_idx }) => {
575 self.advance_to_next_question(question_idx);
577 EnterAction::Selected
578 }
579 Some(FocusItem::Submit) => {
580 if self.can_submit() {
581 EnterAction::Submit
582 } else {
583 EnterAction::None }
585 }
586 Some(FocusItem::Cancel) => EnterAction::Cancel,
587 None => EnterAction::None,
588 }
589 }
590
591 fn advance_to_next_question(&mut self, current_question_idx: usize) {
593 let next_question_idx = current_question_idx + 1;
594
595 if let Some(idx) = self.focus_items.iter().position(|f| match f {
597 FocusItem::Choice { question_idx, .. }
598 | FocusItem::OtherOption { question_idx }
599 | FocusItem::OtherText { question_idx }
600 | FocusItem::TextInput { question_idx } => *question_idx == next_question_idx,
601 FocusItem::Submit | FocusItem::Cancel => false,
602 }) {
603 self.focus_idx = idx;
604 } else {
605 self.focus_submit();
607 }
608 }
609
610 fn current_question_idx(&self) -> Option<usize> {
612 match self.current_focus() {
613 Some(FocusItem::Choice { question_idx, .. })
614 | Some(FocusItem::OtherOption { question_idx })
615 | Some(FocusItem::OtherText { question_idx })
616 | Some(FocusItem::TextInput { question_idx }) => Some(*question_idx),
617 _ => None,
618 }
619 }
620
621 fn focus_next_section(&mut self) {
624 let current_q = self.current_question_idx();
625 let next_q = current_q.map(|q| q + 1).unwrap_or(0);
626
627 if let Some(idx) = self.focus_items.iter().position(|f| match f {
629 FocusItem::Choice { question_idx, .. }
630 | FocusItem::OtherOption { question_idx }
631 | FocusItem::OtherText { question_idx }
632 | FocusItem::TextInput { question_idx } => *question_idx == next_q,
633 FocusItem::Submit | FocusItem::Cancel => false,
634 }) {
635 self.focus_idx = idx;
636 } else {
637 self.focus_submit();
639 }
640 }
641
642 fn focus_prev_section(&mut self) {
644 let current_q = self.current_question_idx();
645
646 let prev_q = match current_q {
648 Some(q) if q > 0 => q - 1,
649 _ => {
650 let last_q = self.request.questions.len().saturating_sub(1);
652 if current_q == Some(0) {
653 self.focus_submit();
655 return;
656 }
657 last_q
658 }
659 };
660
661 if let Some(idx) = self.focus_items.iter().position(|f| match f {
663 FocusItem::Choice { question_idx, .. }
664 | FocusItem::OtherOption { question_idx }
665 | FocusItem::OtherText { question_idx }
666 | FocusItem::TextInput { question_idx } => *question_idx == prev_q,
667 FocusItem::Submit | FocusItem::Cancel => false,
668 }) {
669 self.focus_idx = idx;
670 }
671 }
672
673 pub fn can_submit(&self) -> bool {
675 for (question, answer) in self.request.questions.iter().zip(self.answers.iter()) {
676 if question.is_required() && !answer.has_answer() {
677 return false;
678 }
679 }
680 true
681 }
682
683 fn handle_text_input(&mut self, key: KeyEvent) {
685 let focus = self.current_focus().cloned();
686 match focus {
687 Some(FocusItem::TextInput { question_idx }) => {
688 if let Some(answer) = self.answers.get_mut(question_idx) {
690 if let Some(textarea) = answer.textarea_mut() {
691 textarea.input(key);
692 }
693 }
694 }
695 Some(FocusItem::OtherText { question_idx })
696 | Some(FocusItem::OtherOption { question_idx }) => {
697 if let Some(answer) = self.answers.get_mut(question_idx) {
699 if let Some(textarea) = answer.textarea_mut() {
700 textarea.input(key);
701 }
702 if let AnswerState::SingleChoice { selected, .. } = answer {
704 *selected = None;
705 }
706 }
707 }
708 _ => {}
709 }
710 }
711
712 fn handle_space(&mut self) {
714 match self.current_focus().cloned() {
715 Some(FocusItem::Choice {
716 question_idx,
717 choice_idx,
718 }) => {
719 if let (Some(question), Some(answer)) = (
720 self.request.questions.get(question_idx),
721 self.answers.get_mut(question_idx),
722 ) {
723 let choice_text = match question {
724 Question::SingleChoice { choices, .. }
725 | Question::MultiChoice { choices, .. } => {
726 choices.get(choice_idx).cloned()
727 }
728 _ => None,
729 };
730 if let Some(text) = choice_text {
731 answer.select_choice(&text);
732 }
733 }
734 }
735 Some(FocusItem::OtherOption { .. }) => {
736 self.focus_next();
738 }
739 _ => {}
740 }
741 }
742
743 pub fn process_key(&mut self, key: KeyEvent) -> KeyAction {
747 if !self.active {
748 return KeyAction::NotHandled;
749 }
750
751 let is_text_mode = self.is_text_focused();
753
754 match key.code {
755 KeyCode::Up => {
757 if !is_text_mode {
758 self.focus_prev();
759 return KeyAction::Handled;
760 }
761 }
762 KeyCode::Down => {
763 if !is_text_mode {
764 self.focus_next();
765 return KeyAction::Handled;
766 }
767 }
768 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
769 self.focus_prev();
770 return KeyAction::Handled;
771 }
772 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
773 self.focus_next();
774 return KeyAction::Handled;
775 }
776 KeyCode::Char('k') if !is_text_mode => {
777 self.focus_prev();
778 return KeyAction::Handled;
779 }
780 KeyCode::Char('j') if !is_text_mode => {
781 self.focus_next();
782 return KeyAction::Handled;
783 }
784
785 KeyCode::Tab => {
787 self.focus_next_section();
788 return KeyAction::Handled;
789 }
790
791 KeyCode::BackTab => {
793 self.focus_prev_section();
794 return KeyAction::Handled;
795 }
796
797 KeyCode::Esc => {
799 let tool_use_id = self.tool_use_id.clone();
800 return KeyAction::Cancelled(tool_use_id);
802 }
803
804 KeyCode::Enter => {
806 match self.handle_enter() {
807 EnterAction::Submit => {
808 let tool_use_id = self.tool_use_id.clone();
809 let response = self.build_response();
810 return KeyAction::Submitted(tool_use_id, response);
812 }
813 EnterAction::Cancel => {
814 let tool_use_id = self.tool_use_id.clone();
815 return KeyAction::Cancelled(tool_use_id);
817 }
818 EnterAction::Selected | EnterAction::None => {
819 return KeyAction::Handled;
820 }
821 }
822 }
823
824 KeyCode::Char(' ') if !is_text_mode => {
826 self.handle_space();
827 return KeyAction::Handled;
828 }
829
830 _ if is_text_mode => {
832 self.handle_text_input(key);
833 return KeyAction::Handled;
834 }
835
836 _ => {}
837 }
838
839 KeyAction::NotHandled
840 }
841
842 pub fn panel_height(&self, max_height: u16) -> u16 {
844 let mut lines = 0u16;
846
847 for question in &self.request.questions {
848 lines += 1; match question {
850 Question::SingleChoice { choices, .. } | Question::MultiChoice { choices, .. } => {
851 lines += choices.len() as u16;
852 lines += 1; }
854 Question::FreeText { .. } => {
855 lines += 1; }
857 }
858 }
859
860 let num_questions = self.request.questions.len() as u16;
862 let spacing = if num_questions > 1 { num_questions - 1 } else { 0 };
863 let total = lines + spacing + 7;
864
865 let max_from_percent = (max_height * self.config.max_panel_percent) / 100;
867 total.min(max_from_percent).min(max_height.saturating_sub(6))
868 }
869
870 pub fn render_panel(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
877 if !self.active {
878 return;
879 }
880
881 frame.render_widget(Clear, area);
883
884 self.render_panel_content(frame, area, theme);
886 }
887
888 fn render_panel_content(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
890 let inner_width = area.width.saturating_sub(4) as usize;
891 let mut lines: Vec<Line> = Vec::new();
892
893 let help_text = if self.is_text_focused() {
895 &self.config.help_text_input
896 } else {
897 &self.config.help_text_nav
898 };
899 lines.push(Line::from(Span::styled(help_text.clone(), theme.help_text())));
900 lines.push(Line::from("")); for (q_idx, (question, answer)) in self
904 .request
905 .questions
906 .iter()
907 .zip(self.answers.iter())
908 .enumerate()
909 {
910 if q_idx > 0 {
912 lines.push(Line::from(""));
913 }
914
915 let required = if question.is_required() { "*" } else { "" };
917 let q_text = format!("{}{}{}", self.config.question_prefix, question.text(), required);
918 lines.push(Line::from(Span::styled(
919 truncate_text(&q_text, inner_width),
920 Style::default().add_modifier(Modifier::BOLD),
921 )));
922
923 match question {
924 Question::SingleChoice { choices, .. } => {
925 self.render_choices(&mut lines, q_idx, choices, answer, false, inner_width, theme);
926 }
927 Question::MultiChoice { choices, .. } => {
928 self.render_choices(&mut lines, q_idx, choices, answer, true, inner_width, theme);
929 }
930 Question::FreeText { .. } => {
931 self.render_text_input(&mut lines, q_idx, answer, inner_width, theme);
932 }
933 }
934 }
935
936 lines.push(Line::from(""));
938
939 let submit_focused = self.current_focus() == Some(&FocusItem::Submit);
941 let cancel_focused = self.current_focus() == Some(&FocusItem::Cancel);
942 let submit_enabled = self.can_submit();
943
944 let submit_style = if !submit_enabled {
945 theme.muted_text()
946 } else if submit_focused {
947 theme.button_confirm_focused()
948 } else {
949 theme.button_confirm()
950 };
951 let cancel_style = if cancel_focused {
952 theme.button_cancel_focused()
953 } else {
954 theme.button_cancel()
955 };
956
957 let mut button_spans = vec![Span::raw(" ")];
958
959 if submit_focused && submit_enabled {
961 button_spans.push(Span::styled("\u{203A} ", theme.focus_indicator()));
962 }
963 button_spans.push(Span::styled("Submit", submit_style));
964 button_spans.push(Span::raw(" "));
965
966 if cancel_focused {
968 button_spans.push(Span::styled("\u{203A} ", theme.focus_indicator()));
969 }
970 button_spans.push(Span::styled("Cancel", cancel_style));
971
972 lines.push(Line::from(button_spans));
973
974 let block = Block::default()
975 .borders(Borders::ALL)
976 .border_style(theme.warning())
977 .title(Span::styled(
978 self.config.title.clone(),
979 theme.warning().add_modifier(Modifier::BOLD),
980 ));
981
982 let paragraph = Paragraph::new(lines).block(block);
983 frame.render_widget(paragraph, area);
984 }
985
986 fn render_choices(
988 &self,
989 lines: &mut Vec<Line>,
990 question_idx: usize,
991 choices: &[String],
992 answer: &AnswerState,
993 is_multi: bool,
994 inner_width: usize,
995 theme: &Theme,
996 ) {
997 for (c_idx, choice_text) in choices.iter().enumerate() {
998 let is_focused = self.current_focus()
999 == Some(&FocusItem::Choice {
1000 question_idx,
1001 choice_idx: c_idx,
1002 });
1003 let is_selected = answer.is_selected(choice_text);
1004
1005 let symbol = if is_multi {
1006 if is_selected { &self.config.checkbox_selected } else { &self.config.checkbox_unselected }
1007 } else {
1008 if is_selected { &self.config.radio_selected } else { &self.config.radio_unselected }
1009 };
1010
1011 let prefix = if is_focused { &self.config.selection_indicator } else { &self.config.no_indicator };
1012 let display_text = truncate_text(choice_text, inner_width - 8);
1013
1014 if is_focused {
1015 lines.push(Line::from(vec![
1016 Span::styled(prefix.clone(), theme.focus_indicator()),
1017 Span::styled(format!("{} {}", symbol, display_text), theme.focused_text()),
1018 ]));
1019 } else {
1020 lines.push(Line::from(Span::styled(
1021 format!("{}{} {}", prefix, symbol, display_text),
1022 theme.muted_text(),
1023 )));
1024 }
1025 }
1026
1027 let other_focused = self.current_focus() == Some(&FocusItem::OtherOption { question_idx });
1029 let other_text_focused = self.current_focus() == Some(&FocusItem::OtherText { question_idx });
1030 let is_this_focused = other_focused || other_text_focused;
1031 let has_other = answer.has_other_text();
1032
1033 let is_other_selected = match answer {
1037 AnswerState::SingleChoice { selected, .. } => selected.is_none() && (has_other || is_this_focused),
1038 AnswerState::MultiChoice { .. } => has_other,
1039 _ => false,
1040 };
1041
1042 let symbol = if is_multi {
1043 if is_other_selected { &self.config.checkbox_selected } else { &self.config.checkbox_unselected }
1044 } else {
1045 if is_other_selected { &self.config.radio_selected } else { &self.config.radio_unselected }
1046 };
1047
1048 let prefix = if is_this_focused { &self.config.selection_indicator } else { &self.config.no_indicator };
1049
1050 let (other_text, cursor_col) = match answer {
1052 AnswerState::SingleChoice { other_text, .. }
1053 | AnswerState::MultiChoice { other_text, .. } => {
1054 let text = other_text.lines().first().cloned().unwrap_or_default();
1055 let col = other_text.cursor().1;
1056 (text, col)
1057 }
1058 _ => (String::new(), 0),
1059 };
1060
1061 if is_this_focused {
1063 let chars: Vec<char> = other_text.chars().collect();
1064 let cursor_pos = cursor_col.min(chars.len());
1065 let before: String = chars[..cursor_pos].iter().collect();
1066 let cursor_char = chars.get(cursor_pos).copied().unwrap_or(' ');
1067 let after: String = chars.get(cursor_pos + 1..).map(|s| s.iter().collect()).unwrap_or_default();
1068
1069 lines.push(Line::from(vec![
1070 Span::styled(prefix.clone(), theme.focus_indicator()),
1071 Span::styled(format!("{} Type Something: ", symbol), theme.focused_text()),
1072 Span::styled(before, theme.focused_text()),
1073 Span::styled(cursor_char.to_string(), theme.cursor()),
1074 Span::styled(after, theme.focused_text()),
1075 ]));
1076 } else {
1077 let truncated_display = truncate_text(&other_text, inner_width - 24);
1078 lines.push(Line::from(Span::styled(
1079 format!("{}{} Type Something: {}", prefix, symbol, truncated_display),
1080 theme.muted_text(),
1081 )));
1082 }
1083 }
1084
1085 fn render_text_input(
1087 &self,
1088 lines: &mut Vec<Line>,
1089 question_idx: usize,
1090 answer: &AnswerState,
1091 inner_width: usize,
1092 theme: &Theme,
1093 ) {
1094 let is_focused = self.current_focus() == Some(&FocusItem::TextInput { question_idx });
1095
1096 let (text, cursor_col) = match answer {
1097 AnswerState::FreeText { textarea } => {
1098 let t = textarea.lines().first().cloned().unwrap_or_default();
1099 let col = textarea.cursor().1;
1100 (t, col)
1101 }
1102 _ => (String::new(), 0),
1103 };
1104
1105 let prefix = if is_focused { &self.config.selection_indicator } else { &self.config.no_indicator };
1106
1107 if is_focused {
1108 let chars: Vec<char> = text.chars().collect();
1110 let cursor_pos = cursor_col.min(chars.len());
1111 let before: String = chars[..cursor_pos].iter().collect();
1112 let cursor_char = chars.get(cursor_pos).copied().unwrap_or(' ');
1113 let after: String = chars.get(cursor_pos + 1..).map(|s| s.iter().collect()).unwrap_or_default();
1114
1115 lines.push(Line::from(vec![
1116 Span::styled(prefix.clone(), theme.focus_indicator()),
1117 Span::styled("Type Something: ", theme.focused_text()),
1118 Span::styled(before, theme.focused_text()),
1119 Span::styled(cursor_char.to_string(), theme.cursor()),
1120 Span::styled(after, theme.focused_text()),
1121 ]));
1122 } else {
1123 let display = if text.is_empty() {
1124 "Type Something:".to_string()
1125 } else {
1126 format!("Type Something: {}", text)
1127 };
1128 let truncated = truncate_text(&display, inner_width - 4);
1129 lines.push(Line::from(Span::styled(
1130 format!("{}{}", prefix, truncated),
1131 theme.muted_text(),
1132 )));
1133 }
1134 }
1135}
1136
1137impl Default for QuestionPanel {
1138 fn default() -> Self {
1139 Self::new()
1140 }
1141}
1142
1143use std::any::Any;
1146use super::{widget_ids, Widget, WidgetAction, WidgetKeyContext, WidgetKeyResult};
1147
1148impl Widget for QuestionPanel {
1149 fn id(&self) -> &'static str {
1150 widget_ids::QUESTION_PANEL
1151 }
1152
1153 fn priority(&self) -> u8 {
1154 200 }
1156
1157 fn is_active(&self) -> bool {
1158 self.active
1159 }
1160
1161 fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
1162 if !self.active {
1163 return WidgetKeyResult::NotHandled;
1164 }
1165
1166 let is_text_mode = self.is_text_focused();
1167
1168 if !is_text_mode {
1170 if ctx.nav.is_move_up(&key) {
1171 self.focus_prev();
1172 return WidgetKeyResult::Handled;
1173 }
1174 if ctx.nav.is_move_down(&key) {
1175 self.focus_next();
1176 return WidgetKeyResult::Handled;
1177 }
1178 }
1179
1180 if key.code == KeyCode::Char('p') && key.modifiers.contains(KeyModifiers::CONTROL) {
1182 self.focus_prev();
1183 return WidgetKeyResult::Handled;
1184 }
1185 if key.code == KeyCode::Char('n') && key.modifiers.contains(KeyModifiers::CONTROL) {
1186 self.focus_next();
1187 return WidgetKeyResult::Handled;
1188 }
1189
1190 if ctx.nav.is_cancel(&key) {
1192 let tool_use_id = self.tool_use_id.clone();
1193 return WidgetKeyResult::Action(WidgetAction::CancelQuestion { tool_use_id });
1194 }
1195
1196 match self.process_key(key) {
1198 KeyAction::Submitted(tool_use_id, response) => {
1199 WidgetKeyResult::Action(WidgetAction::SubmitQuestion {
1200 tool_use_id,
1201 response,
1202 })
1203 }
1204 KeyAction::Cancelled(tool_use_id) => {
1205 WidgetKeyResult::Action(WidgetAction::CancelQuestion { tool_use_id })
1206 }
1207 KeyAction::Handled | KeyAction::NotHandled => WidgetKeyResult::Handled,
1208 }
1209 }
1210
1211 fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
1212 self.render_panel(frame, area, theme);
1213 }
1214
1215 fn required_height(&self, max_height: u16) -> u16 {
1216 if self.active {
1217 self.panel_height(max_height)
1218 } else {
1219 0
1220 }
1221 }
1222
1223 fn blocks_input(&self) -> bool {
1224 self.active
1225 }
1226
1227 fn is_overlay(&self) -> bool {
1228 false
1229 }
1230
1231 fn as_any(&self) -> &dyn Any {
1232 self
1233 }
1234
1235 fn as_any_mut(&mut self) -> &mut dyn Any {
1236 self
1237 }
1238
1239 fn into_any(self: Box<Self>) -> Box<dyn Any> {
1240 self
1241 }
1242}
1243
1244fn truncate_text(text: &str, max_width: usize) -> String {
1246 if text.chars().count() <= max_width {
1247 text.to_string()
1248 } else {
1249 let truncated: String = text.chars().take(max_width.saturating_sub(3)).collect();
1250 format!("{}...", truncated)
1251 }
1252}
1253
1254#[cfg(test)]
1255mod tests {
1256 use super::*;
1257
1258 fn create_test_request() -> AskUserQuestionsRequest {
1259 AskUserQuestionsRequest {
1260 questions: vec![
1261 Question::SingleChoice {
1262 text: "Choose one".to_string(),
1263 choices: vec!["Option A".to_string(), "Option B".to_string()],
1264 required: true,
1265 },
1266 ],
1267 }
1268 }
1269
1270 #[test]
1271 fn test_panel_activation() {
1272 let mut panel = QuestionPanel::new();
1273 assert!(!panel.is_active());
1274
1275 let request = create_test_request();
1276 panel.activate("tool_123".to_string(), 1, request, None);
1277
1278 assert!(panel.is_active());
1279 assert_eq!(panel.tool_use_id(), "tool_123");
1280 assert_eq!(panel.session_id(), 1);
1281
1282 panel.deactivate();
1283 assert!(!panel.is_active());
1284 }
1285
1286 #[test]
1287 fn test_navigation() {
1288 let mut panel = QuestionPanel::new();
1289 let request = create_test_request();
1290 panel.activate("tool_1".to_string(), 1, request, None);
1291
1292 assert_eq!(
1294 panel.current_focus(),
1295 Some(&FocusItem::Choice {
1296 question_idx: 0,
1297 choice_idx: 0
1298 })
1299 );
1300
1301 panel.focus_next();
1303 assert_eq!(
1304 panel.current_focus(),
1305 Some(&FocusItem::Choice {
1306 question_idx: 0,
1307 choice_idx: 1
1308 })
1309 );
1310
1311 panel.focus_submit();
1313 assert_eq!(panel.current_focus(), Some(&FocusItem::Submit));
1314 }
1315
1316 #[test]
1317 fn test_handle_key_cancel() {
1318 let mut panel = QuestionPanel::new();
1319 let request = create_test_request();
1320 panel.activate("tool_1".to_string(), 1, request, None);
1321
1322 let action = panel.process_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1323 match action {
1324 KeyAction::Cancelled(tool_use_id) => {
1325 assert_eq!(tool_use_id, "tool_1");
1326 }
1327 _ => panic!("Expected Cancelled action"),
1328 }
1329 panel.deactivate();
1331 assert!(!panel.is_active());
1332 }
1333
1334 #[test]
1335 fn test_answer_state_single_choice() {
1336 let mut state = AnswerState::SingleChoice {
1337 selected: None,
1338 other_text: TextArea::default(),
1339 };
1340
1341 assert!(!state.is_selected("Option A"));
1342 state.select_choice("Option A");
1343 assert!(state.is_selected("Option A"));
1344
1345 state.select_choice("Option B");
1347 assert!(!state.is_selected("Option A"));
1348 assert!(state.is_selected("Option B"));
1349 }
1350
1351 #[test]
1352 fn test_answer_state_multi_choice() {
1353 let mut state = AnswerState::MultiChoice {
1354 selected: HashSet::new(),
1355 other_text: TextArea::default(),
1356 };
1357
1358 state.select_choice("Option A");
1359 state.select_choice("Option B");
1360 assert!(state.is_selected("Option A"));
1361 assert!(state.is_selected("Option B"));
1362
1363 state.select_choice("Option A");
1365 assert!(!state.is_selected("Option A"));
1366 assert!(state.is_selected("Option B"));
1367 }
1368}