use tokio::sync::oneshot;
mod render;
pub(in crate::tui) use render::{questionnaire_cursor_position, questionnaire_lines};
#[cfg(test)]
use render::{questionnaire_frame, questionnaire_question_cursor};
use crate::agent::{
QuestionnaireAnswer, QuestionnaireQuestion, QuestionnaireQuestionKind, QuestionnaireRequest,
QuestionnaireResponse,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum QuestionnaireCancelReason {
UserCancelled,
UiUnavailable,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum QuestionnaireReply {
Answer(QuestionnaireResponse),
Cancelled(QuestionnaireCancelReason),
}
pub(super) struct QuestionnaireResponseChannel {
reply_tx: Option<oneshot::Sender<QuestionnaireReply>>,
}
impl std::fmt::Debug for QuestionnaireResponseChannel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("QuestionnaireResponseChannel")
.field("reply_pending", &self.reply_tx.is_some())
.finish()
}
}
impl QuestionnaireResponseChannel {
pub(super) fn new(reply_tx: oneshot::Sender<QuestionnaireReply>) -> Self {
Self {
reply_tx: Some(reply_tx),
}
}
fn send_response(&mut self, response: QuestionnaireResponse) {
if let Some(reply_tx) = self.reply_tx.take() {
let _ = reply_tx.send(QuestionnaireReply::Answer(response));
}
}
fn cancel(&mut self, reason: QuestionnaireCancelReason) {
if let Some(reply_tx) = self.reply_tx.take() {
let _ = reply_tx.send(QuestionnaireReply::Cancelled(reason));
}
}
}
impl Drop for QuestionnaireResponseChannel {
fn drop(&mut self) {
self.cancel(QuestionnaireCancelReason::UiUnavailable);
}
}
#[derive(Debug)]
pub(super) struct QuestionAnswerRequest {
pub(super) request: QuestionnaireRequest,
pub(super) response: QuestionnaireResponseChannel,
}
#[derive(Debug)]
pub(super) struct QuestionnaireComposer {
request: QuestionnaireRequest,
response: QuestionnaireResponseChannel,
fields: Vec<QuestionnaireFieldState>,
active_index: usize,
}
#[derive(Debug)]
struct QuestionnaireFieldState {
selection: FieldSelection,
choice_cursor: usize,
other_value: String,
other_cursor: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum FieldSelection {
Text,
None,
Single(usize),
Multi { selected: Vec<usize>, other: bool },
Other,
}
impl QuestionnaireComposer {
pub(super) fn new(
request: QuestionnaireRequest,
response: QuestionnaireResponseChannel,
) -> Self {
let fields = request
.questions
.iter()
.map(QuestionnaireFieldState::new)
.collect::<Vec<_>>();
Self {
request,
response,
fields,
active_index: 0,
}
}
fn active_question(&self) -> &QuestionnaireQuestion {
&self.request.questions[self.active_index]
}
fn active_field(&self) -> &QuestionnaireFieldState {
&self.fields[self.active_index]
}
fn active_field_mut(&mut self) -> &mut QuestionnaireFieldState {
&mut self.fields[self.active_index]
}
fn active_char_len(&self) -> usize {
self.active_field().char_len()
}
pub(super) fn move_to_previous_field(&mut self) {
self.active_index = self.active_index.saturating_sub(1);
}
pub(super) fn move_to_next_field(&mut self) {
self.active_index = (self.active_index + 1).min(self.fields.len().saturating_sub(1));
}
pub(super) fn move_active_choice_previous(&mut self) {
let question = self.active_question().clone();
self.active_field_mut().move_choice_previous(&question);
}
pub(super) fn move_active_choice_next(&mut self) {
let question = self.active_question().clone();
self.active_field_mut().move_choice_next(&question);
}
pub(super) fn toggle_active_choice(&mut self) {
let question = self.active_question().clone();
self.active_field_mut().toggle_highlighted(&question);
}
pub(super) fn clear_active_answer(&mut self) {
let question = self.active_question().clone();
*self.active_field_mut() = QuestionnaireFieldState::empty(&question);
}
pub(super) fn active_text_entry_active(&self) -> bool {
self.active_field()
.text_entry_active(self.active_question())
}
pub(super) fn accepts_paste_burst_char(&self, ch: char) -> bool {
self.active_text_entry_active() || (ch != ' ' && self.active_question().allow_other)
}
pub(super) fn accepts_pending_paste_burst_enter(&self) -> bool {
self.active_text_entry_active() || self.active_question().allow_other
}
pub(super) fn insert_char(&mut self, ch: char) -> bool {
if !self.active_text_entry_active() && !self.activate_other_for_typing() {
return false;
}
self.active_field_mut().insert_char(ch);
true
}
pub(super) fn insert_text(&mut self, text: &str) -> bool {
if !self.active_text_entry_active() && !self.activate_other_for_typing() {
return false;
}
self.active_field_mut().insert_text(text);
true
}
pub(super) fn backspace(&mut self) {
if self.active_text_entry_active() {
self.active_field_mut().backspace();
}
}
pub(super) fn delete(&mut self) {
if self.active_text_entry_active() {
self.active_field_mut().delete();
}
}
pub(super) fn delete_previous_word(&mut self) {
if !self.active_text_entry_active() {
return;
}
let field = self.active_field_mut();
let new_cursor = previous_word_boundary(&field.other_value, field.other_cursor);
let start = field.byte_index(new_cursor);
let end = field.byte_index(field.other_cursor);
field.other_value.replace_range(start..end, "");
field.other_cursor = new_cursor;
}
pub(super) fn move_text_cursor_previous_word(&mut self) {
if self.active_text_entry_active() {
let field = self.active_field_mut();
field.other_cursor = previous_word_boundary(&field.other_value, field.other_cursor);
}
}
pub(super) fn move_text_cursor_next_word(&mut self) {
if self.active_text_entry_active() {
let field = self.active_field_mut();
field.other_cursor = next_word_boundary(&field.other_value, field.other_cursor);
}
}
pub(super) fn move_cursor_left(&mut self) {
if self.active_text_entry_active() {
self.active_field_mut().other_cursor =
self.active_field().other_cursor.saturating_sub(1);
} else {
self.move_active_choice_previous();
}
}
pub(super) fn move_cursor_right(&mut self) {
if self.active_text_entry_active() {
let char_len = self.active_char_len();
let field = self.active_field_mut();
field.other_cursor = (field.other_cursor + 1).min(char_len);
} else {
self.move_active_choice_next();
}
}
pub(super) fn move_cursor_home(&mut self) {
if self.active_text_entry_active() {
self.active_field_mut().other_cursor = 0;
}
}
pub(super) fn move_cursor_end(&mut self) {
if self.active_text_entry_active() {
let char_len = self.active_char_len();
self.active_field_mut().other_cursor = char_len;
}
}
pub(super) fn submit(&mut self) -> Result<SubmittedQuestionnaire, String> {
let answers = questionnaire_answers(self)?;
let response = QuestionnaireResponse { answers };
let display = submitted_questionnaire_entry(&self.request, &response);
self.response.send_response(response);
Ok(SubmittedQuestionnaire { display })
}
pub(super) fn cancel_by_user(&mut self) {
self.response
.cancel(QuestionnaireCancelReason::UserCancelled);
}
fn activate_other_for_typing(&mut self) -> bool {
let question = self.active_question().clone();
if !question.allow_other {
return false;
}
match &mut self.active_field_mut().selection {
FieldSelection::Text | FieldSelection::Other => true,
FieldSelection::None | FieldSelection::Single(_) => {
let field = self.active_field_mut();
field.selection = FieldSelection::Other;
field.choice_cursor = question.choices.len();
true
}
FieldSelection::Multi { .. } => {
let field = self.active_field_mut();
if let FieldSelection::Multi { other, .. } = &mut field.selection {
*other = true;
}
field.choice_cursor = question.choices.len();
true
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct SubmittedQuestionnaire {
pub(super) display: String,
}
impl QuestionnaireFieldState {
fn empty(question: &QuestionnaireQuestion) -> Self {
let selection = match question.kind {
QuestionnaireQuestionKind::Text => FieldSelection::Text,
QuestionnaireQuestionKind::Choice => FieldSelection::None,
QuestionnaireQuestionKind::MultiSelect => FieldSelection::Multi {
selected: Vec::new(),
other: false,
},
QuestionnaireQuestionKind::Confirm => FieldSelection::None,
};
Self {
selection,
choice_cursor: 0,
other_value: String::new(),
other_cursor: 0,
}
}
fn new(question: &QuestionnaireQuestion) -> Self {
let (selection, choice_cursor, other_value) = match question.kind {
QuestionnaireQuestionKind::Text => (
FieldSelection::Text,
0,
question
.default
.as_ref()
.map(questionnaire_default_string)
.unwrap_or_default(),
),
QuestionnaireQuestionKind::Choice => default_choice_selection(question),
QuestionnaireQuestionKind::MultiSelect => default_multi_selection(question),
QuestionnaireQuestionKind::Confirm => default_confirm_selection(question),
};
let other_cursor = other_value.chars().count();
Self {
selection,
choice_cursor,
other_value,
other_cursor,
}
}
fn char_len(&self) -> usize {
self.other_value.chars().count()
}
fn byte_index(&self, char_index: usize) -> usize {
self.other_value
.char_indices()
.nth(char_index)
.map(|(index, _)| index)
.unwrap_or(self.other_value.len())
}
pub(super) fn insert_char(&mut self, ch: char) {
let byte_index = self.byte_index(self.other_cursor);
self.other_value.insert(byte_index, ch);
self.other_cursor += 1;
}
pub(super) fn insert_text(&mut self, text: &str) {
let byte_index = self.byte_index(self.other_cursor);
self.other_value.insert_str(byte_index, text);
self.other_cursor += text.chars().count();
}
pub(super) fn backspace(&mut self) {
if self.other_cursor == 0 {
return;
}
let start = self.byte_index(self.other_cursor - 1);
let end = self.byte_index(self.other_cursor);
self.other_value.replace_range(start..end, "");
self.other_cursor -= 1;
}
pub(super) fn delete(&mut self) {
if self.other_cursor >= self.char_len() {
return;
}
let start = self.byte_index(self.other_cursor);
let end = self.byte_index(self.other_cursor + 1);
self.other_value.replace_range(start..end, "");
}
fn text_entry_active(&self, question: &QuestionnaireQuestion) -> bool {
match &self.selection {
FieldSelection::Text | FieldSelection::Other => true,
FieldSelection::None | FieldSelection::Single(_) => false,
FieldSelection::Multi { other, .. } => {
*other && self.choice_cursor == question.choices.len()
}
}
}
fn move_choice_previous(&mut self, question: &QuestionnaireQuestion) {
let count = choice_count(question);
if count == 0 || matches!(self.selection, FieldSelection::Text) {
return;
}
self.choice_cursor = self.choice_cursor.saturating_sub(1);
self.select_highlighted_for_single(question);
}
fn move_choice_next(&mut self, question: &QuestionnaireQuestion) {
let count = choice_count(question);
if count == 0 || matches!(self.selection, FieldSelection::Text) {
return;
}
self.choice_cursor = (self.choice_cursor + 1).min(count.saturating_sub(1));
self.select_highlighted_for_single(question);
}
fn toggle_highlighted(&mut self, question: &QuestionnaireQuestion) {
match &mut self.selection {
FieldSelection::Text => {}
FieldSelection::None | FieldSelection::Single(_) => {
if question.allow_other && self.choice_cursor == question.choices.len() {
self.selection = FieldSelection::Other;
} else {
self.selection = FieldSelection::Single(
self.choice_cursor
.min(choice_count(question).saturating_sub(1)),
);
}
}
FieldSelection::Multi { selected, other } => {
if question.allow_other && self.choice_cursor == question.choices.len() {
*other = !*other;
} else {
let cursor = self
.choice_cursor
.min(question.choices.len().saturating_sub(1));
if selected.contains(&cursor) {
selected.retain(|index| *index != cursor);
} else {
selected.push(cursor);
selected.sort_unstable();
selected.dedup();
}
}
}
FieldSelection::Other => {
if self.choice_cursor < question.choices.len() {
self.selection = FieldSelection::Single(self.choice_cursor);
}
}
}
}
fn select_highlighted_for_single(&mut self, question: &QuestionnaireQuestion) {
match &mut self.selection {
FieldSelection::None | FieldSelection::Single(_) => {
if question.allow_other && self.choice_cursor == question.choices.len() {
self.selection = FieldSelection::Other;
} else {
self.selection = FieldSelection::Single(
self.choice_cursor
.min(choice_count(question).saturating_sub(1)),
);
}
}
FieldSelection::Other if self.choice_cursor < question.choices.len() => {
self.selection = FieldSelection::Single(self.choice_cursor);
}
FieldSelection::Text | FieldSelection::Multi { .. } | FieldSelection::Other => {}
}
}
}
fn default_choice_selection(question: &QuestionnaireQuestion) -> (FieldSelection, usize, String) {
let Some(default) = question.default.as_ref().map(questionnaire_default_string) else {
return (FieldSelection::None, 0, String::new());
};
if let Some(index) = question
.choices
.iter()
.position(|choice| choice.eq_ignore_ascii_case(&default))
{
return (FieldSelection::Single(index), index, String::new());
}
if question.allow_other {
return (FieldSelection::Other, question.choices.len(), default);
}
(FieldSelection::None, 0, String::new())
}
fn default_multi_selection(question: &QuestionnaireQuestion) -> (FieldSelection, usize, String) {
let mut selected = Vec::new();
let mut other_values = Vec::new();
for value in question
.default
.as_ref()
.map(questionnaire_default_strings)
.unwrap_or_default()
{
if let Some(index) = question
.choices
.iter()
.position(|choice| choice.eq_ignore_ascii_case(&value))
{
selected.push(index);
} else if question.allow_other {
other_values.push(value);
}
}
selected.sort_unstable();
selected.dedup();
let other = !other_values.is_empty();
let choice_cursor = selected
.first()
.copied()
.or_else(|| other.then_some(question.choices.len()))
.unwrap_or(0);
(
FieldSelection::Multi { selected, other },
choice_cursor,
other_values.join(", "),
)
}
fn default_confirm_selection(question: &QuestionnaireQuestion) -> (FieldSelection, usize, String) {
match question
.default
.as_ref()
.map(questionnaire_default_string)
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"yes" | "y" | "true" => (FieldSelection::Single(0), 0, String::new()),
"no" | "n" | "false" => (FieldSelection::Single(1), 1, String::new()),
_ => (FieldSelection::None, 0, String::new()),
}
}
fn questionnaire_default_strings(default: &serde_json::Value) -> Vec<String> {
match default {
serde_json::Value::Array(values) => values
.iter()
.map(questionnaire_default_string)
.filter(|value| !value.is_empty())
.collect(),
value => {
let value = questionnaire_default_string(value);
if value.is_empty() {
Vec::new()
} else {
vec![value]
}
}
}
}
fn questionnaire_default_string(default: &serde_json::Value) -> String {
match default {
serde_json::Value::String(value) => value.clone(),
serde_json::Value::Bool(value) => value.to_string(),
serde_json::Value::Number(value) => value.to_string(),
serde_json::Value::Null => String::new(),
serde_json::Value::Array(_) | serde_json::Value::Object(_) => default.to_string(),
}
}
fn questionnaire_default_display(default: &serde_json::Value) -> String {
match default {
serde_json::Value::Array(values) => values
.iter()
.map(questionnaire_default_display)
.collect::<Vec<_>>()
.join(", "),
value => questionnaire_default_string(value),
}
}
fn choice_count(question: &QuestionnaireQuestion) -> usize {
match question.kind {
QuestionnaireQuestionKind::Text => 0,
QuestionnaireQuestionKind::Confirm => 2,
QuestionnaireQuestionKind::Choice | QuestionnaireQuestionKind::MultiSelect => {
question.choices.len() + usize::from(question.allow_other)
}
}
}
pub(super) fn questionnaire_answers(
questionnaire: &QuestionnaireComposer,
) -> Result<Vec<QuestionnaireAnswer>, String> {
questionnaire
.request
.questions
.iter()
.zip(questionnaire.fields.iter())
.enumerate()
.map(|(index, (question, field))| {
let answer = normalize_questionnaire_answer(question, field)
.map_err(|error| format!("question {}: {error}", index + 1))?;
Ok(QuestionnaireAnswer {
id: question.id.clone(),
answer,
})
})
.collect()
}
fn normalize_questionnaire_answer(
question: &QuestionnaireQuestion,
field: &QuestionnaireFieldState,
) -> Result<serde_json::Value, String> {
match question.kind {
QuestionnaireQuestionKind::Text => {
normalize_text_answer(question, &field.other_value).map(serde_json::Value::String)
}
QuestionnaireQuestionKind::Choice => match &field.selection {
FieldSelection::Single(index) => question
.choices
.get(*index)
.cloned()
.map(serde_json::Value::String)
.ok_or_else(|| "answer is not selected".into()),
FieldSelection::Other => {
normalize_text_answer(question, &field.other_value).map(serde_json::Value::String)
}
FieldSelection::None if !question.required => Ok(serde_json::Value::Null),
FieldSelection::Text | FieldSelection::None | FieldSelection::Multi { .. } => {
Err("answer is not selected".into())
}
},
QuestionnaireQuestionKind::MultiSelect => match &field.selection {
FieldSelection::Multi { selected, other } => {
let mut answers = selected
.iter()
.filter_map(|index| question.choices.get(*index).cloned())
.collect::<Vec<_>>();
if *other {
let other_answer = normalize_text_answer(question, &field.other_value)?;
if !other_answer.is_empty() {
answers.push(other_answer);
}
}
if answers.is_empty() && question.required {
return Err("select at least one answer".into());
}
Ok(serde_json::Value::Array(
answers.into_iter().map(serde_json::Value::String).collect(),
))
}
FieldSelection::Text
| FieldSelection::None
| FieldSelection::Single(_)
| FieldSelection::Other => Err("answer is not selected".into()),
},
QuestionnaireQuestionKind::Confirm => match field.selection {
FieldSelection::Single(0) => Ok(serde_json::json!("yes")),
FieldSelection::Single(1) => Ok(serde_json::json!("no")),
FieldSelection::None if !question.required => Ok(serde_json::Value::Null),
FieldSelection::Text
| FieldSelection::None
| FieldSelection::Single(_)
| FieldSelection::Multi { .. }
| FieldSelection::Other => Err("answer is not selected".into()),
},
}
}
fn normalize_text_answer(question: &QuestionnaireQuestion, value: &str) -> Result<String, String> {
let answer = value.trim().to_string();
if answer.is_empty() && question.required {
Err("answer cannot be empty".into())
} else {
Ok(answer)
}
}
pub(super) fn submitted_questionnaire_entry(
request: &QuestionnaireRequest,
response: &QuestionnaireResponse,
) -> String {
if response.answers.len() == 1 {
return questionnaire_answer_display(&response.answers[0].answer);
}
let mut lines = Vec::new();
if let Some(title) = &request.title {
lines.push(title.clone());
} else {
lines.push("questionnaire answers".into());
}
for answer in &response.answers {
let label = request
.questions
.iter()
.find(|question| question.id == answer.id)
.map(|question| question.question.as_str())
.unwrap_or(answer.id.as_str());
lines.push(format!(
"{label}: {}",
questionnaire_answer_display(&answer.answer)
));
}
lines.join("\n")
}
fn questionnaire_answer_display(answer: &serde_json::Value) -> String {
match answer {
serde_json::Value::Array(values) => values
.iter()
.map(questionnaire_answer_display)
.collect::<Vec<_>>()
.join(", "),
serde_json::Value::String(value) => value.clone(),
serde_json::Value::Bool(value) => value.to_string(),
serde_json::Value::Number(value) => value.to_string(),
serde_json::Value::Null => String::new(),
serde_json::Value::Object(_) => answer.to_string(),
}
}
pub(super) fn questionnaire_notice_text(request: &QuestionnaireRequest) -> String {
match (&request.title, request.questions.as_slice()) {
(Some(title), _) => format!("agent asks: {title}"),
(None, [question]) => format!("agent asks: {}", question.question),
(None, questions) => format!("agent asks {} questions", questions.len()),
}
}
fn previous_word_boundary(input: &str, cursor: usize) -> usize {
let chars: Vec<char> = input.chars().collect();
let mut index = cursor.min(chars.len());
while index > 0 && chars[index - 1].is_whitespace() {
index -= 1;
}
while index > 0 && !chars[index - 1].is_whitespace() {
index -= 1;
}
index
}
fn next_word_boundary(input: &str, cursor: usize) -> usize {
let chars: Vec<char> = input.chars().collect();
let mut index = cursor.min(chars.len());
while index < chars.len() && chars[index].is_whitespace() {
index += 1;
}
while index < chars.len() && !chars[index].is_whitespace() {
index += 1;
}
index
}
#[cfg(test)]
#[path = "questionnaire_tests.rs"]
mod tests;