use std::collections::HashSet;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::controller::{
Answer, AskUserQuestionsRequest, AskUserQuestionsResponse, Question, TurnId,
};
use ratatui::{
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
use tui_textarea::TextArea;
use crate::themes::Theme;
pub mod defaults {
pub const MAX_PANEL_PERCENT: u16 = 70;
pub const SELECTION_INDICATOR: &str = " \u{203A} ";
pub const NO_INDICATOR: &str = " ";
pub const TITLE: &str = " User Input Required ";
pub const HELP_TEXT_NAV: &str = " Up/Down: Navigate \u{00B7} Enter/Space: Select \u{00B7} Tab: Next Section \u{00B7} Esc: Cancel";
pub const HELP_TEXT_INPUT: &str = " Type text \u{00B7} Enter: Next \u{00B7} Tab: Next Section \u{00B7} Esc: Cancel";
pub const QUESTION_PREFIX: &str = " \u{2237} ";
pub const RADIO_SELECTED: &str = "\u{25CF}";
pub const RADIO_UNSELECTED: &str = "\u{25CB}";
pub const CHECKBOX_SELECTED: &str = "\u{25A0}";
pub const CHECKBOX_UNSELECTED: &str = "\u{25A1}";
}
#[derive(Clone)]
pub struct QuestionPanelConfig {
pub max_panel_percent: u16,
pub selection_indicator: String,
pub no_indicator: String,
pub title: String,
pub help_text_nav: String,
pub help_text_input: String,
pub question_prefix: String,
pub radio_selected: String,
pub radio_unselected: String,
pub checkbox_selected: String,
pub checkbox_unselected: String,
}
impl Default for QuestionPanelConfig {
fn default() -> Self {
Self::new()
}
}
impl QuestionPanelConfig {
pub fn new() -> Self {
Self {
max_panel_percent: defaults::MAX_PANEL_PERCENT,
selection_indicator: defaults::SELECTION_INDICATOR.to_string(),
no_indicator: defaults::NO_INDICATOR.to_string(),
title: defaults::TITLE.to_string(),
help_text_nav: defaults::HELP_TEXT_NAV.to_string(),
help_text_input: defaults::HELP_TEXT_INPUT.to_string(),
question_prefix: defaults::QUESTION_PREFIX.to_string(),
radio_selected: defaults::RADIO_SELECTED.to_string(),
radio_unselected: defaults::RADIO_UNSELECTED.to_string(),
checkbox_selected: defaults::CHECKBOX_SELECTED.to_string(),
checkbox_unselected: defaults::CHECKBOX_UNSELECTED.to_string(),
}
}
pub fn with_max_panel_percent(mut self, percent: u16) -> Self {
self.max_panel_percent = percent;
self
}
pub fn with_selection_indicator(mut self, indicator: impl Into<String>) -> Self {
self.selection_indicator = indicator.into();
self
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
pub fn with_radio_symbols(mut self, selected: impl Into<String>, unselected: impl Into<String>) -> Self {
self.radio_selected = selected.into();
self.radio_unselected = unselected.into();
self
}
pub fn with_checkbox_symbols(mut self, selected: impl Into<String>, unselected: impl Into<String>) -> Self {
self.checkbox_selected = selected.into();
self.checkbox_unselected = unselected.into();
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FocusItem {
Choice {
question_idx: usize,
choice_idx: usize,
},
OtherOption { question_idx: usize },
OtherText { question_idx: usize },
TextInput { question_idx: usize },
Submit,
Cancel,
}
pub enum AnswerState {
SingleChoice {
selected: Option<String>,
other_text: TextArea<'static>,
},
MultiChoice {
selected: HashSet<String>,
other_text: TextArea<'static>,
},
FreeText { textarea: TextArea<'static> },
}
impl AnswerState {
pub fn from_question(question: &Question) -> Self {
match question {
Question::SingleChoice { .. } => {
let other_text = TextArea::default();
AnswerState::SingleChoice {
selected: None,
other_text,
}
}
Question::MultiChoice { .. } => {
let other_text = TextArea::default();
AnswerState::MultiChoice {
selected: HashSet::new(),
other_text,
}
}
Question::FreeText { default_value, .. } => {
let mut textarea = TextArea::default();
if let Some(default) = default_value {
textarea.insert_str(default);
}
AnswerState::FreeText { textarea }
}
}
}
pub fn to_answer(&self, question_text: &str) -> Answer {
match self {
AnswerState::SingleChoice {
selected,
other_text,
} => {
let other = other_text.lines().join("\n");
let answer_values = if !other.is_empty() {
vec![other]
} else {
selected.iter().cloned().collect()
};
Answer {
question: question_text.to_string(),
answer: answer_values,
}
}
AnswerState::MultiChoice {
selected,
other_text,
} => {
let other = other_text.lines().join("\n");
let mut answer_values: Vec<String> = selected.iter().cloned().collect();
if !other.is_empty() {
answer_values.push(other);
}
Answer {
question: question_text.to_string(),
answer: answer_values,
}
}
AnswerState::FreeText { textarea } => Answer {
question: question_text.to_string(),
answer: vec![textarea.lines().join("\n")],
},
}
}
pub fn is_selected(&self, choice_id: &str) -> bool {
match self {
AnswerState::SingleChoice { selected, .. } => {
selected.as_ref().map(|s| s == choice_id).unwrap_or(false)
}
AnswerState::MultiChoice { selected, .. } => selected.contains(choice_id),
AnswerState::FreeText { .. } => false,
}
}
pub fn select_choice(&mut self, choice_id: &str) {
match self {
AnswerState::SingleChoice { selected, .. } => {
*selected = Some(choice_id.to_string());
}
AnswerState::MultiChoice { selected, .. } => {
if selected.contains(choice_id) {
selected.remove(choice_id);
} else {
selected.insert(choice_id.to_string());
}
}
AnswerState::FreeText { .. } => {}
}
}
pub fn textarea_mut(&mut self) -> Option<&mut TextArea<'static>> {
match self {
AnswerState::SingleChoice { other_text, .. }
| AnswerState::MultiChoice { other_text, .. } => Some(other_text),
AnswerState::FreeText { textarea } => Some(textarea),
}
}
pub fn has_other_text(&self) -> bool {
match self {
AnswerState::SingleChoice { other_text, .. }
| AnswerState::MultiChoice { other_text, .. } => {
!other_text.lines().join("").is_empty()
}
AnswerState::FreeText { .. } => false,
}
}
pub fn has_answer(&self) -> bool {
match self {
AnswerState::SingleChoice { selected, other_text } => {
selected.is_some() || !other_text.lines().join("").trim().is_empty()
}
AnswerState::MultiChoice { selected, other_text } => {
!selected.is_empty() || !other_text.lines().join("").trim().is_empty()
}
AnswerState::FreeText { textarea } => {
!textarea.lines().join("").trim().is_empty()
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnterAction {
None,
Selected,
Submit,
Cancel,
}
#[derive(Debug, Clone)]
pub enum KeyAction {
Handled,
NotHandled,
Submitted(String, AskUserQuestionsResponse),
Cancelled(String),
}
pub struct QuestionPanel {
active: bool,
tool_use_id: String,
session_id: i64,
request: AskUserQuestionsRequest,
turn_id: Option<TurnId>,
answers: Vec<AnswerState>,
focus_items: Vec<FocusItem>,
focus_idx: usize,
config: QuestionPanelConfig,
}
impl QuestionPanel {
pub fn new() -> Self {
Self::with_config(QuestionPanelConfig::new())
}
pub fn with_config(config: QuestionPanelConfig) -> Self {
Self {
active: false,
tool_use_id: String::new(),
session_id: 0,
request: AskUserQuestionsRequest {
questions: Vec::new(),
},
turn_id: None,
answers: Vec::new(),
focus_items: Vec::new(),
focus_idx: 0,
config,
}
}
pub fn config(&self) -> &QuestionPanelConfig {
&self.config
}
pub fn set_config(&mut self, config: QuestionPanelConfig) {
self.config = config;
}
pub fn activate(
&mut self,
tool_use_id: String,
session_id: i64,
request: AskUserQuestionsRequest,
turn_id: Option<TurnId>,
) {
self.active = true;
self.tool_use_id = tool_use_id;
self.session_id = session_id;
self.answers = request
.questions
.iter()
.map(AnswerState::from_question)
.collect();
self.focus_items = Self::build_focus_items(&request.questions);
self.focus_idx = 0;
self.request = request;
self.turn_id = turn_id;
}
fn build_focus_items(questions: &[Question]) -> Vec<FocusItem> {
let mut items = Vec::new();
for (q_idx, question) in questions.iter().enumerate() {
match question {
Question::SingleChoice { choices, .. } | Question::MultiChoice { choices, .. } => {
for c_idx in 0..choices.len() {
items.push(FocusItem::Choice {
question_idx: q_idx,
choice_idx: c_idx,
});
}
items.push(FocusItem::OtherOption { question_idx: q_idx });
}
Question::FreeText { .. } => {
items.push(FocusItem::TextInput { question_idx: q_idx });
}
}
}
items.push(FocusItem::Submit);
items.push(FocusItem::Cancel);
items
}
pub fn deactivate(&mut self) {
self.active = false;
self.tool_use_id.clear();
self.request.questions.clear();
self.answers.clear();
self.focus_items.clear();
self.focus_idx = 0;
}
pub fn is_active(&self) -> bool {
self.active
}
pub fn tool_use_id(&self) -> &str {
&self.tool_use_id
}
pub fn session_id(&self) -> i64 {
self.session_id
}
pub fn request(&self) -> &AskUserQuestionsRequest {
&self.request
}
pub fn turn_id(&self) -> Option<&TurnId> {
self.turn_id.as_ref()
}
pub fn build_response(&self) -> AskUserQuestionsResponse {
let answers = self
.request
.questions
.iter()
.zip(self.answers.iter())
.map(|(q, a)| a.to_answer(q.text()))
.collect();
AskUserQuestionsResponse { answers }
}
pub fn current_focus(&self) -> Option<&FocusItem> {
self.focus_items.get(self.focus_idx)
}
pub fn focus_next(&mut self) {
if !self.focus_items.is_empty() {
self.focus_idx = (self.focus_idx + 1) % self.focus_items.len();
}
}
pub fn focus_prev(&mut self) {
if !self.focus_items.is_empty() {
if self.focus_idx == 0 {
self.focus_idx = self.focus_items.len() - 1;
} else {
self.focus_idx -= 1;
}
}
}
pub fn focus_submit(&mut self) {
if let Some(idx) = self.focus_items.iter().position(|f| *f == FocusItem::Submit) {
self.focus_idx = idx;
}
}
pub fn is_text_focused(&self) -> bool {
matches!(
self.current_focus(),
Some(FocusItem::TextInput { .. } | FocusItem::OtherText { .. } | FocusItem::OtherOption { .. })
)
}
fn handle_enter(&mut self) -> EnterAction {
match self.current_focus().cloned() {
Some(FocusItem::Choice {
question_idx,
choice_idx,
}) => {
if let (Some(question), Some(answer)) = (
self.request.questions.get(question_idx),
self.answers.get_mut(question_idx),
) {
let choice_text = match question {
Question::SingleChoice { choices, .. }
| Question::MultiChoice { choices, .. } => {
choices.get(choice_idx).cloned()
}
_ => None,
};
if let Some(text) = choice_text {
answer.select_choice(&text);
}
}
if matches!(
self.request.questions.get(question_idx),
Some(Question::SingleChoice { .. })
) {
self.advance_to_next_question(question_idx);
}
EnterAction::Selected
}
Some(FocusItem::OtherOption { question_idx: _ }) => {
self.focus_next();
EnterAction::Selected
}
Some(FocusItem::OtherText { question_idx }) => {
self.advance_to_next_question(question_idx);
EnterAction::Selected
}
Some(FocusItem::TextInput { question_idx }) => {
self.advance_to_next_question(question_idx);
EnterAction::Selected
}
Some(FocusItem::Submit) => {
if self.can_submit() {
EnterAction::Submit
} else {
EnterAction::None }
}
Some(FocusItem::Cancel) => EnterAction::Cancel,
None => EnterAction::None,
}
}
fn advance_to_next_question(&mut self, current_question_idx: usize) {
let next_question_idx = current_question_idx + 1;
if let Some(idx) = self.focus_items.iter().position(|f| match f {
FocusItem::Choice { question_idx, .. }
| FocusItem::OtherOption { question_idx }
| FocusItem::OtherText { question_idx }
| FocusItem::TextInput { question_idx } => *question_idx == next_question_idx,
FocusItem::Submit | FocusItem::Cancel => false,
}) {
self.focus_idx = idx;
} else {
self.focus_submit();
}
}
fn current_question_idx(&self) -> Option<usize> {
match self.current_focus() {
Some(FocusItem::Choice { question_idx, .. })
| Some(FocusItem::OtherOption { question_idx })
| Some(FocusItem::OtherText { question_idx })
| Some(FocusItem::TextInput { question_idx }) => Some(*question_idx),
_ => None,
}
}
fn focus_next_section(&mut self) {
let current_q = self.current_question_idx();
let next_q = current_q.map(|q| q + 1).unwrap_or(0);
if let Some(idx) = self.focus_items.iter().position(|f| match f {
FocusItem::Choice { question_idx, .. }
| FocusItem::OtherOption { question_idx }
| FocusItem::OtherText { question_idx }
| FocusItem::TextInput { question_idx } => *question_idx == next_q,
FocusItem::Submit | FocusItem::Cancel => false,
}) {
self.focus_idx = idx;
} else {
self.focus_submit();
}
}
fn focus_prev_section(&mut self) {
let current_q = self.current_question_idx();
let prev_q = match current_q {
Some(q) if q > 0 => q - 1,
_ => {
let last_q = self.request.questions.len().saturating_sub(1);
if current_q == Some(0) {
self.focus_submit();
return;
}
last_q
}
};
if let Some(idx) = self.focus_items.iter().position(|f| match f {
FocusItem::Choice { question_idx, .. }
| FocusItem::OtherOption { question_idx }
| FocusItem::OtherText { question_idx }
| FocusItem::TextInput { question_idx } => *question_idx == prev_q,
FocusItem::Submit | FocusItem::Cancel => false,
}) {
self.focus_idx = idx;
}
}
pub fn can_submit(&self) -> bool {
for (question, answer) in self.request.questions.iter().zip(self.answers.iter()) {
if question.is_required() && !answer.has_answer() {
return false;
}
}
true
}
fn handle_text_input(&mut self, key: KeyEvent) {
let focus = self.current_focus().cloned();
match focus {
Some(FocusItem::TextInput { question_idx }) => {
if let Some(answer) = self.answers.get_mut(question_idx) {
if let Some(textarea) = answer.textarea_mut() {
textarea.input(key);
}
}
}
Some(FocusItem::OtherText { question_idx })
| Some(FocusItem::OtherOption { question_idx }) => {
if let Some(answer) = self.answers.get_mut(question_idx) {
if let Some(textarea) = answer.textarea_mut() {
textarea.input(key);
}
if let AnswerState::SingleChoice { selected, .. } = answer {
*selected = None;
}
}
}
_ => {}
}
}
fn handle_space(&mut self) {
match self.current_focus().cloned() {
Some(FocusItem::Choice {
question_idx,
choice_idx,
}) => {
if let (Some(question), Some(answer)) = (
self.request.questions.get(question_idx),
self.answers.get_mut(question_idx),
) {
let choice_text = match question {
Question::SingleChoice { choices, .. }
| Question::MultiChoice { choices, .. } => {
choices.get(choice_idx).cloned()
}
_ => None,
};
if let Some(text) = choice_text {
answer.select_choice(&text);
}
}
}
Some(FocusItem::OtherOption { .. }) => {
self.focus_next();
}
_ => {}
}
}
pub fn process_key(&mut self, key: KeyEvent) -> KeyAction {
if !self.active {
return KeyAction::NotHandled;
}
let is_text_mode = self.is_text_focused();
match key.code {
KeyCode::Up => {
if !is_text_mode {
self.focus_prev();
return KeyAction::Handled;
}
}
KeyCode::Down => {
if !is_text_mode {
self.focus_next();
return KeyAction::Handled;
}
}
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.focus_prev();
return KeyAction::Handled;
}
KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.focus_next();
return KeyAction::Handled;
}
KeyCode::Char('k') if !is_text_mode => {
self.focus_prev();
return KeyAction::Handled;
}
KeyCode::Char('j') if !is_text_mode => {
self.focus_next();
return KeyAction::Handled;
}
KeyCode::Tab => {
self.focus_next_section();
return KeyAction::Handled;
}
KeyCode::BackTab => {
self.focus_prev_section();
return KeyAction::Handled;
}
KeyCode::Esc => {
let tool_use_id = self.tool_use_id.clone();
return KeyAction::Cancelled(tool_use_id);
}
KeyCode::Enter => {
match self.handle_enter() {
EnterAction::Submit => {
let tool_use_id = self.tool_use_id.clone();
let response = self.build_response();
return KeyAction::Submitted(tool_use_id, response);
}
EnterAction::Cancel => {
let tool_use_id = self.tool_use_id.clone();
return KeyAction::Cancelled(tool_use_id);
}
EnterAction::Selected | EnterAction::None => {
return KeyAction::Handled;
}
}
}
KeyCode::Char(' ') if !is_text_mode => {
self.handle_space();
return KeyAction::Handled;
}
_ if is_text_mode => {
self.handle_text_input(key);
return KeyAction::Handled;
}
_ => {}
}
KeyAction::NotHandled
}
pub fn panel_height(&self, max_height: u16) -> u16 {
let mut lines = 0u16;
for question in &self.request.questions {
lines += 1; match question {
Question::SingleChoice { choices, .. } | Question::MultiChoice { choices, .. } => {
lines += choices.len() as u16;
lines += 1; }
Question::FreeText { .. } => {
lines += 1; }
}
}
let num_questions = self.request.questions.len() as u16;
let spacing = if num_questions > 1 { num_questions - 1 } else { 0 };
let total = lines + spacing + 7;
let max_from_percent = (max_height * self.config.max_panel_percent) / 100;
total.min(max_from_percent).min(max_height.saturating_sub(6))
}
pub fn render_panel(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
if !self.active {
return;
}
frame.render_widget(Clear, area);
self.render_panel_content(frame, area, theme);
}
fn render_panel_content(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
let inner_width = area.width.saturating_sub(4) as usize;
let mut lines: Vec<Line> = Vec::new();
let help_text = if self.is_text_focused() {
&self.config.help_text_input
} else {
&self.config.help_text_nav
};
lines.push(Line::from(Span::styled(help_text.clone(), theme.help_text())));
lines.push(Line::from(""));
for (q_idx, (question, answer)) in self
.request
.questions
.iter()
.zip(self.answers.iter())
.enumerate()
{
if q_idx > 0 {
lines.push(Line::from(""));
}
let required = if question.is_required() { "*" } else { "" };
let q_text = format!("{}{}{}", self.config.question_prefix, question.text(), required);
lines.push(Line::from(Span::styled(
truncate_text(&q_text, inner_width),
Style::default().add_modifier(Modifier::BOLD),
)));
match question {
Question::SingleChoice { choices, .. } => {
self.render_choices(&mut lines, q_idx, choices, answer, false, inner_width, theme);
}
Question::MultiChoice { choices, .. } => {
self.render_choices(&mut lines, q_idx, choices, answer, true, inner_width, theme);
}
Question::FreeText { .. } => {
self.render_text_input(&mut lines, q_idx, answer, inner_width, theme);
}
}
}
lines.push(Line::from(""));
let submit_focused = self.current_focus() == Some(&FocusItem::Submit);
let cancel_focused = self.current_focus() == Some(&FocusItem::Cancel);
let submit_enabled = self.can_submit();
let submit_style = if !submit_enabled {
theme.muted_text()
} else if submit_focused {
theme.button_confirm_focused()
} else {
theme.button_confirm()
};
let cancel_style = if cancel_focused {
theme.button_cancel_focused()
} else {
theme.button_cancel()
};
let mut button_spans = vec![Span::raw(" ")];
if submit_focused && submit_enabled {
button_spans.push(Span::styled("\u{203A} ", theme.focus_indicator()));
}
button_spans.push(Span::styled("Submit", submit_style));
button_spans.push(Span::raw(" "));
if cancel_focused {
button_spans.push(Span::styled("\u{203A} ", theme.focus_indicator()));
}
button_spans.push(Span::styled("Cancel", cancel_style));
lines.push(Line::from(button_spans));
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme.warning())
.title(Span::styled(
self.config.title.clone(),
theme.warning().add_modifier(Modifier::BOLD),
));
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
fn render_choices(
&self,
lines: &mut Vec<Line>,
question_idx: usize,
choices: &[String],
answer: &AnswerState,
is_multi: bool,
inner_width: usize,
theme: &Theme,
) {
for (c_idx, choice_text) in choices.iter().enumerate() {
let is_focused = self.current_focus()
== Some(&FocusItem::Choice {
question_idx,
choice_idx: c_idx,
});
let is_selected = answer.is_selected(choice_text);
let symbol = if is_multi {
if is_selected { &self.config.checkbox_selected } else { &self.config.checkbox_unselected }
} else {
if is_selected { &self.config.radio_selected } else { &self.config.radio_unselected }
};
let prefix = if is_focused { &self.config.selection_indicator } else { &self.config.no_indicator };
let display_text = truncate_text(choice_text, inner_width - 8);
if is_focused {
lines.push(Line::from(vec![
Span::styled(prefix.clone(), theme.focus_indicator()),
Span::styled(format!("{} {}", symbol, display_text), theme.focused_text()),
]));
} else {
lines.push(Line::from(Span::styled(
format!("{}{} {}", prefix, symbol, display_text),
theme.muted_text(),
)));
}
}
let other_focused = self.current_focus() == Some(&FocusItem::OtherOption { question_idx });
let other_text_focused = self.current_focus() == Some(&FocusItem::OtherText { question_idx });
let is_this_focused = other_focused || other_text_focused;
let has_other = answer.has_other_text();
let is_other_selected = match answer {
AnswerState::SingleChoice { selected, .. } => selected.is_none() && (has_other || is_this_focused),
AnswerState::MultiChoice { .. } => has_other,
_ => false,
};
let symbol = if is_multi {
if is_other_selected { &self.config.checkbox_selected } else { &self.config.checkbox_unselected }
} else {
if is_other_selected { &self.config.radio_selected } else { &self.config.radio_unselected }
};
let prefix = if is_this_focused { &self.config.selection_indicator } else { &self.config.no_indicator };
let (other_text, cursor_col) = match answer {
AnswerState::SingleChoice { other_text, .. }
| AnswerState::MultiChoice { other_text, .. } => {
let text = other_text.lines().first().cloned().unwrap_or_default();
let col = other_text.cursor().1;
(text, col)
}
_ => (String::new(), 0),
};
if is_this_focused {
let chars: Vec<char> = other_text.chars().collect();
let cursor_pos = cursor_col.min(chars.len());
let before: String = chars[..cursor_pos].iter().collect();
let cursor_char = chars.get(cursor_pos).copied().unwrap_or(' ');
let after: String = chars.get(cursor_pos + 1..).map(|s| s.iter().collect()).unwrap_or_default();
lines.push(Line::from(vec![
Span::styled(prefix.clone(), theme.focus_indicator()),
Span::styled(format!("{} Type Something: ", symbol), theme.focused_text()),
Span::styled(before, theme.focused_text()),
Span::styled(cursor_char.to_string(), theme.cursor()),
Span::styled(after, theme.focused_text()),
]));
} else {
let truncated_display = truncate_text(&other_text, inner_width - 24);
lines.push(Line::from(Span::styled(
format!("{}{} Type Something: {}", prefix, symbol, truncated_display),
theme.muted_text(),
)));
}
}
fn render_text_input(
&self,
lines: &mut Vec<Line>,
question_idx: usize,
answer: &AnswerState,
inner_width: usize,
theme: &Theme,
) {
let is_focused = self.current_focus() == Some(&FocusItem::TextInput { question_idx });
let (text, cursor_col) = match answer {
AnswerState::FreeText { textarea } => {
let t = textarea.lines().first().cloned().unwrap_or_default();
let col = textarea.cursor().1;
(t, col)
}
_ => (String::new(), 0),
};
let prefix = if is_focused { &self.config.selection_indicator } else { &self.config.no_indicator };
if is_focused {
let chars: Vec<char> = text.chars().collect();
let cursor_pos = cursor_col.min(chars.len());
let before: String = chars[..cursor_pos].iter().collect();
let cursor_char = chars.get(cursor_pos).copied().unwrap_or(' ');
let after: String = chars.get(cursor_pos + 1..).map(|s| s.iter().collect()).unwrap_or_default();
lines.push(Line::from(vec![
Span::styled(prefix.clone(), theme.focus_indicator()),
Span::styled("Type Something: ", theme.focused_text()),
Span::styled(before, theme.focused_text()),
Span::styled(cursor_char.to_string(), theme.cursor()),
Span::styled(after, theme.focused_text()),
]));
} else {
let display = if text.is_empty() {
"Type Something:".to_string()
} else {
format!("Type Something: {}", text)
};
let truncated = truncate_text(&display, inner_width - 4);
lines.push(Line::from(Span::styled(
format!("{}{}", prefix, truncated),
theme.muted_text(),
)));
}
}
}
impl Default for QuestionPanel {
fn default() -> Self {
Self::new()
}
}
use std::any::Any;
use super::{widget_ids, Widget, WidgetAction, WidgetKeyContext, WidgetKeyResult};
impl Widget for QuestionPanel {
fn id(&self) -> &'static str {
widget_ids::QUESTION_PANEL
}
fn priority(&self) -> u8 {
200 }
fn is_active(&self) -> bool {
self.active
}
fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
if !self.active {
return WidgetKeyResult::NotHandled;
}
let is_text_mode = self.is_text_focused();
if !is_text_mode {
if ctx.nav.is_move_up(&key) {
self.focus_prev();
return WidgetKeyResult::Handled;
}
if ctx.nav.is_move_down(&key) {
self.focus_next();
return WidgetKeyResult::Handled;
}
}
if key.code == KeyCode::Char('p') && key.modifiers.contains(KeyModifiers::CONTROL) {
self.focus_prev();
return WidgetKeyResult::Handled;
}
if key.code == KeyCode::Char('n') && key.modifiers.contains(KeyModifiers::CONTROL) {
self.focus_next();
return WidgetKeyResult::Handled;
}
if ctx.nav.is_cancel(&key) {
let tool_use_id = self.tool_use_id.clone();
return WidgetKeyResult::Action(WidgetAction::CancelQuestion { tool_use_id });
}
match self.process_key(key) {
KeyAction::Submitted(tool_use_id, response) => {
WidgetKeyResult::Action(WidgetAction::SubmitQuestion {
tool_use_id,
response,
})
}
KeyAction::Cancelled(tool_use_id) => {
WidgetKeyResult::Action(WidgetAction::CancelQuestion { tool_use_id })
}
KeyAction::Handled | KeyAction::NotHandled => WidgetKeyResult::Handled,
}
}
fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
self.render_panel(frame, area, theme);
}
fn required_height(&self, max_height: u16) -> u16 {
if self.active {
self.panel_height(max_height)
} else {
0
}
}
fn blocks_input(&self) -> bool {
self.active
}
fn is_overlay(&self) -> bool {
false
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn into_any(self: Box<Self>) -> Box<dyn Any> {
self
}
}
fn truncate_text(text: &str, max_width: usize) -> String {
if text.chars().count() <= max_width {
text.to_string()
} else {
let truncated: String = text.chars().take(max_width.saturating_sub(3)).collect();
format!("{}...", truncated)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_request() -> AskUserQuestionsRequest {
AskUserQuestionsRequest {
questions: vec![
Question::SingleChoice {
text: "Choose one".to_string(),
choices: vec!["Option A".to_string(), "Option B".to_string()],
required: true,
},
],
}
}
#[test]
fn test_panel_activation() {
let mut panel = QuestionPanel::new();
assert!(!panel.is_active());
let request = create_test_request();
panel.activate("tool_123".to_string(), 1, request, None);
assert!(panel.is_active());
assert_eq!(panel.tool_use_id(), "tool_123");
assert_eq!(panel.session_id(), 1);
panel.deactivate();
assert!(!panel.is_active());
}
#[test]
fn test_navigation() {
let mut panel = QuestionPanel::new();
let request = create_test_request();
panel.activate("tool_1".to_string(), 1, request, None);
assert_eq!(
panel.current_focus(),
Some(&FocusItem::Choice {
question_idx: 0,
choice_idx: 0
})
);
panel.focus_next();
assert_eq!(
panel.current_focus(),
Some(&FocusItem::Choice {
question_idx: 0,
choice_idx: 1
})
);
panel.focus_submit();
assert_eq!(panel.current_focus(), Some(&FocusItem::Submit));
}
#[test]
fn test_handle_key_cancel() {
let mut panel = QuestionPanel::new();
let request = create_test_request();
panel.activate("tool_1".to_string(), 1, request, None);
let action = panel.process_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
match action {
KeyAction::Cancelled(tool_use_id) => {
assert_eq!(tool_use_id, "tool_1");
}
_ => panic!("Expected Cancelled action"),
}
panel.deactivate();
assert!(!panel.is_active());
}
#[test]
fn test_answer_state_single_choice() {
let mut state = AnswerState::SingleChoice {
selected: None,
other_text: TextArea::default(),
};
assert!(!state.is_selected("Option A"));
state.select_choice("Option A");
assert!(state.is_selected("Option A"));
state.select_choice("Option B");
assert!(!state.is_selected("Option A"));
assert!(state.is_selected("Option B"));
}
#[test]
fn test_answer_state_multi_choice() {
let mut state = AnswerState::MultiChoice {
selected: HashSet::new(),
other_text: TextArea::default(),
};
state.select_choice("Option A");
state.select_choice("Option B");
assert!(state.is_selected("Option A"));
assert!(state.is_selected("Option B"));
state.select_choice("Option A");
assert!(!state.is_selected("Option A"));
assert!(state.is_selected("Option B"));
}
}