agent_core/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 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
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 = " Type text \u{00B7} Enter: Next \u{00B7} Tab: Next Section \u{00B7} Esc: Cancel";
50    /// Question prefix icon
51    pub const QUESTION_PREFIX: &str = " \u{2237} ";
52    /// Radio button symbols (single choice)
53    pub const RADIO_SELECTED: &str = "\u{25CF}";
54    pub const RADIO_UNSELECTED: &str = "\u{25CB}";
55    /// Checkbox symbols (multi choice)
56    pub const CHECKBOX_SELECTED: &str = "\u{25A0}";
57    pub const CHECKBOX_UNSELECTED: &str = "\u{25A1}";
58}
59
60/// Configuration for QuestionPanel widget
61#[derive(Clone)]
62pub struct QuestionPanelConfig {
63    /// Maximum percentage of screen height the panel can use
64    pub max_panel_percent: u16,
65    /// Selection indicator for focused items
66    pub selection_indicator: String,
67    /// Blank space for non-focused items
68    pub no_indicator: String,
69    /// Panel title
70    pub title: String,
71    /// Help text for navigation mode
72    pub help_text_nav: String,
73    /// Help text for text input mode
74    pub help_text_input: String,
75    /// Question prefix icon
76    pub question_prefix: String,
77    /// Radio button selected symbol
78    pub radio_selected: String,
79    /// Radio button unselected symbol
80    pub radio_unselected: String,
81    /// Checkbox selected symbol
82    pub checkbox_selected: String,
83    /// Checkbox unselected symbol
84    pub checkbox_unselected: String,
85}
86
87impl Default for QuestionPanelConfig {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93impl QuestionPanelConfig {
94    /// Create a new QuestionPanelConfig with default values
95    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    /// Set the maximum panel height percentage
112    pub fn with_max_panel_percent(mut self, percent: u16) -> Self {
113        self.max_panel_percent = percent;
114        self
115    }
116
117    /// Set the selection indicator
118    pub fn with_selection_indicator(mut self, indicator: impl Into<String>) -> Self {
119        self.selection_indicator = indicator.into();
120        self
121    }
122
123    /// Set the panel title
124    pub fn with_title(mut self, title: impl Into<String>) -> Self {
125        self.title = title.into();
126        self
127    }
128
129    /// Set radio button symbols
130    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    /// Set checkbox symbols
137    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/// Represents a focusable item in the panel
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum FocusItem {
147    /// A choice option within a question
148    Choice {
149        question_idx: usize,
150        choice_idx: usize,
151    },
152    /// The "Other" option for a choice question
153    OtherOption { question_idx: usize },
154    /// Text input for "Other" in a choice question
155    OtherText { question_idx: usize },
156    /// Free text input field
157    TextInput { question_idx: usize },
158    /// Submit button
159    Submit,
160    /// Cancel button
161    Cancel,
162}
163
164/// Answer state for a single question
165pub enum AnswerState {
166    /// Single choice answer
167    SingleChoice {
168        selected: Option<String>,
169        other_text: TextArea<'static>,
170    },
171    /// Multiple choice answer
172    MultiChoice {
173        selected: HashSet<String>,
174        other_text: TextArea<'static>,
175    },
176    /// Free text answer
177    FreeText { textarea: TextArea<'static> },
178}
179
180impl AnswerState {
181    /// Create answer state from a question
182    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    /// Convert to Answer for response
209    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                // If user typed a custom answer, use that; otherwise use selected choice
217                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                // Add custom answer if provided
234                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    /// Check if a choice is selected
250    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    /// Toggle/select a choice
261    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    /// Get the textarea for this answer (if applicable)
278    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    /// Check if "Other" has text
287    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    /// Check if this answer has a valid response (selected choice or text entered)
298    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/// Result of pressing Enter
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub enum EnterAction {
316    None,
317    Selected,
318    Submit,
319    Cancel,
320}
321
322/// Result of handling a key event
323#[derive(Debug, Clone)]
324pub enum KeyAction {
325    /// No action taken, key was handled internally
326    Handled,
327    /// Key was not handled (pass to parent)
328    NotHandled,
329    /// User submitted answers (includes tool_use_id and response)
330    Submitted(String, AskUserQuestionsResponse),
331    /// User cancelled
332    Cancelled(String),
333}
334
335/// State for the question panel overlay
336pub struct QuestionPanel {
337    /// Whether the panel is active
338    active: bool,
339    /// Tool use ID for this interaction
340    tool_use_id: String,
341    /// Session ID
342    session_id: i64,
343    /// The questions
344    request: AskUserQuestionsRequest,
345    /// Turn ID
346    turn_id: Option<TurnId>,
347    /// Answers for each question
348    answers: Vec<AnswerState>,
349    /// All focusable items in order
350    focus_items: Vec<FocusItem>,
351    /// Current focus index
352    focus_idx: usize,
353    /// Configuration for display customization
354    config: QuestionPanelConfig,
355}
356
357impl QuestionPanel {
358    /// Create a new inactive question panel
359    pub fn new() -> Self {
360        Self::with_config(QuestionPanelConfig::new())
361    }
362
363    /// Create a new inactive question panel with custom configuration
364    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    /// Get the current configuration
381    pub fn config(&self) -> &QuestionPanelConfig {
382        &self.config
383    }
384
385    /// Set a new configuration
386    pub fn set_config(&mut self, config: QuestionPanelConfig) {
387        self.config = config;
388    }
389
390    /// Activate the panel with questions
391    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        // Initialize answers
403        self.answers = request
404            .questions
405            .iter()
406            .map(AnswerState::from_question)
407            .collect();
408
409        // Build focus items list
410        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    /// Build the list of focusable items from questions
418    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                    // Each choice is focusable
425                    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                    // "Type Something" option - always available for custom answers
432                    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        // Add buttons
441        items.push(FocusItem::Submit);
442        items.push(FocusItem::Cancel);
443
444        items
445    }
446
447    /// Deactivate the panel
448    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    /// Check if the panel is active
458    pub fn is_active(&self) -> bool {
459        self.active
460    }
461
462    /// Get the current tool use ID
463    pub fn tool_use_id(&self) -> &str {
464        &self.tool_use_id
465    }
466
467    /// Get the session ID
468    pub fn session_id(&self) -> i64 {
469        self.session_id
470    }
471
472    /// Get the current request
473    pub fn request(&self) -> &AskUserQuestionsRequest {
474        &self.request
475    }
476
477    /// Get the turn ID
478    pub fn turn_id(&self) -> Option<&TurnId> {
479        self.turn_id.as_ref()
480    }
481
482    /// Build the response from current answers
483    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    /// Get current focus item
495    pub fn current_focus(&self) -> Option<&FocusItem> {
496        self.focus_items.get(self.focus_idx)
497    }
498
499    /// Move focus to next item
500    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    /// Move focus to previous item
507    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    /// Jump to submit button
518    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    /// Check if current focus is on a text input (or OtherOption which also accepts typing)
525    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    /// Handle Enter key - select choice or advance
533    fn handle_enter(&mut self) -> EnterAction {
534        match self.current_focus().cloned() {
535            Some(FocusItem::Choice {
536                question_idx,
537                choice_idx,
538            }) => {
539                // Select this choice
540                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                // For single choice, advance to next question
556                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                // Move to the Other text input
566                self.focus_next();
567                EnterAction::Selected
568            }
569            Some(FocusItem::OtherText { question_idx }) => {
570                // Advance to next question
571                self.advance_to_next_question(question_idx);
572                EnterAction::Selected
573            }
574            Some(FocusItem::TextInput { question_idx }) => {
575                // Advance to next question
576                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 // Can't submit yet, required fields missing
584                }
585            }
586            Some(FocusItem::Cancel) => EnterAction::Cancel,
587            None => EnterAction::None,
588        }
589    }
590
591    /// Advance focus to the first item of the next question (or Submit)
592    fn advance_to_next_question(&mut self, current_question_idx: usize) {
593        let next_question_idx = current_question_idx + 1;
594
595        // Find the first focus item for the next question
596        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            // No more questions, go to Submit
606            self.focus_submit();
607        }
608    }
609
610    /// Get the question index for the current focus item
611    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    /// Focus the next question section (Tab behavior)
622    /// Moves to the first item (or selected item) of the next question
623    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        // Find first focus item for next question, or Submit if no more questions
628        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            // No more questions, go to Submit
638            self.focus_submit();
639        }
640    }
641
642    /// Focus the previous question section (Shift+Tab behavior)
643    fn focus_prev_section(&mut self) {
644        let current_q = self.current_question_idx();
645
646        // If on Submit/Cancel or question 0, go to first question
647        let prev_q = match current_q {
648            Some(q) if q > 0 => q - 1,
649            _ => {
650                // Already on first question or on buttons, find last question
651                let last_q = self.request.questions.len().saturating_sub(1);
652                if current_q == Some(0) {
653                    // On first question, wrap to Submit
654                    self.focus_submit();
655                    return;
656                }
657                last_q
658            }
659        };
660
661        // Find first focus item for previous question
662        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    /// Check if all required questions have been answered
674    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    /// Handle key input for text areas
684    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                // FreeText question - forward to its textarea
689                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                // OtherOption also accepts typing - forward to the textarea
698                if let Some(answer) = self.answers.get_mut(question_idx) {
699                    if let Some(textarea) = answer.textarea_mut() {
700                        textarea.input(key);
701                    }
702                    // For single choice, clear the selected option when typing in "Other"
703                    if let AnswerState::SingleChoice { selected, .. } = answer {
704                        *selected = None;
705                    }
706                }
707            }
708            _ => {}
709        }
710    }
711
712    /// Toggle selection with Space
713    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                // Space on Other option moves to text input
737                self.focus_next();
738            }
739            _ => {}
740        }
741    }
742
743    /// Handle a key event
744    ///
745    /// Returns the action that should be taken based on the key press.
746    pub fn process_key(&mut self, key: KeyEvent) -> KeyAction {
747        if !self.active {
748            return KeyAction::NotHandled;
749        }
750
751        // Check if we're in a text input mode
752        let is_text_mode = self.is_text_focused();
753
754        match key.code {
755            // Navigation (always available)
756            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            // Tab to move to next question section
786            KeyCode::Tab => {
787                self.focus_next_section();
788                return KeyAction::Handled;
789            }
790
791            // Shift+Tab to move to previous question section
792            KeyCode::BackTab => {
793                self.focus_prev_section();
794                return KeyAction::Handled;
795            }
796
797            // Cancel
798            KeyCode::Esc => {
799                let tool_use_id = self.tool_use_id.clone();
800                // Note: don't deactivate here - let the caller do it after processing
801                return KeyAction::Cancelled(tool_use_id);
802            }
803
804            // Enter - select or submit
805            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                        // Note: don't deactivate here - let the caller do it after processing
811                        return KeyAction::Submitted(tool_use_id, response);
812                    }
813                    EnterAction::Cancel => {
814                        let tool_use_id = self.tool_use_id.clone();
815                        // Note: don't deactivate here - let the caller do it after processing
816                        return KeyAction::Cancelled(tool_use_id);
817                    }
818                    EnterAction::Selected | EnterAction::None => {
819                        return KeyAction::Handled;
820                    }
821                }
822            }
823
824            // Space - toggle selection (when not in text mode)
825            KeyCode::Char(' ') if !is_text_mode => {
826                self.handle_space();
827                return KeyAction::Handled;
828            }
829
830            // Text input
831            _ 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    /// Calculate the panel height based on content
843    pub fn panel_height(&self, max_height: u16) -> u16 {
844        // Calculate height needed for content
845        let mut lines = 0u16;
846
847        for question in &self.request.questions {
848            lines += 1; // Question text
849            match question {
850                Question::SingleChoice { choices, .. } | Question::MultiChoice { choices, .. } => {
851                    lines += choices.len() as u16;
852                    lines += 1; // "Type Something:" - always shown for custom answers
853                }
854                Question::FreeText { .. } => {
855                    lines += 1; // Text input line
856                }
857            }
858        }
859
860        // Add: help text(1) + help blank(1) + spacing between questions + blank before buttons(1) + buttons(1) + borders(2)
861        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        // Cap at percentage of available height, leaving room for chat and input
866        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    /// Render the question panel
871    ///
872    /// # Arguments
873    /// * `frame` - The Ratatui frame to render into
874    /// * `area` - The area to render the panel in
875    /// * `theme` - Theme implementation for styling
876    pub fn render_panel(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
877        if !self.active {
878            return;
879        }
880
881        // Clear the area
882        frame.render_widget(Clear, area);
883
884        // Render panel content
885        self.render_panel_content(frame, area, theme);
886    }
887
888    /// Render panel content as a single unified panel with vertical question list
889    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        // Help text at top
894        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("")); // blank line after help
901
902        // Render each question vertically
903        for (q_idx, (question, answer)) in self
904            .request
905            .questions
906            .iter()
907            .zip(self.answers.iter())
908            .enumerate()
909        {
910            // Add blank line before each question (except first)
911            if q_idx > 0 {
912                lines.push(Line::from(""));
913            }
914
915            // Question text with arrow prefix and required marker
916            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        // Add blank line before buttons
937        lines.push(Line::from(""));
938
939        // Add buttons - Submit and Cancel side by side
940        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        // Submit with indicator if focused (only if enabled)
960        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        // Cancel with indicator if focused
967        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    /// Render choice options inline
987    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        // "Type Something:" option - always available for custom answers
1028        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        // For single choice, "Type Something" is selected if:
1034        // - no other choice is selected AND (has text OR is currently focused)
1035        // For multi choice, it's selected if there's text
1036        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        // Get the text input content and cursor position
1051        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        // Build the text display with cursor shown as inverse character
1062        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    /// Render free text input inline
1086    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            // Show cursor as inverse character
1109            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
1143// --- Widget trait implementation ---
1144
1145use 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 // High priority - modal panel
1155    }
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        // Use NavigationHelper for navigation keys (when not in text mode)
1169        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        // Ctrl+P/N always work for navigation (even in text mode)
1181        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        // Cancel using nav helper
1191        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        // Handle other keys via process_key for backward compatibility
1197        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
1244/// Truncate text to fit width
1245fn 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        // Default focus is first choice
1293        assert_eq!(
1294            panel.current_focus(),
1295            Some(&FocusItem::Choice {
1296                question_idx: 0,
1297                choice_idx: 0
1298            })
1299        );
1300
1301        // Move to next
1302        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        // Jump to submit
1312        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 doesn't deactivate itself - caller must do it
1330        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        // Selecting another clears the first
1346        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        // Toggle off
1364        state.select_choice("Option A");
1365        assert!(!state.is_selected("Option A"));
1366        assert!(state.is_selected("Option B"));
1367    }
1368}