use super::{centered_popup, OverlayAction, OverlayComponent};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
use oxi_agent::tools::questionnaire::{Answer, Question, QuestionnaireResponse};
use oxi_tui::theme::{Theme, ThemeStyles};
use ratatui::{
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
Frame,
};
use std::collections::{HashMap, HashSet};
pub struct QuestionnaireOverlay {
questions: Vec<Question>,
current_tab: usize,
option_cursor: usize,
selected: HashMap<usize, HashSet<usize>>,
answers: HashMap<String, Answer>,
input_mode: bool,
input_text: String,
input_question_id: String,
responder: Option<tokio::sync::oneshot::Sender<QuestionnaireResponse>>,
}
impl QuestionnaireOverlay {
pub fn new(
questions: Vec<Question>,
responder: tokio::sync::oneshot::Sender<QuestionnaireResponse>,
) -> Self {
if questions.is_empty() {
let _ = responder.send(QuestionnaireResponse {
answers: vec![],
cancelled: false,
});
return Self {
questions,
current_tab: 0,
option_cursor: 0,
selected: HashMap::new(),
answers: HashMap::new(),
input_mode: false,
input_text: String::new(),
input_question_id: String::new(),
responder: None,
};
}
Self {
questions,
current_tab: 0,
option_cursor: 0,
selected: HashMap::new(),
answers: HashMap::new(),
input_mode: false,
input_text: String::new(),
input_question_id: String::new(),
responder: Some(responder),
}
}
fn is_multi(&self) -> bool {
self.questions.len() > 1
}
fn submit_tab(&self) -> usize {
self.questions.len()
}
fn total_tabs(&self) -> usize {
self.questions.len() + 1
}
fn current_question(&self) -> Option<&Question> {
self.questions.get(self.current_tab)
}
fn all_answered(&self) -> bool {
self.questions
.iter()
.all(|q| self.answers.contains_key(&q.id))
}
fn toggle_multi(&mut self, tab_idx: usize, opt_idx: usize) {
let entry = self.selected.entry(tab_idx).or_default();
if entry.contains(&opt_idx) {
entry.remove(&opt_idx);
} else {
entry.insert(opt_idx);
}
}
fn save_answer(
&mut self,
question_id: String,
value: String,
label: String,
was_custom: bool,
index: Option<usize>,
) {
self.answers.insert(
question_id.clone(),
Answer {
id: question_id,
value,
label,
was_custom,
index,
},
);
}
fn advance(&mut self) {
if !self.is_multi() {
self.do_submit(false);
return;
}
for i in (self.current_tab + 1)..self.total_tabs() {
if i >= self.submit_tab() {
self.current_tab = self.submit_tab();
self.option_cursor = 0;
return;
}
if !self.answers.contains_key(&self.questions[i].id) {
self.current_tab = i;
self.option_cursor = 0;
return;
}
}
self.current_tab = self.submit_tab();
self.option_cursor = 0;
}
fn do_submit(&mut self, cancelled: bool) {
let answers: Vec<Answer> = std::mem::take(&mut self.answers).into_values().collect();
if let Some(responder) = self.responder.take() {
let _ = responder.send(QuestionnaireResponse { answers, cancelled });
}
}
}
impl std::fmt::Debug for QuestionnaireOverlay {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("QuestionnaireOverlay")
.field("questions", &self.questions.len())
.field("current_tab", &self.current_tab)
.field("input_mode", &self.input_mode)
.finish()
}
}
impl OverlayComponent for QuestionnaireOverlay {
fn handle_key(&mut self, key: KeyEvent) -> OverlayAction {
if key.kind != KeyEventKind::Press {
return OverlayAction::None;
}
if self.input_mode {
match key.code {
KeyCode::Enter => {
let text = if self.input_text.trim().is_empty() {
"(no response)".to_string()
} else {
self.input_text.trim().to_string()
};
let q_id = std::mem::take(&mut self.input_question_id);
self.save_answer(q_id, text.clone(), text, true, None);
self.input_mode = false;
self.input_text.clear();
self.advance();
}
KeyCode::Esc => {
self.input_mode = false;
self.input_text.clear();
let _ = std::mem::take(&mut self.input_question_id);
}
KeyCode::Backspace => {
self.input_text.pop();
}
KeyCode::Char(c) => {
self.input_text.push(c);
}
_ => {}
}
return OverlayAction::None;
}
if self.is_multi() {
match key.code {
KeyCode::Tab | KeyCode::Right => {
self.current_tab = (self.current_tab + 1) % self.total_tabs();
self.option_cursor = 0;
return OverlayAction::None;
}
KeyCode::BackTab | KeyCode::Left => {
self.current_tab =
(self.current_tab + self.total_tabs() - 1) % self.total_tabs();
self.option_cursor = 0;
return OverlayAction::None;
}
_ => {}
}
}
if self.current_tab == self.submit_tab() {
match key.code {
KeyCode::Enter if self.all_answered() => {
self.do_submit(false);
return OverlayAction::Close;
}
KeyCode::Esc => {
self.do_submit(true);
return OverlayAction::Close;
}
_ => return OverlayAction::None,
}
}
let q = match self.current_question() {
Some(q) => q,
None => return OverlayAction::None,
};
let opt_count = q.options.len() + if q.allow_other { 1 } else { 0 };
let q_id = q.id.clone();
match key.code {
KeyCode::Up => {
self.option_cursor = self.option_cursor.saturating_sub(1);
}
KeyCode::Down if opt_count > 0 => {
self.option_cursor = (self.option_cursor + 1).min(opt_count - 1);
}
KeyCode::Enter => {
let sel = self.option_cursor;
if sel < q.options.len() {
let opt = &q.options[sel];
if q.multi_select {
self.toggle_multi(self.current_tab, sel);
} else {
self.save_answer(
q_id,
opt.value.clone(),
opt.label.clone(),
false,
Some(sel + 1),
);
self.advance();
}
} else if q.allow_other {
self.input_mode = true;
self.input_question_id = q_id;
self.input_text.clear();
}
}
KeyCode::Char(' ') if q.multi_select => {
let sel = self.option_cursor;
if sel < q.options.len() {
self.toggle_multi(self.current_tab, sel);
}
}
KeyCode::Esc => {
self.do_submit(true);
return OverlayAction::Close;
}
_ => {}
}
OverlayAction::None
}
fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
let popup = centered_popup(area, 0.85, 0.80);
frame.render_widget(Clear, popup);
let title_style = Style::default()
.fg(theme.colors.primary.to_ratatui())
.add_modifier(Modifier::BOLD);
let border_block = Block::default()
.title(Line::styled(" Questionnaire ", title_style))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.colors.border.to_ratatui()));
let inner = border_block.inner(popup);
frame.render_widget(border_block, popup);
self.render_content(frame, inner, theme);
}
fn hint(&self) -> &str {
if self.input_mode {
" Enter submit | Esc cancel"
} else if self.is_multi() {
" Tab/←→ tabs | ↑↓ navigate | Enter select | Esc cancel"
} else {
" ↑↓ navigate | Enter select | Esc cancel"
}
}
}
impl QuestionnaireOverlay {
fn render_content(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
let styles = theme.to_styles();
let lines = self.build_lines(area.width, theme, &styles);
let list_items: Vec<ListItem> = lines.into_iter().map(ListItem::new).collect();
frame.render_widget(List::new(list_items), area);
let hint_y = area.y + area.height.saturating_sub(1);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(self.hint(), styles.muted))),
Rect {
x: area.x,
y: hint_y,
width: area.width,
height: 1,
},
);
}
fn build_lines(&self, width: u16, theme: &Theme, styles: &ThemeStyles) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let sep_line = "─".repeat(width as usize);
let fg = theme.colors.accent.to_ratatui();
if self.input_mode {
self.build_input_mode_lines(&mut lines, width, theme, styles, &sep_line);
} else if self.current_tab == self.submit_tab() {
self.build_submit_lines(&mut lines, theme, styles, &sep_line);
} else {
self.build_question_lines(&mut lines, theme, styles, &sep_line);
}
lines.push(Line::from(Span::styled(
sep_line.clone(),
Style::default().fg(fg),
)));
lines
}
fn build_question_lines(
&self,
lines: &mut Vec<Line<'static>>,
theme: &Theme,
styles: &ThemeStyles,
sep_line: &str,
) {
let q = match self.current_question() {
Some(q) => q,
None => return,
};
let fg = theme.colors.accent.to_ratatui();
lines.push(Line::from(Span::styled(
sep_line.to_string(),
Style::default().fg(fg),
)));
if self.is_multi() {
self.build_tab_bar(lines, theme, styles);
lines.push(Line::from(Span::raw("")));
}
lines.push(Line::from(vec![
Span::styled(" ? ", styles.accent.add_modifier(Modifier::BOLD)),
Span::styled(q.prompt.clone(), styles.normal),
]));
lines.push(Line::from(Span::styled(
sep_line.to_string(),
Style::default().fg(fg),
)));
let multi_selected = self.selected.get(&self.current_tab);
let opt_count = q.options.len() + if q.allow_other { 1 } else { 0 };
for i in 0..opt_count {
let is_sel = i == self.option_cursor;
let pointer = if is_sel { "→ " } else { " " };
let pointer_style = if is_sel { styles.accent } else { styles.normal };
if i < q.options.len() {
let opt = &q.options[i];
let check = if q.multi_select {
let is_checked = multi_selected.map(|s| s.contains(&i)).unwrap_or(false);
Some(if is_checked { "☑" } else { "☐" })
} else {
None
};
let check_str = check.map(|c| format!("{} ", c)).unwrap_or_default();
lines.push(Line::from(vec![
Span::styled(pointer.to_string(), pointer_style),
Span::styled(
format!("{}. {}{}", i + 1, check_str, opt.label),
if is_sel { styles.accent } else { styles.normal },
),
]));
if let Some(ref desc) = opt.description {
lines.push(Line::from(vec![
Span::styled(" ".to_string(), styles.muted),
Span::styled(desc.clone(), styles.muted),
]));
}
} else {
lines.push(Line::from(vec![
Span::styled(pointer.to_string(), pointer_style),
Span::styled(format!("{}. ", i + 1), styles.accent),
Span::styled(
"Type something...".to_string(),
styles.accent.add_modifier(Modifier::DIM),
),
Span::styled(" ✎".to_string(), styles.accent),
]));
}
}
}
fn build_submit_lines(
&self,
lines: &mut Vec<Line<'static>>,
theme: &Theme,
styles: &ThemeStyles,
sep_line: &str,
) {
let fg = theme.colors.accent.to_ratatui();
lines.push(Line::from(Span::styled(
sep_line.to_string(),
Style::default().fg(fg),
)));
if self.is_multi() {
self.build_tab_bar(lines, theme, styles);
lines.push(Line::from(Span::raw("")));
}
if self.all_answered() {
lines.push(Line::from(vec![
Span::styled(
" ✓ ".to_string(),
styles.success.add_modifier(Modifier::BOLD),
),
Span::styled(
"Ready to submit".to_string(),
styles.success.add_modifier(Modifier::BOLD),
),
]));
} else {
let missing: Vec<String> = self
.questions
.iter()
.filter(|q| !self.answers.contains_key(&q.id))
.map(|q| q.label.clone())
.collect();
lines.push(Line::from(vec![
Span::styled(
" ⚠ ".to_string(),
styles.warning.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("Unanswered: {}", missing.join(", ")),
styles.warning,
),
]));
}
lines.push(Line::from(Span::raw("")));
for q in &self.questions {
if let Some(ans) = self.answers.get(&q.id) {
let label = if ans.was_custom {
format!("(wrote) {}", ans.label)
} else if let Some(idx) = ans.index {
format!("{}. {}", idx, ans.label)
} else {
ans.label.clone()
};
lines.push(Line::from(vec![
Span::styled(format!(" {:>12} ", q.label), styles.muted),
Span::styled(label, styles.normal),
]));
}
}
if self.all_answered() {
lines.push(Line::from(Span::styled(
" Press Enter to submit".to_string(),
styles.success,
)));
}
}
fn build_input_mode_lines(
&self,
lines: &mut Vec<Line<'static>>,
width: u16,
theme: &Theme,
styles: &ThemeStyles,
sep_line: &str,
) {
let q = match self.current_question() {
Some(q) => q,
None => return,
};
let fg = theme.colors.accent.to_ratatui();
lines.push(Line::from(Span::styled(
sep_line.to_string(),
Style::default().fg(fg),
)));
lines.push(Line::from(vec![
Span::styled(
" ? ".to_string(),
styles.accent.add_modifier(Modifier::BOLD),
),
Span::styled(q.prompt.clone(), styles.normal),
]));
lines.push(Line::from(Span::styled(
sep_line.to_string(),
Style::default().fg(fg),
)));
for (i, opt) in q.options.iter().enumerate() {
let is_sel = i == self.option_cursor;
lines.push(Line::from(vec![
Span::styled(if is_sel { "→ " } else { " " }, styles.accent),
Span::styled(
format!("{}. {}", i + 1, opt.label),
if is_sel { styles.accent } else { styles.normal },
),
]));
}
lines.push(Line::from(Span::styled(
sep_line.to_string(),
Style::default().fg(fg),
)));
lines.push(Line::from(Span::styled(
" Your answer:".to_string(),
styles.muted,
)));
let max_input_w = (width as usize).saturating_sub(4);
let display = self
.input_text
.chars()
.take(max_input_w)
.collect::<String>();
let cursor_style = Style::default()
.fg(theme.colors.foreground.to_ratatui())
.bg(theme.colors.primary.to_ratatui());
if display.is_empty() {
lines.push(Line::from(vec![
Span::raw(" ".to_string()),
Span::styled("_".to_string(), cursor_style),
]));
} else {
lines.push(Line::from(vec![
Span::raw(" ".to_string()),
Span::styled(display, styles.normal),
Span::styled("_".to_string(), cursor_style),
]));
}
lines.push(Line::from(Span::styled(
" Enter to submit | Esc cancel".to_string(),
styles.muted,
)));
}
fn build_tab_bar(&self, lines: &mut Vec<Line<'static>>, _theme: &Theme, styles: &ThemeStyles) {
let left_style = if self.current_tab == 0 {
styles.muted
} else {
styles.normal
};
lines.push(Line::from(vec![Span::styled("← ".to_string(), left_style)]));
for (i, q) in self.questions.iter().enumerate() {
let is_active = i == self.current_tab;
let is_done = self.answers.contains_key(&q.id);
let box_char = if is_done { "■" } else { "□" };
let text_style = if is_active {
styles.normal
} else if is_done {
styles.success
} else {
styles.muted
};
lines.push(Line::from(vec![Span::styled(
format!("{} {} ", box_char, q.label),
text_style,
)]));
}
let can_submit = self.all_answered();
let (submit_char, submit_style) = if can_submit {
("✓", styles.success)
} else {
("·", styles.muted)
};
let is_submit_active = self.current_tab == self.submit_tab();
lines.push(Line::from(vec![Span::styled(
format!("{} Submit ", submit_char),
submit_style,
)]));
let right_style = if is_submit_active {
styles.muted
} else {
styles.normal
};
lines.push(Line::from(vec![Span::styled(
" →".to_string(),
right_style,
)]));
}
}