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 Submit button
12//! - For text fields: all typing goes to the TextArea
13
14use std::collections::HashSet;
15
16use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
17use crate::controller::{
18    Answer, AskUserQuestionsRequest, AskUserQuestionsResponse, Question, TurnId,
19};
20use ratatui::{
21    layout::Rect,
22    style::{Modifier, Style},
23    text::{Line, Span},
24    widgets::{Block, Borders, Clear, Paragraph},
25    Frame,
26};
27use tui_textarea::TextArea;
28
29use crate::tui::themes::Theme;
30
31/// Maximum percentage of screen height the panel can use
32const MAX_PANEL_PERCENT: u16 = 70;
33
34/// Represents a focusable item in the panel
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum FocusItem {
37    /// A choice option within a question
38    Choice {
39        question_idx: usize,
40        choice_idx: usize,
41    },
42    /// The "Other" option for a choice question
43    OtherOption { question_idx: usize },
44    /// Text input for "Other" in a choice question
45    OtherText { question_idx: usize },
46    /// Free text input field
47    TextInput { question_idx: usize },
48    /// Submit button
49    Submit,
50    /// Cancel button
51    Cancel,
52}
53
54/// Answer state for a single question
55pub enum AnswerState {
56    /// Single choice answer
57    SingleChoice {
58        selected: Option<String>,
59        other_text: TextArea<'static>,
60    },
61    /// Multiple choice answer
62    MultiChoice {
63        selected: HashSet<String>,
64        other_text: TextArea<'static>,
65    },
66    /// Free text answer
67    FreeText { textarea: TextArea<'static> },
68}
69
70impl AnswerState {
71    /// Create answer state from a question
72    pub fn from_question(question: &Question) -> Self {
73        match question {
74            Question::SingleChoice { .. } => {
75                let other_text = TextArea::default();
76                AnswerState::SingleChoice {
77                    selected: None,
78                    other_text,
79                }
80            }
81            Question::MultiChoice { .. } => {
82                let other_text = TextArea::default();
83                AnswerState::MultiChoice {
84                    selected: HashSet::new(),
85                    other_text,
86                }
87            }
88            Question::FreeText { default_value, .. } => {
89                let mut textarea = TextArea::default();
90                if let Some(default) = default_value {
91                    textarea.insert_str(default);
92                }
93                AnswerState::FreeText { textarea }
94            }
95        }
96    }
97
98    /// Convert to Answer for response
99    pub fn to_answer(&self, question_text: &str) -> Answer {
100        match self {
101            AnswerState::SingleChoice {
102                selected,
103                other_text,
104            } => {
105                let other = other_text.lines().join("\n");
106                // If user typed a custom answer, use that; otherwise use selected choice
107                let answer_values = if !other.is_empty() {
108                    vec![other]
109                } else {
110                    selected.iter().cloned().collect()
111                };
112                Answer {
113                    question: question_text.to_string(),
114                    answer: answer_values,
115                }
116            }
117            AnswerState::MultiChoice {
118                selected,
119                other_text,
120            } => {
121                let other = other_text.lines().join("\n");
122                let mut answer_values: Vec<String> = selected.iter().cloned().collect();
123                // Add custom answer if provided
124                if !other.is_empty() {
125                    answer_values.push(other);
126                }
127                Answer {
128                    question: question_text.to_string(),
129                    answer: answer_values,
130                }
131            }
132            AnswerState::FreeText { textarea } => Answer {
133                question: question_text.to_string(),
134                answer: vec![textarea.lines().join("\n")],
135            },
136        }
137    }
138
139    /// Check if a choice is selected
140    pub fn is_selected(&self, choice_id: &str) -> bool {
141        match self {
142            AnswerState::SingleChoice { selected, .. } => {
143                selected.as_ref().map(|s| s == choice_id).unwrap_or(false)
144            }
145            AnswerState::MultiChoice { selected, .. } => selected.contains(choice_id),
146            AnswerState::FreeText { .. } => false,
147        }
148    }
149
150    /// Toggle/select a choice
151    pub fn select_choice(&mut self, choice_id: &str) {
152        match self {
153            AnswerState::SingleChoice { selected, .. } => {
154                *selected = Some(choice_id.to_string());
155            }
156            AnswerState::MultiChoice { selected, .. } => {
157                if selected.contains(choice_id) {
158                    selected.remove(choice_id);
159                } else {
160                    selected.insert(choice_id.to_string());
161                }
162            }
163            AnswerState::FreeText { .. } => {}
164        }
165    }
166
167    /// Get the textarea for this answer (if applicable)
168    pub fn textarea_mut(&mut self) -> Option<&mut TextArea<'static>> {
169        match self {
170            AnswerState::SingleChoice { other_text, .. }
171            | AnswerState::MultiChoice { other_text, .. } => Some(other_text),
172            AnswerState::FreeText { textarea } => Some(textarea),
173        }
174    }
175
176    /// Check if "Other" has text
177    pub fn has_other_text(&self) -> bool {
178        match self {
179            AnswerState::SingleChoice { other_text, .. }
180            | AnswerState::MultiChoice { other_text, .. } => {
181                !other_text.lines().join("").is_empty()
182            }
183            AnswerState::FreeText { .. } => false,
184        }
185    }
186}
187
188/// Result of pressing Enter
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum EnterAction {
191    None,
192    Selected,
193    Submit,
194    Cancel,
195}
196
197/// Result of handling a key event
198#[derive(Debug, Clone)]
199pub enum KeyAction {
200    /// No action taken, key was handled internally
201    Handled,
202    /// Key was not handled (pass to parent)
203    NotHandled,
204    /// User submitted answers (includes tool_use_id and response)
205    Submitted(String, AskUserQuestionsResponse),
206    /// User cancelled
207    Cancelled(String),
208}
209
210/// State for the question panel overlay
211pub struct QuestionPanel {
212    /// Whether the panel is active
213    active: bool,
214    /// Tool use ID for this interaction
215    tool_use_id: String,
216    /// Session ID
217    session_id: i64,
218    /// The questions
219    request: AskUserQuestionsRequest,
220    /// Turn ID
221    turn_id: Option<TurnId>,
222    /// Answers for each question
223    answers: Vec<AnswerState>,
224    /// All focusable items in order
225    focus_items: Vec<FocusItem>,
226    /// Current focus index
227    focus_idx: usize,
228}
229
230impl QuestionPanel {
231    /// Create a new inactive question panel
232    pub fn new() -> Self {
233        Self {
234            active: false,
235            tool_use_id: String::new(),
236            session_id: 0,
237            request: AskUserQuestionsRequest {
238                questions: Vec::new(),
239            },
240            turn_id: None,
241            answers: Vec::new(),
242            focus_items: Vec::new(),
243            focus_idx: 0,
244        }
245    }
246
247    /// Activate the panel with questions
248    pub fn activate(
249        &mut self,
250        tool_use_id: String,
251        session_id: i64,
252        request: AskUserQuestionsRequest,
253        turn_id: Option<TurnId>,
254    ) {
255        self.active = true;
256        self.tool_use_id = tool_use_id;
257        self.session_id = session_id;
258
259        // Initialize answers
260        self.answers = request
261            .questions
262            .iter()
263            .map(AnswerState::from_question)
264            .collect();
265
266        // Build focus items list
267        self.focus_items = Self::build_focus_items(&request.questions);
268        self.focus_idx = 0;
269
270        self.request = request;
271        self.turn_id = turn_id;
272    }
273
274    /// Build the list of focusable items from questions
275    fn build_focus_items(questions: &[Question]) -> Vec<FocusItem> {
276        let mut items = Vec::new();
277
278        for (q_idx, question) in questions.iter().enumerate() {
279            match question {
280                Question::SingleChoice { choices, .. } | Question::MultiChoice { choices, .. } => {
281                    // Each choice is focusable
282                    for c_idx in 0..choices.len() {
283                        items.push(FocusItem::Choice {
284                            question_idx: q_idx,
285                            choice_idx: c_idx,
286                        });
287                    }
288                    // "Type Something" option - always available for custom answers
289                    items.push(FocusItem::OtherOption { question_idx: q_idx });
290                }
291                Question::FreeText { .. } => {
292                    items.push(FocusItem::TextInput { question_idx: q_idx });
293                }
294            }
295        }
296
297        // Add buttons
298        items.push(FocusItem::Submit);
299        items.push(FocusItem::Cancel);
300
301        items
302    }
303
304    /// Deactivate the panel
305    pub fn deactivate(&mut self) {
306        self.active = false;
307        self.tool_use_id.clear();
308        self.request.questions.clear();
309        self.answers.clear();
310        self.focus_items.clear();
311        self.focus_idx = 0;
312    }
313
314    /// Check if the panel is active
315    pub fn is_active(&self) -> bool {
316        self.active
317    }
318
319    /// Get the current tool use ID
320    pub fn tool_use_id(&self) -> &str {
321        &self.tool_use_id
322    }
323
324    /// Get the session ID
325    pub fn session_id(&self) -> i64 {
326        self.session_id
327    }
328
329    /// Get the current request
330    pub fn request(&self) -> &AskUserQuestionsRequest {
331        &self.request
332    }
333
334    /// Get the turn ID
335    pub fn turn_id(&self) -> Option<&TurnId> {
336        self.turn_id.as_ref()
337    }
338
339    /// Build the response from current answers
340    pub fn build_response(&self) -> AskUserQuestionsResponse {
341        let answers = self
342            .request
343            .questions
344            .iter()
345            .zip(self.answers.iter())
346            .map(|(q, a)| a.to_answer(q.text()))
347            .collect();
348        AskUserQuestionsResponse { answers }
349    }
350
351    /// Get current focus item
352    pub fn current_focus(&self) -> Option<&FocusItem> {
353        self.focus_items.get(self.focus_idx)
354    }
355
356    /// Move focus to next item
357    pub fn focus_next(&mut self) {
358        if !self.focus_items.is_empty() {
359            self.focus_idx = (self.focus_idx + 1) % self.focus_items.len();
360        }
361    }
362
363    /// Move focus to previous item
364    pub fn focus_prev(&mut self) {
365        if !self.focus_items.is_empty() {
366            if self.focus_idx == 0 {
367                self.focus_idx = self.focus_items.len() - 1;
368            } else {
369                self.focus_idx -= 1;
370            }
371        }
372    }
373
374    /// Jump to submit button
375    pub fn focus_submit(&mut self) {
376        if let Some(idx) = self.focus_items.iter().position(|f| *f == FocusItem::Submit) {
377            self.focus_idx = idx;
378        }
379    }
380
381    /// Check if current focus is on a text input (or OtherOption which also accepts typing)
382    pub fn is_text_focused(&self) -> bool {
383        matches!(
384            self.current_focus(),
385            Some(FocusItem::TextInput { .. } | FocusItem::OtherText { .. } | FocusItem::OtherOption { .. })
386        )
387    }
388
389    /// Handle Enter key - select choice or advance
390    fn handle_enter(&mut self) -> EnterAction {
391        match self.current_focus().cloned() {
392            Some(FocusItem::Choice {
393                question_idx,
394                choice_idx,
395            }) => {
396                // Select this choice
397                if let (Some(question), Some(answer)) = (
398                    self.request.questions.get(question_idx),
399                    self.answers.get_mut(question_idx),
400                ) {
401                    let choice_text = match question {
402                        Question::SingleChoice { choices, .. }
403                        | Question::MultiChoice { choices, .. } => {
404                            choices.get(choice_idx).cloned()
405                        }
406                        _ => None,
407                    };
408                    if let Some(text) = choice_text {
409                        answer.select_choice(&text);
410                    }
411                }
412                // For single choice, advance to next question
413                if matches!(
414                    self.request.questions.get(question_idx),
415                    Some(Question::SingleChoice { .. })
416                ) {
417                    self.advance_to_next_question(question_idx);
418                }
419                EnterAction::Selected
420            }
421            Some(FocusItem::OtherOption { question_idx: _ }) => {
422                // Move to the Other text input
423                self.focus_next();
424                EnterAction::Selected
425            }
426            Some(FocusItem::OtherText { question_idx }) => {
427                // Advance to next question
428                self.advance_to_next_question(question_idx);
429                EnterAction::Selected
430            }
431            Some(FocusItem::TextInput { question_idx }) => {
432                // Advance to next question
433                self.advance_to_next_question(question_idx);
434                EnterAction::Selected
435            }
436            Some(FocusItem::Submit) => EnterAction::Submit,
437            Some(FocusItem::Cancel) => EnterAction::Cancel,
438            None => EnterAction::None,
439        }
440    }
441
442    /// Advance focus to the first item of the next question (or Submit)
443    fn advance_to_next_question(&mut self, current_question_idx: usize) {
444        let next_question_idx = current_question_idx + 1;
445
446        // Find the first focus item for the next question
447        if let Some(idx) = self.focus_items.iter().position(|f| match f {
448            FocusItem::Choice { question_idx, .. }
449            | FocusItem::OtherOption { question_idx }
450            | FocusItem::OtherText { question_idx }
451            | FocusItem::TextInput { question_idx } => *question_idx == next_question_idx,
452            FocusItem::Submit | FocusItem::Cancel => false,
453        }) {
454            self.focus_idx = idx;
455        } else {
456            // No more questions, go to Submit
457            self.focus_submit();
458        }
459    }
460
461    /// Handle key input for text areas
462    fn handle_text_input(&mut self, key: KeyEvent) {
463        let focus = self.current_focus().cloned();
464        match focus {
465            Some(FocusItem::TextInput { question_idx }) => {
466                // FreeText question - forward to its textarea
467                if let Some(answer) = self.answers.get_mut(question_idx) {
468                    if let Some(textarea) = answer.textarea_mut() {
469                        textarea.input(key);
470                    }
471                }
472            }
473            Some(FocusItem::OtherText { question_idx })
474            | Some(FocusItem::OtherOption { question_idx }) => {
475                // OtherOption also accepts typing - forward to the textarea
476                if let Some(answer) = self.answers.get_mut(question_idx) {
477                    if let Some(textarea) = answer.textarea_mut() {
478                        textarea.input(key);
479                    }
480                    // For single choice, clear the selected option when typing in "Other"
481                    if let AnswerState::SingleChoice { selected, .. } = answer {
482                        *selected = None;
483                    }
484                }
485            }
486            _ => {}
487        }
488    }
489
490    /// Toggle selection with Space
491    fn handle_space(&mut self) {
492        match self.current_focus().cloned() {
493            Some(FocusItem::Choice {
494                question_idx,
495                choice_idx,
496            }) => {
497                if let (Some(question), Some(answer)) = (
498                    self.request.questions.get(question_idx),
499                    self.answers.get_mut(question_idx),
500                ) {
501                    let choice_text = match question {
502                        Question::SingleChoice { choices, .. }
503                        | Question::MultiChoice { choices, .. } => {
504                            choices.get(choice_idx).cloned()
505                        }
506                        _ => None,
507                    };
508                    if let Some(text) = choice_text {
509                        answer.select_choice(&text);
510                    }
511                }
512            }
513            Some(FocusItem::OtherOption { .. }) => {
514                // Space on Other option moves to text input
515                self.focus_next();
516            }
517            _ => {}
518        }
519    }
520
521    /// Handle a key event
522    ///
523    /// Returns the action that should be taken based on the key press.
524    pub fn process_key(&mut self, key: KeyEvent) -> KeyAction {
525        if !self.active {
526            return KeyAction::NotHandled;
527        }
528
529        // Check if we're in a text input mode
530        let is_text_mode = self.is_text_focused();
531
532        match key.code {
533            // Navigation (always available)
534            KeyCode::Up => {
535                if !is_text_mode {
536                    self.focus_prev();
537                    return KeyAction::Handled;
538                }
539            }
540            KeyCode::Down => {
541                if !is_text_mode {
542                    self.focus_next();
543                    return KeyAction::Handled;
544                }
545            }
546            KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
547                self.focus_prev();
548                return KeyAction::Handled;
549            }
550            KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
551                self.focus_next();
552                return KeyAction::Handled;
553            }
554            KeyCode::Char('k') if !is_text_mode => {
555                self.focus_prev();
556                return KeyAction::Handled;
557            }
558            KeyCode::Char('j') if !is_text_mode => {
559                self.focus_next();
560                return KeyAction::Handled;
561            }
562
563            // Tab to jump to submit
564            KeyCode::Tab => {
565                self.focus_submit();
566                return KeyAction::Handled;
567            }
568
569            // Cancel
570            KeyCode::Esc => {
571                let tool_use_id = self.tool_use_id.clone();
572                // Note: don't deactivate here - let the caller do it after processing
573                return KeyAction::Cancelled(tool_use_id);
574            }
575
576            // Enter - select or submit
577            KeyCode::Enter => {
578                match self.handle_enter() {
579                    EnterAction::Submit => {
580                        let tool_use_id = self.tool_use_id.clone();
581                        let response = self.build_response();
582                        // Note: don't deactivate here - let the caller do it after processing
583                        return KeyAction::Submitted(tool_use_id, response);
584                    }
585                    EnterAction::Cancel => {
586                        let tool_use_id = self.tool_use_id.clone();
587                        // Note: don't deactivate here - let the caller do it after processing
588                        return KeyAction::Cancelled(tool_use_id);
589                    }
590                    EnterAction::Selected | EnterAction::None => {
591                        return KeyAction::Handled;
592                    }
593                }
594            }
595
596            // Space - toggle selection (when not in text mode)
597            KeyCode::Char(' ') if !is_text_mode => {
598                self.handle_space();
599                return KeyAction::Handled;
600            }
601
602            // Text input
603            _ if is_text_mode => {
604                self.handle_text_input(key);
605                return KeyAction::Handled;
606            }
607
608            _ => {}
609        }
610
611        KeyAction::NotHandled
612    }
613
614    /// Calculate the panel height based on content
615    pub fn panel_height(&self, max_height: u16) -> u16 {
616        // Calculate height needed for content
617        let mut lines = 0u16;
618
619        for question in &self.request.questions {
620            lines += 1; // Question text
621            match question {
622                Question::SingleChoice { choices, .. } | Question::MultiChoice { choices, .. } => {
623                    lines += choices.len() as u16;
624                    lines += 1; // "Type Something:" - always shown for custom answers
625                }
626                Question::FreeText { .. } => {
627                    lines += 1; // Text input line
628                }
629            }
630        }
631
632        // Add: help text(1) + help blank(1) + spacing between questions + blank before buttons(1) + buttons(1) + borders(2)
633        let num_questions = self.request.questions.len() as u16;
634        let spacing = if num_questions > 1 { num_questions - 1 } else { 0 };
635        let total = lines + spacing + 7;
636
637        // Cap at percentage of available height, leaving room for chat and input
638        let max_from_percent = (max_height * MAX_PANEL_PERCENT) / 100;
639        total.min(max_from_percent).min(max_height.saturating_sub(6))
640    }
641
642    /// Render the question panel
643    ///
644    /// # Arguments
645    /// * `frame` - The Ratatui frame to render into
646    /// * `area` - The area to render the panel in
647    /// * `theme` - Theme implementation for styling
648    pub fn render_panel(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
649        if !self.active {
650            return;
651        }
652
653        // Clear the area
654        frame.render_widget(Clear, area);
655
656        // Render panel content
657        self.render_panel_content(frame, area, theme);
658    }
659
660    /// Render panel content as a single unified panel with vertical question list
661    fn render_panel_content(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
662        let inner_width = area.width.saturating_sub(4) as usize;
663        let mut lines: Vec<Line> = Vec::new();
664
665        // Help text at top
666        let help_text = if self.is_text_focused() {
667            " Type text | Enter: Next | Tab: Submit | Esc: Cancel"
668        } else {
669            " Up/Down: Navigate | Enter/Space: Select | Tab: Submit | Esc: Cancel"
670        };
671        lines.push(Line::from(Span::styled(help_text, theme.help_text())));
672        lines.push(Line::from("")); // blank line after help
673
674        // Question prefix - dots icon (with leading space for padding from border)
675        const QUESTION_PREFIX: &str = " \u{2237} "; // space + proportion (double colon dots)
676
677        // Render each question vertically
678        for (q_idx, (question, answer)) in self
679            .request
680            .questions
681            .iter()
682            .zip(self.answers.iter())
683            .enumerate()
684        {
685            // Add blank line before each question (except first)
686            if q_idx > 0 {
687                lines.push(Line::from(""));
688            }
689
690            // Question text with arrow prefix and required marker
691            let required = if question.is_required() { "*" } else { "" };
692            let q_text = format!("{}{}{}", QUESTION_PREFIX, question.text(), required);
693            lines.push(Line::from(Span::styled(
694                truncate_text(&q_text, inner_width),
695                Style::default().add_modifier(Modifier::BOLD),
696            )));
697
698            match question {
699                Question::SingleChoice { choices, .. } => {
700                    self.render_choices(&mut lines, q_idx, choices, answer, false, inner_width, theme);
701                }
702                Question::MultiChoice { choices, .. } => {
703                    self.render_choices(&mut lines, q_idx, choices, answer, true, inner_width, theme);
704                }
705                Question::FreeText { .. } => {
706                    self.render_text_input(&mut lines, q_idx, answer, inner_width, theme);
707                }
708            }
709        }
710
711        // Add blank line before buttons
712        lines.push(Line::from(""));
713
714        // Add buttons - Submit and Cancel side by side
715        let submit_focused = self.current_focus() == Some(&FocusItem::Submit);
716        let cancel_focused = self.current_focus() == Some(&FocusItem::Cancel);
717
718        let submit_style = if submit_focused {
719            theme.button_confirm_focused()
720        } else {
721            theme.button_confirm()
722        };
723        let cancel_style = if cancel_focused {
724            theme.button_cancel_focused()
725        } else {
726            theme.button_cancel()
727        };
728
729        let mut button_spans = vec![Span::raw("  ")];
730
731        // Submit with indicator if focused
732        if submit_focused {
733            button_spans.push(Span::styled("\u{203A} ", theme.focus_indicator()));
734        }
735        button_spans.push(Span::styled("Submit", submit_style));
736        button_spans.push(Span::raw("   "));
737
738        // Cancel with indicator if focused
739        if cancel_focused {
740            button_spans.push(Span::styled("\u{203A} ", theme.focus_indicator()));
741        }
742        button_spans.push(Span::styled("Cancel", cancel_style));
743
744        lines.push(Line::from(button_spans));
745
746        let block = Block::default()
747            .borders(Borders::ALL)
748            .border_style(theme.warning())
749            .title(Span::styled(
750                " User Input Required ",
751                theme.warning().add_modifier(Modifier::BOLD),
752            ));
753
754        let paragraph = Paragraph::new(lines).block(block);
755        frame.render_widget(paragraph, area);
756    }
757
758    /// Render choice options inline
759    fn render_choices(
760        &self,
761        lines: &mut Vec<Line>,
762        question_idx: usize,
763        choices: &[String],
764        answer: &AnswerState,
765        is_multi: bool,
766        inner_width: usize,
767        theme: &Theme,
768    ) {
769        // Selection indicator for focused items
770        const INDICATOR: &str = " \u{203A} "; // space + arrow
771        const NO_INDICATOR: &str = "   "; // 3 spaces to match
772
773        for (c_idx, choice_text) in choices.iter().enumerate() {
774            let is_focused = self.current_focus()
775                == Some(&FocusItem::Choice {
776                    question_idx,
777                    choice_idx: c_idx,
778                });
779            let is_selected = answer.is_selected(choice_text);
780
781            let symbol = if is_multi {
782                if is_selected { "\u{25A0}" } else { "\u{25A1}" } // filled/empty square
783            } else {
784                if is_selected { "\u{25CF}" } else { "\u{25CB}" } // filled/empty circle
785            };
786
787            let prefix = if is_focused { INDICATOR } else { NO_INDICATOR };
788            let display_text = truncate_text(choice_text, inner_width - 8);
789
790            if is_focused {
791                lines.push(Line::from(vec![
792                    Span::styled(prefix, theme.focus_indicator()),
793                    Span::styled(format!("{} {}", symbol, display_text), theme.focused_text()),
794                ]));
795            } else {
796                lines.push(Line::from(Span::styled(
797                    format!("{}{} {}", prefix, symbol, display_text),
798                    theme.muted_text(),
799                )));
800            }
801        }
802
803        // "Type Something:" option - always available for custom answers
804        let other_focused = self.current_focus() == Some(&FocusItem::OtherOption { question_idx });
805        let other_text_focused = self.current_focus() == Some(&FocusItem::OtherText { question_idx });
806        let is_this_focused = other_focused || other_text_focused;
807        let has_other = answer.has_other_text();
808
809        // For single choice, "Type Something" is selected if:
810        // - no other choice is selected AND (has text OR is currently focused)
811        // For multi choice, it's selected if there's text
812        let is_other_selected = match answer {
813            AnswerState::SingleChoice { selected, .. } => selected.is_none() && (has_other || is_this_focused),
814            AnswerState::MultiChoice { .. } => has_other,
815            _ => false,
816        };
817
818        let symbol = if is_multi {
819            if is_other_selected { "\u{25A0}" } else { "\u{25A1}" }
820        } else {
821            if is_other_selected { "\u{25CF}" } else { "\u{25CB}" }
822        };
823
824        let prefix = if is_this_focused { INDICATOR } else { NO_INDICATOR };
825
826        // Get the text input content and cursor position
827        let (other_text, cursor_col) = match answer {
828            AnswerState::SingleChoice { other_text, .. }
829            | AnswerState::MultiChoice { other_text, .. } => {
830                let text = other_text.lines().first().cloned().unwrap_or_default();
831                let col = other_text.cursor().1;
832                (text, col)
833            }
834            _ => (String::new(), 0),
835        };
836
837        // Build the text display with cursor shown as inverse character
838        if is_this_focused {
839            let chars: Vec<char> = other_text.chars().collect();
840            let cursor_pos = cursor_col.min(chars.len());
841            let before: String = chars[..cursor_pos].iter().collect();
842            let cursor_char = chars.get(cursor_pos).copied().unwrap_or(' ');
843            let after: String = chars.get(cursor_pos + 1..).map(|s| s.iter().collect()).unwrap_or_default();
844
845            lines.push(Line::from(vec![
846                Span::styled(prefix, theme.focus_indicator()),
847                Span::styled(format!("{} Type Something: ", symbol), theme.focused_text()),
848                Span::styled(before, theme.focused_text()),
849                Span::styled(cursor_char.to_string(), theme.cursor()),
850                Span::styled(after, theme.focused_text()),
851            ]));
852        } else {
853            let truncated_display = truncate_text(&other_text, inner_width - 24);
854            lines.push(Line::from(Span::styled(
855                format!("{}{} Type Something: {}", prefix, symbol, truncated_display),
856                theme.muted_text(),
857            )));
858        }
859    }
860
861    /// Render free text input inline
862    fn render_text_input(
863        &self,
864        lines: &mut Vec<Line>,
865        question_idx: usize,
866        answer: &AnswerState,
867        inner_width: usize,
868        theme: &Theme,
869    ) {
870        // Selection indicator for focused items
871        const INDICATOR: &str = " \u{203A} "; // space + arrow
872        const NO_INDICATOR: &str = "   "; // 3 spaces to match
873
874        let is_focused = self.current_focus() == Some(&FocusItem::TextInput { question_idx });
875
876        let (text, cursor_col) = match answer {
877            AnswerState::FreeText { textarea } => {
878                let t = textarea.lines().first().cloned().unwrap_or_default();
879                let col = textarea.cursor().1;
880                (t, col)
881            }
882            _ => (String::new(), 0),
883        };
884
885        let prefix = if is_focused { INDICATOR } else { NO_INDICATOR };
886
887        if is_focused {
888            // Show cursor as inverse character
889            let chars: Vec<char> = text.chars().collect();
890            let cursor_pos = cursor_col.min(chars.len());
891            let before: String = chars[..cursor_pos].iter().collect();
892            let cursor_char = chars.get(cursor_pos).copied().unwrap_or(' ');
893            let after: String = chars.get(cursor_pos + 1..).map(|s| s.iter().collect()).unwrap_or_default();
894
895            lines.push(Line::from(vec![
896                Span::styled(prefix, theme.focus_indicator()),
897                Span::styled("Type Something: ", theme.focused_text()),
898                Span::styled(before, theme.focused_text()),
899                Span::styled(cursor_char.to_string(), theme.cursor()),
900                Span::styled(after, theme.focused_text()),
901            ]));
902        } else {
903            let display = if text.is_empty() {
904                "Type Something:".to_string()
905            } else {
906                format!("Type Something: {}", text)
907            };
908            let truncated = truncate_text(&display, inner_width - 4);
909            lines.push(Line::from(Span::styled(
910                format!("{}{}", prefix, truncated),
911                theme.muted_text(),
912            )));
913        }
914    }
915}
916
917impl Default for QuestionPanel {
918    fn default() -> Self {
919        Self::new()
920    }
921}
922
923// --- Widget trait implementation ---
924
925use std::any::Any;
926use super::{widget_ids, Widget, WidgetAction, WidgetKeyResult};
927
928impl Widget for QuestionPanel {
929    fn id(&self) -> &'static str {
930        widget_ids::QUESTION_PANEL
931    }
932
933    fn priority(&self) -> u8 {
934        200 // High priority - modal panel
935    }
936
937    fn is_active(&self) -> bool {
938        self.active
939    }
940
941    fn handle_key(&mut self, key: KeyEvent, _theme: &Theme) -> WidgetKeyResult {
942        if !self.active {
943            return WidgetKeyResult::NotHandled;
944        }
945
946        match self.process_key(key) {
947            KeyAction::Submitted(tool_use_id, response) => {
948                WidgetKeyResult::Action(WidgetAction::SubmitQuestion {
949                    tool_use_id,
950                    response,
951                })
952            }
953            KeyAction::Cancelled(tool_use_id) => {
954                WidgetKeyResult::Action(WidgetAction::CancelQuestion { tool_use_id })
955            }
956            KeyAction::Handled | KeyAction::NotHandled => WidgetKeyResult::Handled,
957        }
958    }
959
960    fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
961        self.render_panel(frame, area, theme);
962    }
963
964    fn required_height(&self, max_height: u16) -> u16 {
965        if self.active {
966            self.panel_height(max_height)
967        } else {
968            0
969        }
970    }
971
972    fn blocks_input(&self) -> bool {
973        self.active
974    }
975
976    fn is_overlay(&self) -> bool {
977        false
978    }
979
980    fn as_any(&self) -> &dyn Any {
981        self
982    }
983
984    fn as_any_mut(&mut self) -> &mut dyn Any {
985        self
986    }
987
988    fn into_any(self: Box<Self>) -> Box<dyn Any> {
989        self
990    }
991}
992
993/// Truncate text to fit width
994fn truncate_text(text: &str, max_width: usize) -> String {
995    if text.chars().count() <= max_width {
996        text.to_string()
997    } else {
998        let truncated: String = text.chars().take(max_width.saturating_sub(3)).collect();
999        format!("{}...", truncated)
1000    }
1001}
1002
1003#[cfg(test)]
1004mod tests {
1005    use super::*;
1006
1007    fn create_test_request() -> AskUserQuestionsRequest {
1008        AskUserQuestionsRequest {
1009            questions: vec![
1010                Question::SingleChoice {
1011                    text: "Choose one".to_string(),
1012                    choices: vec!["Option A".to_string(), "Option B".to_string()],
1013                    required: true,
1014                },
1015            ],
1016        }
1017    }
1018
1019    #[test]
1020    fn test_panel_activation() {
1021        let mut panel = QuestionPanel::new();
1022        assert!(!panel.is_active());
1023
1024        let request = create_test_request();
1025        panel.activate("tool_123".to_string(), 1, request, None);
1026
1027        assert!(panel.is_active());
1028        assert_eq!(panel.tool_use_id(), "tool_123");
1029        assert_eq!(panel.session_id(), 1);
1030
1031        panel.deactivate();
1032        assert!(!panel.is_active());
1033    }
1034
1035    #[test]
1036    fn test_navigation() {
1037        let mut panel = QuestionPanel::new();
1038        let request = create_test_request();
1039        panel.activate("tool_1".to_string(), 1, request, None);
1040
1041        // Default focus is first choice
1042        assert_eq!(
1043            panel.current_focus(),
1044            Some(&FocusItem::Choice {
1045                question_idx: 0,
1046                choice_idx: 0
1047            })
1048        );
1049
1050        // Move to next
1051        panel.focus_next();
1052        assert_eq!(
1053            panel.current_focus(),
1054            Some(&FocusItem::Choice {
1055                question_idx: 0,
1056                choice_idx: 1
1057            })
1058        );
1059
1060        // Jump to submit
1061        panel.focus_submit();
1062        assert_eq!(panel.current_focus(), Some(&FocusItem::Submit));
1063    }
1064
1065    #[test]
1066    fn test_handle_key_cancel() {
1067        let mut panel = QuestionPanel::new();
1068        let request = create_test_request();
1069        panel.activate("tool_1".to_string(), 1, request, None);
1070
1071        let action = panel.process_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1072        match action {
1073            KeyAction::Cancelled(tool_use_id) => {
1074                assert_eq!(tool_use_id, "tool_1");
1075            }
1076            _ => panic!("Expected Cancelled action"),
1077        }
1078        // Panel doesn't deactivate itself - caller must do it
1079        panel.deactivate();
1080        assert!(!panel.is_active());
1081    }
1082
1083    #[test]
1084    fn test_answer_state_single_choice() {
1085        let mut state = AnswerState::SingleChoice {
1086            selected: None,
1087            other_text: TextArea::default(),
1088        };
1089
1090        assert!(!state.is_selected("Option A"));
1091        state.select_choice("Option A");
1092        assert!(state.is_selected("Option A"));
1093
1094        // Selecting another clears the first
1095        state.select_choice("Option B");
1096        assert!(!state.is_selected("Option A"));
1097        assert!(state.is_selected("Option B"));
1098    }
1099
1100    #[test]
1101    fn test_answer_state_multi_choice() {
1102        let mut state = AnswerState::MultiChoice {
1103            selected: HashSet::new(),
1104            other_text: TextArea::default(),
1105        };
1106
1107        state.select_choice("Option A");
1108        state.select_choice("Option B");
1109        assert!(state.is_selected("Option A"));
1110        assert!(state.is_selected("Option B"));
1111
1112        // Toggle off
1113        state.select_choice("Option A");
1114        assert!(!state.is_selected("Option A"));
1115        assert!(state.is_selected("Option B"));
1116    }
1117}