use color_eyre::Result;
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
use super::Component;
use crate::action::{Action, Pane, QuestionDecision};
const MAX_OPTIONS: usize = 4;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct QuestionOptionView {
pub label: String,
pub description: String,
pub recommended: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct QuestionView {
pub conversation_id: String,
pub turn_id: String,
pub call_id: String,
pub index: u32,
pub header: String,
pub question: String,
pub options: Vec<QuestionOptionView>,
pub args_json: String,
pub answer_token: String,
pub response: Option<QuestionOutcomeView>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct QuestionOutcomeView {
pub state: String,
pub selected_label: String,
}
#[derive(Default)]
pub(crate) struct Questions {
pub conversation_id: Option<String>,
pub items: Vec<QuestionView>,
pub selected: Option<usize>,
focused: bool,
last_error: Option<String>,
}
impl Questions {
fn upsert(&mut self, view: QuestionView) -> usize {
if let Some(idx) = self.index_of(
&view.conversation_id,
&view.turn_id,
&view.call_id,
view.index,
) {
self.items[idx] = view;
idx
} else {
self.items.insert(0, view);
if let Some(sel) = self.selected.as_mut() {
*sel += 1;
}
0
}
}
fn index_of(
&self,
conversation_id: &str,
turn_id: &str,
call_id: &str,
index: u32,
) -> Option<usize> {
self.items.iter().position(|v| {
v.conversation_id == conversation_id
&& v.turn_id == turn_id
&& v.call_id == call_id
&& v.index == index
})
}
#[must_use]
pub(crate) fn pending_count(&self) -> usize {
self.items.iter().filter(|v| v.response.is_none()).count()
}
fn move_selection(&mut self, delta: isize) {
self.selected = super::step_selection(self.selected, self.items.len(), delta);
}
fn decision_for_selected(&self, selected_index: Option<u32>) -> Option<QuestionDecision> {
let item = self.items.get(self.selected?)?;
if item.response.is_some() {
return None;
}
let selected_label = match selected_index {
Some(i) => item.options.get(i as usize)?.label.clone(),
None => String::new(),
};
Some(QuestionDecision {
turn_id: item.turn_id.clone(),
call_id: item.call_id.clone(),
index: item.index,
conversation_id: item.conversation_id.clone(),
selected_index,
selected_label,
answer_token: item.answer_token.clone(),
})
}
fn on_key(&mut self, key: KeyEvent) -> Option<Action> {
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
self.move_selection(1);
None
}
KeyCode::Char('k') | KeyCode::Up => {
self.move_selection(-1);
None
}
KeyCode::Char(c @ '1'..='9') => {
let n = c.to_digit(10).unwrap_or(0);
if n == 0 || n as usize > MAX_OPTIONS {
return None;
}
self.emit_decision(Some(n - 1))
}
KeyCode::Char('d') => self.emit_decision(None),
_ => None,
}
}
fn emit_decision(&mut self, selected_index: Option<u32>) -> Option<Action> {
if let Some(decision) = self.decision_for_selected(selected_index) {
self.last_error = None;
Some(Action::QuestionDecide(decision))
} else {
self.last_error = Some(
"no pending question selected (already answered, or no such option)".to_owned(),
);
None
}
}
}
impl Component for Questions {
fn controls(&self) -> Vec<(&'static str, &'static str)> {
vec![("↑↓/jk", "move"), ("1-4", "pick option"), ("d", "decline")]
}
fn handle(&mut self, action: &Action) -> Option<Action> {
match action {
Action::Nav(pane) => {
self.focused = *pane == Pane::Questions;
None
}
Action::Select(conversation_id) => {
self.conversation_id = Some(conversation_id.clone());
None
}
Action::QuestionPending(view) => {
let idx = self.upsert(view.clone());
if self.selected.is_none() {
self.selected = Some(idx);
}
None
}
Action::QuestionSubmitted {
call_id,
index,
persisted,
outcome,
} => {
if let Some(idx) = self.items.iter().position(|v| {
&v.call_id == call_id && v.index == *index && v.response.is_none()
}) {
if *persisted {
if let Some(outcome) = outcome {
self.items[idx].response = Some(outcome.clone());
}
self.last_error = None;
} else {
self.last_error = Some(format!(
"control plane did not persist answer for {call_id}#{index} (already answered or unknown)"
));
}
}
None
}
Action::Error(msg) => {
self.last_error = Some(msg.clone());
None
}
Action::Key(key) if self.focused => self.on_key(*key),
_ => None,
}
}
#[allow(clippy::too_many_lines)] fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
let pending = self.pending_count();
let title = self.conversation_id.as_ref().map_or_else(
|| format!(" questions — {pending} pending "),
|id| format!(" questions — {pending} pending (sel conv {id}) "),
);
let outer = Block::default().title(title).borders(Borders::ALL);
let inner = outer.inner(area);
frame.render_widget(outer, area);
if self.items.is_empty() {
let empty = Paragraph::new("no questions — the inbox is clear")
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(empty, inner);
return Ok(());
}
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(inner);
let list_items: Vec<ListItem> = self
.items
.iter()
.map(|v| {
let (marker, marker_style) = match &v.response {
None => ("● ", Style::default().fg(Color::Yellow)),
Some(_) => ("✓ ", Style::default().fg(Color::Green)),
};
ListItem::new(Line::from(vec![
Span::styled(marker, marker_style),
Span::styled(
v.header.clone(),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
short(&v.conversation_id),
Style::default().fg(Color::DarkGray),
),
]))
})
.collect();
let list = List::new(list_items)
.block(Block::default().borders(Borders::RIGHT))
.highlight_style(
Style::default()
.bg(Color::Blue)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ ");
let mut state = ListState::default();
state.select(self.selected);
frame.render_stateful_widget(list, cols[0], &mut state);
let detail = self.selected.and_then(|s| self.items.get(s));
let detail_text = detail.map_or_else(
|| Line::from("select a question").into(),
|v| {
let mut lines: Vec<Line> = vec![
Line::from(vec![Span::styled(
polyc_proto::question_prompt_text(&v.header, &v.question),
Style::default().add_modifier(Modifier::BOLD),
)]),
Line::from(vec![
Span::styled("call ", Style::default().fg(Color::DarkGray)),
Span::raw(format!("{}#{}", v.call_id, v.index)),
]),
Line::from(vec![
Span::styled("conv ", Style::default().fg(Color::DarkGray)),
Span::raw(v.conversation_id.clone()),
]),
Line::from(""),
];
for (i, opt) in v.options.iter().enumerate() {
lines.push(Line::from(vec![
Span::styled(format!("[{}] ", i + 1), Style::default().fg(Color::Cyan)),
Span::raw(polyc_proto::question_option_line(
&opt.label,
&opt.description,
opt.recommended,
)),
]));
}
lines.push(Line::from(""));
if let Some(o) = &v.response {
let text = polyc_proto::question_answered_text(
&v.header,
&o.state,
"you",
&o.selected_label,
);
let style = if o.state == "declined" {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Green)
};
lines.push(Line::from(Span::styled(text, style)));
} else {
lines.push(Line::from(Span::styled(
"[1-4] pick an option [d] decline",
Style::default().fg(Color::Cyan),
)));
}
ratatui::text::Text::from(lines)
},
);
let detail_widget = Paragraph::new(detail_text).wrap(Wrap { trim: false });
if let Some(err) = &self.last_error {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(cols[1]);
frame.render_widget(detail_widget, rows[0]);
frame.render_widget(
Paragraph::new(err.clone()).style(Style::default().fg(Color::Red)),
rows[1],
);
} else {
frame.render_widget(detail_widget, cols[1]);
}
Ok(())
}
}
fn short(s: &str) -> String {
let count = s.chars().count();
if count <= 12 {
s.to_owned()
} else {
let head: String = s.chars().take(6).collect();
let tail: String = s.chars().skip(count - 4).collect();
format!("{head}…{tail}")
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn pending(conv: &str, call_id: &str) -> QuestionView {
QuestionView {
turn_id: "00000000-0000-0000-0000-000000000001".to_owned(),
conversation_id: conv.to_owned(),
call_id: call_id.to_owned(),
index: 0,
header: "Deploy target".to_owned(),
question: "Which environment should this ship to?".to_owned(),
options: vec![
QuestionOptionView {
label: "Staging".to_owned(),
description: "Deploys to staging only.".to_owned(),
recommended: false,
},
QuestionOptionView {
label: "Production".to_owned(),
description: "Deploys straight to production.".to_owned(),
recommended: true,
},
],
args_json: r#"{"questions":[]}"#.to_owned(),
answer_token: "tok".to_owned(),
response: None,
}
}
fn key(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
#[test]
fn pending_action_inserts_and_selects() {
let mut pane = Questions::default();
assert!(
pane.handle(&Action::QuestionPending(pending("c1", "call-1")))
.is_none()
);
assert_eq!(pane.items.len(), 1);
assert_eq!(pane.selected, Some(0));
assert_eq!(pane.pending_count(), 1);
}
#[test]
fn pending_is_deduped_on_identity() {
let mut pane = Questions::default();
pane.handle(&Action::QuestionPending(pending("c1", "call-1")));
pane.handle(&Action::QuestionPending(pending("c1", "call-1")));
assert_eq!(pane.items.len(), 1);
}
#[test]
fn newest_first_ordering_shifts_selection() {
let mut pane = Questions::default();
pane.handle(&Action::QuestionPending(pending("c1", "call-1")));
pane.handle(&Action::QuestionPending(pending("c1", "call-2")));
assert_eq!(pane.items[0].call_id, "call-2");
assert_eq!(pane.selected, Some(1));
}
#[test]
fn number_key_emits_decision_only_when_focused() {
let mut pane = Questions::default();
pane.handle(&Action::QuestionPending(pending("c1", "call-1")));
assert!(pane.handle(&Action::Key(key('1'))).is_none());
pane.handle(&Action::Nav(Pane::Questions));
let out = pane.handle(&Action::Key(key('1')));
match out {
Some(Action::QuestionDecide(d)) => {
assert_eq!(d.selected_index, Some(0));
assert_eq!(d.call_id, "call-1");
assert_eq!(d.conversation_id, "c1");
}
other => panic!("expected QuestionDecide, got {other:?}"),
}
}
#[test]
fn decline_key_emits_none_selection() {
let mut pane = Questions::default();
pane.handle(&Action::QuestionPending(pending("c1", "call-1")));
pane.handle(&Action::Nav(Pane::Questions));
let out = pane.handle(&Action::Key(key('d')));
assert!(matches!(out, Some(Action::QuestionDecide(d)) if d.selected_index.is_none()));
}
#[test]
fn out_of_range_option_number_is_rejected() {
let mut pane = Questions::default();
pane.handle(&Action::QuestionPending(pending("c1", "call-1")));
pane.handle(&Action::Nav(Pane::Questions));
assert!(pane.handle(&Action::Key(key('3'))).is_none());
assert!(pane.last_error.is_some());
}
#[test]
fn cannot_decide_already_decided_item() {
let mut pane = Questions::default();
let mut decided = pending("c1", "call-1");
decided.response = Some(QuestionOutcomeView {
state: "answered".to_owned(),
selected_label: "Production".to_owned(),
});
pane.handle(&Action::QuestionPending(decided));
pane.handle(&Action::Nav(Pane::Questions));
assert!(pane.handle(&Action::Key(key('1'))).is_none());
assert!(pane.last_error.is_some());
assert_eq!(pane.pending_count(), 0);
}
#[test]
fn submitted_not_persisted_sets_error() {
let mut pane = Questions::default();
pane.handle(&Action::QuestionPending(pending("c1", "call-1")));
pane.handle(&Action::QuestionSubmitted {
call_id: "call-1".to_owned(),
index: 0,
persisted: false,
outcome: None,
});
assert!(pane.last_error.is_some());
}
#[test]
fn submitted_persisted_folds_outcome_and_leaves_pending_set() {
let mut pane = Questions::default();
pane.handle(&Action::QuestionPending(pending("c1", "call-1")));
pane.handle(&Action::QuestionSubmitted {
call_id: "call-1".to_owned(),
index: 0,
persisted: true,
outcome: Some(QuestionOutcomeView {
state: "answered".to_owned(),
selected_label: "Production".to_owned(),
}),
});
assert_eq!(pane.pending_count(), 0);
assert_eq!(
pane.items[0]
.response
.as_ref()
.map(|o| o.selected_label.as_str()),
Some("Production")
);
}
}