Skip to main content

agent_air_tui/widgets/
question_panel.rs

1//! Question panel widget for AskUserQuestions tool
2//!
3//! A reusable Ratatui panel that displays questions from the LLM
4//! and collects structured responses from the user.
5//!
6//! # Navigation
7//! - Up/Down/Ctrl-P/Ctrl-N: Move between focusable items
8//! - Enter: Select choice or advance to next question
9//! - Space: Toggle selection (for multi-choice)
10//! - Esc: Cancel and close panel
11//! - Tab: Jump to next question section
12//! - Shift+Tab: Jump to previous question section
13//! - For text fields: all typing goes to the TextArea
14//!
15//! # Submit Button
16//! The Submit button is disabled (grayed out) until all required questions
17//! have been answered.
18
19use 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
36/// Default configuration values for QuestionPanel
37pub mod defaults {
38    /// Maximum percentage of screen height the panel can use
39    pub const MAX_PANEL_PERCENT: u16 = 70;
40    /// Selection indicator for focused items
41    pub const SELECTION_INDICATOR: &str = " \u{203A} ";
42    /// Blank space for non-focused items
43    pub const NO_INDICATOR: &str = "   ";
44    /// Panel title
45    pub const TITLE: &str = " User Input Required ";
46    /// Help text for navigation mode
47    pub const HELP_TEXT_NAV: &str = " Up/Down: Navigate \u{00B7} Enter/Space: Select \u{00B7} Tab: Next Section \u{00B7} Esc: Cancel";
48    /// Help text for text input mode
49    pub const HELP_TEXT_INPUT: &str =
50        " Type text \u{00B7} Enter: Next \u{00B7} Tab: Next Section \u{00B7} Esc: Cancel";
51    /// Question prefix icon
52    pub const QUESTION_PREFIX: &str = " \u{2237} ";
53    /// Radio button symbols (single choice)
54    pub const RADIO_SELECTED: &str = "\u{25CF}";
55    pub const RADIO_UNSELECTED: &str = "\u{25CB}";
56    /// Checkbox symbols (multi choice)
57    pub const CHECKBOX_SELECTED: &str = "\u{25A0}";
58    pub const CHECKBOX_UNSELECTED: &str = "\u{25A1}";
59}
60
61/// Configuration for QuestionPanel widget
62#[derive(Clone)]
63pub struct QuestionPanelConfig {
64    /// Maximum percentage of screen height the panel can use
65    pub max_panel_percent: u16,
66    /// Selection indicator for focused items
67    pub selection_indicator: String,
68    /// Blank space for non-focused items
69    pub no_indicator: String,
70    /// Panel title
71    pub title: String,
72    /// Help text for navigation mode
73    pub help_text_nav: String,
74    /// Help text for text input mode
75    pub help_text_input: String,
76    /// Question prefix icon
77    pub question_prefix: String,
78    /// Radio button selected symbol
79    pub radio_selected: String,
80    /// Radio button unselected symbol
81    pub radio_unselected: String,
82    /// Checkbox selected symbol
83    pub checkbox_selected: String,
84    /// Checkbox unselected symbol
85    pub checkbox_unselected: String,
86}
87
88impl Default for QuestionPanelConfig {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl QuestionPanelConfig {
95    /// Create a new QuestionPanelConfig with default values
96    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    /// Set the maximum panel height percentage
113    pub fn with_max_panel_percent(mut self, percent: u16) -> Self {
114        self.max_panel_percent = percent;
115        self
116    }
117
118    /// Set the selection indicator
119    pub fn with_selection_indicator(mut self, indicator: impl Into<String>) -> Self {
120        self.selection_indicator = indicator.into();
121        self
122    }
123
124    /// Set the panel title
125    pub fn with_title(mut self, title: impl Into<String>) -> Self {
126        self.title = title.into();
127        self
128    }
129
130    /// Set radio button symbols
131    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    /// Set checkbox symbols
142    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/// Represents a focusable item in the panel
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum FocusItem {
156    /// A choice option within a question
157    Choice {
158        question_idx: usize,
159        choice_idx: usize,
160    },
161    /// The "Other" option for a choice question
162    OtherOption { question_idx: usize },
163    /// Text input for "Other" in a choice question
164    OtherText { question_idx: usize },
165    /// Free text input field
166    TextInput { question_idx: usize },
167    /// Submit button
168    Submit,
169    /// Cancel button
170    Cancel,
171}
172
173/// Answer state for a single question
174pub enum AnswerState {
175    /// Single choice answer
176    SingleChoice {
177        selected: Option<String>,
178        other_text: TextArea<'static>,
179    },
180    /// Multiple choice answer
181    MultiChoice {
182        selected: HashSet<String>,
183        other_text: TextArea<'static>,
184    },
185    /// Free text answer
186    FreeText { textarea: TextArea<'static> },
187}
188
189impl AnswerState {
190    /// Create answer state from a question
191    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    /// Convert to Answer for response
218    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                // If user typed a custom answer, use that; otherwise use selected choice
226                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                // Add custom answer if provided
243                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    /// Check if a choice is selected
259    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    /// Toggle/select a choice
270    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    /// Get the textarea for this answer (if applicable)
287    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    /// Check if "Other" has text
296    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    /// Check if this answer has a valid response (selected choice or text entered)
307    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/// Result of pressing Enter
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub enum EnterAction {
325    None,
326    Selected,
327    Submit,
328    Cancel,
329}
330
331/// Result of handling a key event
332#[derive(Debug, Clone)]
333pub enum KeyAction {
334    /// No action taken, key was handled internally
335    Handled,
336    /// Key was not handled (pass to parent)
337    NotHandled,
338    /// User submitted answers (includes tool_use_id and response)
339    Submitted(String, AskUserQuestionsResponse),
340    /// User cancelled
341    Cancelled(String),
342}
343
344/// State for the question panel overlay
345pub struct QuestionPanel {
346    /// Whether the panel is active
347    active: bool,
348    /// Tool use ID for this interaction
349    tool_use_id: String,
350    /// Session ID
351    session_id: i64,
352    /// The questions
353    request: AskUserQuestionsRequest,
354    /// Turn ID
355    turn_id: Option<TurnId>,
356    /// Answers for each question
357    answers: Vec<AnswerState>,
358    /// All focusable items in order
359    focus_items: Vec<FocusItem>,
360    /// Current focus index
361    focus_idx: usize,
362    /// Configuration for display customization
363    config: QuestionPanelConfig,
364}
365
366impl QuestionPanel {
367    /// Create a new inactive question panel
368    pub fn new() -> Self {
369        Self::with_config(QuestionPanelConfig::new())
370    }
371
372    /// Create a new inactive question panel with custom configuration
373    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    /// Get the current configuration
390    pub fn config(&self) -> &QuestionPanelConfig {
391        &self.config
392    }
393
394    /// Set a new configuration
395    pub fn set_config(&mut self, config: QuestionPanelConfig) {
396        self.config = config;
397    }
398
399    /// Activate the panel with questions
400    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        // Initialize answers
412        self.answers = request
413            .questions
414            .iter()
415            .map(AnswerState::from_question)
416            .collect();
417
418        // Build focus items list
419        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    /// Build the list of focusable items from questions
427    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                    // Each choice is focusable
434                    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                    // "Type Something" option - always available for custom answers
441                    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        // Add buttons
454        items.push(FocusItem::Submit);
455        items.push(FocusItem::Cancel);
456
457        items
458    }
459
460    /// Deactivate the panel
461    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    /// Check if the panel is active
471    pub fn is_active(&self) -> bool {
472        self.active
473    }
474
475    /// Get the current tool use ID
476    pub fn tool_use_id(&self) -> &str {
477        &self.tool_use_id
478    }
479
480    /// Get the session ID
481    pub fn session_id(&self) -> i64 {
482        self.session_id
483    }
484
485    /// Get the current request
486    pub fn request(&self) -> &AskUserQuestionsRequest {
487        &self.request
488    }
489
490    /// Get the turn ID
491    pub fn turn_id(&self) -> Option<&TurnId> {
492        self.turn_id.as_ref()
493    }
494
495    /// Build the response from current answers
496    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    /// Get current focus item
508    pub fn current_focus(&self) -> Option<&FocusItem> {
509        self.focus_items.get(self.focus_idx)
510    }
511
512    /// Move focus to next item
513    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    /// Move focus to previous item
520    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    /// Jump to submit button
531    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    /// Check if current focus is on a text input (or OtherOption which also accepts typing)
542    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    /// Handle Enter key - select choice or advance
554    fn handle_enter(&mut self) -> EnterAction {
555        match self.current_focus().cloned() {
556            Some(FocusItem::Choice {
557                question_idx,
558                choice_idx,
559            }) => {
560                // Select this choice
561                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                // For single choice, advance to next question
575                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                // Move to the Other text input
585                self.focus_next();
586                EnterAction::Selected
587            }
588            Some(FocusItem::OtherText { question_idx }) => {
589                // Advance to next question
590                self.advance_to_next_question(question_idx);
591                EnterAction::Selected
592            }
593            Some(FocusItem::TextInput { question_idx }) => {
594                // Advance to next question
595                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 // Can't submit yet, required fields missing
603                }
604            }
605            Some(FocusItem::Cancel) => EnterAction::Cancel,
606            None => EnterAction::None,
607        }
608    }
609
610    /// Advance focus to the first item of the next question (or Submit)
611    fn advance_to_next_question(&mut self, current_question_idx: usize) {
612        let next_question_idx = current_question_idx + 1;
613
614        // Find the first focus item for the next question
615        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            // No more questions, go to Submit
625            self.focus_submit();
626        }
627    }
628
629    /// Get the question index for the current focus item
630    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    /// Focus the next question section (Tab behavior)
641    /// Moves to the first item (or selected item) of the next question
642    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        // Find first focus item for next question, or Submit if no more questions
647        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            // No more questions, go to Submit
657            self.focus_submit();
658        }
659    }
660
661    /// Focus the previous question section (Shift+Tab behavior)
662    fn focus_prev_section(&mut self) {
663        let current_q = self.current_question_idx();
664
665        // If on Submit/Cancel or question 0, go to first question
666        let prev_q = match current_q {
667            Some(q) if q > 0 => q - 1,
668            _ => {
669                // Already on first question or on buttons, find last question
670                let last_q = self.request.questions.len().saturating_sub(1);
671                if current_q == Some(0) {
672                    // On first question, wrap to Submit
673                    self.focus_submit();
674                    return;
675                }
676                last_q
677            }
678        };
679
680        // Find first focus item for previous question
681        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    /// Check if all required questions have been answered
693    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    /// Handle key input for text areas
703    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                // FreeText question - forward to its textarea
708                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                // OtherOption also accepts typing - forward to the textarea
717                if let Some(answer) = self.answers.get_mut(question_idx) {
718                    if let Some(textarea) = answer.textarea_mut() {
719                        textarea.input(key);
720                    }
721                    // For single choice, clear the selected option when typing in "Other"
722                    if let AnswerState::SingleChoice { selected, .. } = answer {
723                        *selected = None;
724                    }
725                }
726            }
727            _ => {}
728        }
729    }
730
731    /// Toggle selection with Space
732    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                // Space on Other option moves to text input
754                self.focus_next();
755            }
756            _ => {}
757        }
758    }
759
760    /// Handle a key event
761    ///
762    /// Returns the action that should be taken based on the key press.
763    pub fn process_key(&mut self, key: KeyEvent) -> KeyAction {
764        if !self.active {
765            return KeyAction::NotHandled;
766        }
767
768        // Check if we're in a text input mode
769        let is_text_mode = self.is_text_focused();
770
771        match key.code {
772            // Navigation (always available)
773            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            // Tab to move to next question section
803            KeyCode::Tab => {
804                self.focus_next_section();
805                return KeyAction::Handled;
806            }
807
808            // Shift+Tab to move to previous question section
809            KeyCode::BackTab => {
810                self.focus_prev_section();
811                return KeyAction::Handled;
812            }
813
814            // Cancel
815            KeyCode::Esc => {
816                let tool_use_id = self.tool_use_id.clone();
817                // Note: don't deactivate here - let the caller do it after processing
818                return KeyAction::Cancelled(tool_use_id);
819            }
820
821            // Enter - select or submit
822            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                        // Note: don't deactivate here - let the caller do it after processing
828                        return KeyAction::Submitted(tool_use_id, response);
829                    }
830                    EnterAction::Cancel => {
831                        let tool_use_id = self.tool_use_id.clone();
832                        // Note: don't deactivate here - let the caller do it after processing
833                        return KeyAction::Cancelled(tool_use_id);
834                    }
835                    EnterAction::Selected | EnterAction::None => {
836                        return KeyAction::Handled;
837                    }
838                }
839            }
840
841            // Space - toggle selection (when not in text mode)
842            KeyCode::Char(' ') if !is_text_mode => {
843                self.handle_space();
844                return KeyAction::Handled;
845            }
846
847            // Text input
848            _ 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    /// Calculate the panel height based on content
860    pub fn panel_height(&self, max_height: u16) -> u16 {
861        // Calculate height needed for content
862        let mut lines = 0u16;
863
864        for question in &self.request.questions {
865            lines += 1; // Question text
866            match question {
867                Question::SingleChoice { choices, .. } | Question::MultiChoice { choices, .. } => {
868                    lines += choices.len() as u16;
869                    lines += 1; // "Type Something:" - always shown for custom answers
870                }
871                Question::FreeText { .. } => {
872                    lines += 1; // Text input line
873                }
874            }
875        }
876
877        // Add: help text(1) + help blank(1) + spacing between questions + blank before buttons(1) + buttons(1) + borders(2)
878        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        // Cap at percentage of available height, leaving room for chat and input
883        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    /// Render the question panel
890    ///
891    /// # Arguments
892    /// * `frame` - The Ratatui frame to render into
893    /// * `area` - The area to render the panel in
894    /// * `theme` - Theme implementation for styling
895    pub fn render_panel(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
896        if !self.active {
897            return;
898        }
899
900        // Clear the area
901        frame.render_widget(Clear, area);
902
903        // Render panel content
904        self.render_panel_content(frame, area, theme);
905    }
906
907    /// Render panel content as a single unified panel with vertical question list
908    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        // Help text at top
913        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("")); // blank line after help
923
924        // Render each question vertically
925        for (q_idx, (question, answer)) in self
926            .request
927            .questions
928            .iter()
929            .zip(self.answers.iter())
930            .enumerate()
931        {
932            // Add blank line before each question (except first)
933            if q_idx > 0 {
934                lines.push(Line::from(""));
935            }
936
937            // Question text with arrow prefix and required marker
938            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        // Add blank line before buttons
980        lines.push(Line::from(""));
981
982        // Add buttons - Submit and Cancel side by side
983        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        // Submit with indicator if focused (only if enabled)
1003        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        // Cancel with indicator if focused
1010        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    /// Render choice options inline
1030    #[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        // "Type Something:" option - always available for custom answers
1082        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        // For single choice, "Type Something" is selected if:
1089        // - no other choice is selected AND (has text OR is currently focused)
1090        // For multi choice, it's selected if there's text
1091        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        // Get the text input content and cursor position
1118        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        // Build the text display with cursor shown as inverse character
1129        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    /// Render free text input inline
1156    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            // Show cursor as inverse character
1183            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
1220// --- Widget trait implementation ---
1221
1222use 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 // High priority - modal panel
1232    }
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        // Use NavigationHelper for navigation keys (when not in text mode)
1246        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        // Ctrl+P/N always work for navigation (even in text mode)
1258        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        // Cancel using nav helper
1268        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        // Handle other keys via process_key for backward compatibility
1274        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
1321/// Truncate text to fit width
1322fn 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        // Default focus is first choice
1368        assert_eq!(
1369            panel.current_focus(),
1370            Some(&FocusItem::Choice {
1371                question_idx: 0,
1372                choice_idx: 0
1373            })
1374        );
1375
1376        // Move to next
1377        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        // Jump to submit
1387        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 doesn't deactivate itself - caller must do it
1405        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        // Selecting another clears the first
1421        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        // Toggle off
1439        state.select_choice("Option A");
1440        assert!(!state.is_selected("Option A"));
1441        assert!(state.is_selected("Option B"));
1442    }
1443}